diff --git a/packages/twenty-front/src/modules/activities/calendar/right-drawer/components/RightDrawerCalendarEvent.tsx b/packages/twenty-front/src/modules/activities/calendar/right-drawer/components/RightDrawerCalendarEvent.tsx index 06aa66012..2407dfe55 100644 --- a/packages/twenty-front/src/modules/activities/calendar/right-drawer/components/RightDrawerCalendarEvent.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/right-drawer/components/RightDrawerCalendarEvent.tsx @@ -5,17 +5,17 @@ import { FIND_ONE_CALENDAR_EVENT_OPERATION_SIGNATURE } from '@/activities/calend import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState'; -import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; export const RightDrawerCalendarEvent = () => { - const { setRecords } = useSetRecordInStore(); + const { upsertRecords } = useUpsertRecordsInStore(); const viewableRecordId = useRecoilValue(viewableRecordIdState); const { record: calendarEvent } = useFindOneRecord({ objectNameSingular: FIND_ONE_CALENDAR_EVENT_OPERATION_SIGNATURE.objectNameSingular, objectRecordId: viewableRecordId ?? '', recordGqlFields: FIND_ONE_CALENDAR_EVENT_OPERATION_SIGNATURE.fields, - onCompleted: (record) => setRecords([record]), + onCompleted: (record) => upsertRecords([record]), }); if (!calendarEvent) { diff --git a/packages/twenty-front/src/modules/activities/components/ActivityEditorFields.tsx b/packages/twenty-front/src/modules/activities/components/ActivityEditorFields.tsx index 9f221d840..e8b189258 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityEditorFields.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityEditorFields.tsx @@ -86,14 +86,6 @@ export const ActivityEditorFields = ({ customUseUpdateOneObjectHook: useUpsertOneActivityMutation, }); - const { FieldContextProvider: ActivityTargetsContextProvider } = - useFieldContext({ - objectNameSingular: CoreObjectNameSingular.Activity, - objectRecordId: activityId, - fieldMetadataName: 'activityTargets', - fieldPosition: 3, - }); - return ( {activity.type === 'Task' && @@ -112,16 +104,12 @@ export const ActivityEditorFields = ({ )} - {ActivityTargetsContextProvider && - isDefined(activityFromCache) && - isRightDrawerAnimationCompleted && ( - - - - )} + {isDefined(activityFromCache) && isRightDrawerAnimationCompleted && ( + + )} ); }; diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts index b63e429ef..47c30f4d0 100644 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts @@ -8,11 +8,11 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState'; -import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; export const useRightDrawerEmailThread = () => { const viewableRecordId = useRecoilValue(viewableRecordIdState); - const { setRecords } = useSetRecordInStore(); + const { upsertRecords } = useUpsertRecordsInStore(); const { record: thread } = useFindOneRecord({ objectNameSingular: CoreObjectNameSingular.MessageThread, @@ -20,7 +20,7 @@ export const useRightDrawerEmailThread = () => { recordGqlFields: { id: true, }, - onCompleted: (record) => setRecords([record]), + onCompleted: (record) => upsertRecords([record]), }); const FETCH_ALL_MESSAGES_OPERATION_SIGNATURE = diff --git a/packages/twenty-front/src/modules/activities/hooks/useObjectRecordMultiSelectScopedStates.ts b/packages/twenty-front/src/modules/activities/hooks/useObjectRecordMultiSelectScopedStates.ts new file mode 100644 index 000000000..b49c9aefa --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/useObjectRecordMultiSelectScopedStates.ts @@ -0,0 +1,35 @@ +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, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetInlineCellEditMode.tsx b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetInlineCellEditMode.tsx index bec5740b5..0b85e2007 100644 --- a/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetInlineCellEditMode.tsx +++ b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetInlineCellEditMode.tsx @@ -1,9 +1,10 @@ import styled from '@emotion/styled'; -import { isNonEmptyArray, isNull } from '@sniptt/guards'; -import { useRecoilState, useSetRecoilState } from 'recoil'; +import { isNull } from '@sniptt/guards'; +import { useRecoilCallback, useRecoilState, useSetRecoilState } from 'recoil'; import { v4 } from 'uuid'; import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity'; +import { ActivityTargetObjectRecordEffect } from '@/activities/inline-cell/components/ActivityTargetObjectRecordEffect'; import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState'; import { Activity } from '@/activities/types/Activity'; import { ActivityTarget } from '@/activities/types/ActivityTarget'; @@ -15,10 +16,17 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { useCreateManyRecordsInCache } from '@/object-record/cache/hooks/useCreateManyRecordsInCache'; import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords'; import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; +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 { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; -import { MultipleObjectRecordSelect } from '@/object-record/relation-picker/components/MultipleObjectRecordSelect'; -import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; +import { ActivityTargetInlineCellEditModeMultiRecordsEffect } from '@/object-record/relation-picker/components/ActivityTargetInlineCellEditModeMultiRecordsEffect'; +import { MultiRecordSelect } from '@/object-record/relation-picker/components/MultiRecordSelect'; +import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope'; import { prefillRecord } from '@/object-record/utils/prefillRecord'; const StyledSelectContainer = styled.div` @@ -37,6 +45,7 @@ export const ActivityTargetInlineCellEditMode = ({ activityTargetWithTargetRecords, }: ActivityTargetInlineCellEditModeProps) => { const [isActivityInCreateMode] = useRecoilState(isActivityInCreateModeState); + const relationPickerScopeId = `relation-picker-${activity.id}`; const selectedTargetObjectIds = activityTargetWithTargetRecords.map( (activityTarget) => ({ @@ -74,109 +83,181 @@ export const ActivityTargetInlineCellEditMode = ({ objectNameSingular: CoreObjectNameSingular.ActivityTarget, }); - const handleSubmit = async (selectedRecords: ObjectRecordForSelect[]) => { - closeEditableField(); + const handleSubmit = useRecoilCallback( + ({ snapshot }) => + async () => { + const activityTargetsAfterUpdate = + activityTargetWithTargetRecords.filter((activityTarget) => { + const record = snapshot + .getLoadable( + objectRecordMultiSelectComponentFamilyState({ + scopeId: relationPickerScopeId, + familyKey: activityTarget.targetObject.id, + }), + ) + .getValue() as ObjectRecordAndSelected; - const activityTargetsToDelete = activityTargetWithTargetRecords.filter( - (activityTargetObjectRecord) => - !selectedRecords.some( - (selectedRecord) => - selectedRecord.recordIdentifier.id === - activityTargetObjectRecord.targetObject.id, - ), - ); + return record.selected; + }); + setActivityFromStore((currentActivity) => { + if (isNull(currentActivity)) { + return null; + } - const selectedTargetObjectsToCreate = selectedRecords.filter( - (selectedRecord) => - !activityTargetWithTargetRecords.some( - (activityTargetWithTargetRecord) => - activityTargetWithTargetRecord.targetObject.id === - selectedRecord.recordIdentifier.id, - ), - ); - - const existingActivityTargets = activityTargetWithTargetRecords.map( - (activityTargetObjectRecord) => activityTargetObjectRecord.activityTarget, - ); - - let activityTargetsAfterUpdate = Array.from(existingActivityTargets); - - const activityTargetsToCreate = selectedTargetObjectsToCreate.map( - (selectedRecord) => { - const emptyActivityTarget = prefillRecord({ - objectMetadataItem: objectMetadataItemActivityTarget, - input: { - id: v4(), - activityId: activity.id, - activity, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - [getActivityTargetObjectFieldName({ - nameSingular: selectedRecord.objectMetadataItem.nameSingular, - })]: selectedRecord.record, - [getActivityTargetObjectFieldIdName({ - nameSingular: selectedRecord.objectMetadataItem.nameSingular, - })]: selectedRecord.recordIdentifier.id, - }, + return { + ...currentActivity, + activityTargets: activityTargetsAfterUpdate, + }; }); - - return emptyActivityTarget; + closeEditableField(); }, - ); + [ + activityTargetWithTargetRecords, + closeEditableField, + relationPickerScopeId, + setActivityFromStore, + ], + ); - activityTargetsAfterUpdate.push(...activityTargetsToCreate); - - if (isNonEmptyArray(activityTargetsToDelete)) { - activityTargetsAfterUpdate = activityTargetsAfterUpdate.filter( - (activityTarget) => - !activityTargetsToDelete.some( - (activityTargetToDelete) => - activityTargetToDelete.activityTarget.id === activityTarget.id, - ), - ); - } - - if (isActivityInCreateMode) { - createManyActivityTargetsInCache(activityTargetsToCreate); - upsertActivity({ - activity, - input: { - activityTargets: activityTargetsAfterUpdate, - }, - }); - } else { - if (activityTargetsToCreate.length > 0) { - await createManyActivityTargets(activityTargetsToCreate); - } - - if (activityTargetsToDelete.length > 0) { - await deleteManyActivityTargets( - activityTargetsToDelete.map( - (activityTargetObjectRecord) => - activityTargetObjectRecord.activityTarget.id, - ), + const handleChange = useRecoilCallback( + ({ snapshot, set }) => + async (recordId: string) => { + const existingActivityTargets = activityTargetWithTargetRecords.map( + (activityTargetObjectRecord) => + activityTargetObjectRecord.activityTarget, ); - } - } - setActivityFromStore((currentActivity) => { - if (isNull(currentActivity)) { - return null; - } + let activityTargetsAfterUpdate = Array.from(existingActivityTargets); - return { - ...currentActivity, - activityTargets: activityTargetsAfterUpdate, - }; - }); - }; + const previouslyCheckedRecordsIds = snapshot + .getLoadable( + objectRecordMultiSelectCheckedRecordsIdsComponentState({ + scopeId: relationPickerScopeId, + }), + ) + .getValue(); + + const isNewlySelected = !previouslyCheckedRecordsIds.includes(recordId); + + if (isNewlySelected) { + const record = snapshot + .getLoadable( + objectRecordMultiSelectComponentFamilyState({ + scopeId: relationPickerScopeId, + familyKey: recordId, + }), + ) + .getValue(); + + if (!record) { + throw new Error( + `Could not find selected record with id ${recordId}`, + ); + } + + set( + objectRecordMultiSelectCheckedRecordsIdsComponentState({ + scopeId: relationPickerScopeId, + }), + (prev) => [...prev, recordId], + ); + + const newActivityTargetId = v4(); + const fieldName = getActivityTargetObjectFieldName({ + nameSingular: record.objectMetadataItem.nameSingular, + }); + const fieldNameWithIdSuffix = getActivityTargetObjectFieldIdName({ + nameSingular: record.objectMetadataItem.nameSingular, + }); + const newActivityTarget = prefillRecord({ + objectMetadataItem: objectMetadataItemActivityTarget, + input: { + id: newActivityTargetId, + activityId: activity.id, + activity, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + [fieldName]: record.record, + [fieldNameWithIdSuffix]: recordId, + }, + }); + + activityTargetsAfterUpdate.push(newActivityTarget); + + if (isActivityInCreateMode) { + createManyActivityTargetsInCache([newActivityTarget]); + upsertActivity({ + activity, + input: { + activityTargets: activityTargetsAfterUpdate, + }, + }); + } else { + await createManyActivityTargets([newActivityTarget]); + } + + 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: relationPickerScopeId, + }), + previouslyCheckedRecordsIds.filter((id) => id !== recordId), + ); + activityTargetsAfterUpdate = activityTargetsAfterUpdate.filter( + (activityTarget) => activityTarget.id !== activityTargetToDeleteId, + ); + + if (isActivityInCreateMode) { + upsertActivity({ + activity, + input: { + activityTargets: activityTargetsAfterUpdate, + }, + }); + } else { + await deleteManyActivityTargets([activityTargetToDeleteId]); + } + + set(activityTargetObjectRecordFamilyState(recordId), { + activityTargetId: null, + }); + } + }, + [ + activity, + activityTargetWithTargetRecords, + createManyActivityTargets, + createManyActivityTargetsInCache, + deleteManyActivityTargets, + isActivityInCreateMode, + objectMetadataItemActivityTarget, + relationPickerScopeId, + upsertActivity, + ], + ); return ( - + + + + + ); }; diff --git a/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetObjectRecordEffect.tsx b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetObjectRecordEffect.tsx new file mode 100644 index 000000000..aee9ea49d --- /dev/null +++ b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetObjectRecordEffect.tsx @@ -0,0 +1,42 @@ +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 <>; +}; diff --git a/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx index 2755acefd..f1490e1d4 100644 --- a/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx +++ b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx @@ -7,6 +7,8 @@ import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTa import { ActivityTargetInlineCellEditMode } from '@/activities/inline-cell/components/ActivityTargetInlineCellEditMode'; import { Activity } from '@/activities/types/Activity'; import { ActivityEditorHotkeyScope } from '@/activities/types/ActivityEditorHotkeyScope'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFieldContext } from '@/object-record/hooks/useFieldContext'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { FieldFocusContextProvider } from '@/object-record/record-field/contexts/FieldFocusContextProvider'; import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope'; @@ -41,33 +43,45 @@ export const ActivityTargetsInlineCell = ({ ActivityEditorHotkeyScope.ActivityTargets, ); + const { FieldContextProvider: ActivityTargetsContextProvider } = + useFieldContext({ + objectNameSingular: CoreObjectNameSingular.Activity, + objectRecordId: activity.id, + fieldMetadataName: 'activityTargets', + fieldPosition: 3, + overridenIsFieldEmpty: activityTargetObjectRecords.length === 0, + }); + return ( - + + } + label="Relations" + displayModeContent={ + + } /> - } - label="Relations" - displayModeContent={ - - } - isDisplayModeContentEmpty={activityTargetObjectRecords.length === 0} - /> + + )} ); diff --git a/packages/twenty-front/src/modules/activities/states/objectRecordsIdsMultiSelectComponentState.ts b/packages/twenty-front/src/modules/activities/states/objectRecordsIdsMultiSelectComponentState.ts new file mode 100644 index 000000000..67959df63 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/states/objectRecordsIdsMultiSelectComponentState.ts @@ -0,0 +1,8 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const objectRecordsIdsMultiSelecComponentState = createComponentState< + string[] +>({ + key: 'objectRecordsIdsMultiSelectComponentState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/activities/timeline/components/TimelineQueryEffect.tsx b/packages/twenty-front/src/modules/activities/timeline/components/TimelineQueryEffect.tsx index 88c966911..60367044b 100644 --- a/packages/twenty-front/src/modules/activities/timeline/components/TimelineQueryEffect.tsx +++ b/packages/twenty-front/src/modules/activities/timeline/components/TimelineQueryEffect.tsx @@ -4,7 +4,7 @@ import { useRecoilCallback, useRecoilState, useSetRecoilState } from 'recoil'; import { useActivities } from '@/activities/hooks/useActivities'; import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timeline/constants/FindManyTimelineActivitiesOrderBy'; import { objectShowPageTargetableObjectState } from '@/activities/timeline/states/objectShowPageTargetableObjectIdState'; -import { timelineActivitiesFammilyState } from '@/activities/timeline/states/timelineActivitiesFamilyState'; +import { timelineActivitiesFamilyState } from '@/activities/timeline/states/timelineActivitiesFamilyState'; import { timelineActivitiesForGroupState } from '@/activities/timeline/states/timelineActivitiesForGroupState'; import { timelineActivityWithoutTargetsFamilyState } from '@/activities/timeline/states/timelineActivityWithoutTargetsFamilyState'; import { Activity } from '@/activities/types/Activity'; @@ -68,11 +68,11 @@ export const TimelineQueryEffect = ({ (newActivities: Activity[]) => { for (const newActivity of newActivities) { const currentActivity = snapshot - .getLoadable(timelineActivitiesFammilyState(newActivity.id)) + .getLoadable(timelineActivitiesFamilyState(newActivity.id)) .getValue(); if (!isDeeplyEqual(newActivity, currentActivity)) { - set(timelineActivitiesFammilyState(newActivity.id), newActivity); + set(timelineActivitiesFamilyState(newActivity.id), newActivity); } const currentActivityWithoutTarget = snapshot diff --git a/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesFamilyState.ts b/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesFamilyState.ts index f2acc7a33..88903b6c8 100644 --- a/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesFamilyState.ts +++ b/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesFamilyState.ts @@ -1,10 +1,10 @@ import { Activity } from '@/activities/types/Activity'; import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; -export const timelineActivitiesFammilyState = createFamilyState< +export const timelineActivitiesFamilyState = createFamilyState< Activity | null, string >({ - key: 'timelineActivitiesFammilyState', + key: 'timelineActivitiesFamilyState', defaultValue: null, }); diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx index 413a70e21..8d572594f 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx @@ -5,7 +5,7 @@ import { useOpenCalendarEventRightDrawer } from '@/activities/calendar/right-dra import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; -import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; import { formatToHumanReadableDay, formatToHumanReadableMonth, @@ -85,7 +85,7 @@ export const EventCardCalendarEvent = ({ }: { calendarEventId: string; }) => { - const { setRecords } = useSetRecordInStore(); + const { upsertRecords } = useUpsertRecordsInStore(); const { record: calendarEvent, @@ -101,7 +101,7 @@ export const EventCardCalendarEvent = ({ endsAt: true, }, onCompleted: (data) => { - setRecords([data]); + upsertRecords([data]); }, }); diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessage.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessage.tsx index 9c2b41dd4..134dd95ba 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessage.tsx +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessage.tsx @@ -7,7 +7,7 @@ import { EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage import { EventCardMessageNotShared } from '@/activities/timelineActivities/rows/message/components/EventCardMessageNotShared'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; -import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; import { isDefined } from '~/utils/isDefined'; const StyledEventCardMessageContainer = styled.div` @@ -56,7 +56,7 @@ export const EventCardMessage = ({ messageId: string; authorFullName: string; }) => { - const { setRecords } = useSetRecordInStore(); + const { upsertRecords } = useUpsertRecordsInStore(); const { record: message, @@ -75,7 +75,7 @@ export const EventCardMessage = ({ }, }, onCompleted: (data) => { - setRecords([data]); + upsertRecords([data]); }, }); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts index 0b12d7524..16d103532 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts @@ -70,6 +70,7 @@ export const triggerUpdateRelationsOptimisticEffect = ({ isDeeplyEqual( currentFieldValueOnSourceRecord, updatedFieldValueOnSourceRecord, + { strict: true }, ) ) { return; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts index 222070f21..fe2581b8c 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts @@ -1,6 +1,8 @@ import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { isDefined } from '~/utils/isDefined'; +import { + FieldMetadataType, + RelationDefinitionType, +} from '~/generated-metadata/graphql'; import { ObjectMetadataItem } from '../types/ObjectMetadataItem'; @@ -10,6 +12,15 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({ fields: Array; }): FilterDefinition[] => fields.reduce((acc, field) => { + if ( + field.type === FieldMetadataType.Relation && + field.relationDefinition?.direction !== + RelationDefinitionType.ManyToOne && + field.relationDefinition?.direction !== RelationDefinitionType.OneToOne + ) { + return acc; + } + if ( ![ FieldMetadataType.DateTime, @@ -33,12 +44,6 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({ return acc; } - if (field.type === FieldMetadataType.Relation) { - if (isDefined(field.fromRelationMetadata)) { - return acc; - } - } - return [...acc, formatFieldMetadataItemAsFilterDefinition({ field })]; }, [] as FilterDefinition[]); @@ -51,9 +56,9 @@ export const formatFieldMetadataItemAsFilterDefinition = ({ label: field.label, iconName: field.icon ?? 'Icon123', relationObjectMetadataNamePlural: - field.toRelationMetadata?.fromObjectMetadata.namePlural, + field.relationDefinition?.targetObjectMetadata.namePlural, relationObjectMetadataNameSingular: - field.toRelationMetadata?.fromObjectMetadata.nameSingular, + field.relationDefinition?.targetObjectMetadata.nameSingular, type: getFilterTypeFromFieldType(field.type), }); diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts index f162131f5..d7faf5a77 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts @@ -4,6 +4,7 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename'; import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords'; +import { getRefName } from '@/object-record/cache/utils/getRefName'; import { RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { @@ -39,7 +40,7 @@ export const getRecordNodeFromRecord = ({ if (!isRootLevel && computeReferences) { return { - __ref: `${nodeTypeName}:${record.id}`, + __ref: getRefName(objectMetadataItem.nameSingular, record.id), } as unknown as RecordGqlNode; // Fix typing: we want a Reference in computeReferences mode } diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRefName.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRefName.ts new file mode 100644 index 000000000..3a2e12a88 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRefName.ts @@ -0,0 +1,7 @@ +import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename'; + +export const getRefName = (objectNameSingular: string, id: string) => { + const nodeTypeName = getNodeTypename(objectNameSingular); + + return `${nodeTypeName}:${id}`; +}; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/updateRecordFromCache.ts b/packages/twenty-front/src/modules/object-record/cache/utils/updateRecordFromCache.ts index 7ce423c09..26df70cec 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/updateRecordFromCache.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/updateRecordFromCache.ts @@ -13,11 +13,13 @@ export const updateRecordFromCache = ({ objectMetadataItems, objectMetadataItem, cache, + recordGqlFields = undefined, record, }: { objectMetadataItems: ObjectMetadataItem[]; objectMetadataItem: ObjectMetadataItem; cache: ApolloCache; + recordGqlFields?: Record; record: T; }) => { if (isUndefinedOrNull(objectMetadataItem)) { @@ -32,6 +34,7 @@ export const updateRecordFromCache = ({ objectMetadataItems, objectMetadataItem, computeReferences: true, + recordGqlFields, }, )} `; diff --git a/packages/twenty-front/src/modules/object-record/graphql/utils/generateDepthOneRecordGqlFields.ts b/packages/twenty-front/src/modules/object-record/graphql/utils/generateDepthOneRecordGqlFields.ts index ce52cd786..7870c00cc 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/utils/generateDepthOneRecordGqlFields.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/utils/generateDepthOneRecordGqlFields.ts @@ -1,14 +1,31 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { isDefined } from '~/utils/isDefined'; export const generateDepthOneRecordGqlFields = ({ objectMetadataItem, + record, }: { objectMetadataItem: ObjectMetadataItem; + record?: Record; }) => { - return objectMetadataItem.fields.reduce((acc, field) => { - return { - ...acc, - [field.name]: true, - }; - }, {}); + const gqlFieldsFromObjectMetadataItem = objectMetadataItem.fields.reduce( + (acc, field) => { + return { + ...acc, + [field.name]: true, + }; + }, + {}, + ); + + if (isDefined(record)) { + return Object.keys(gqlFieldsFromObjectMetadataItem).reduce((acc, key) => { + return { + ...acc, + [key]: Object.keys(record).includes(key), + }; + }, gqlFieldsFromObjectMetadataItem); + } + + return gqlFieldsFromObjectMetadataItem; }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useAttachRelatedRecordFromRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useAttachRelatedRecordFromRecord.ts new file mode 100644 index 000000000..17c80b4c1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useAttachRelatedRecordFromRecord.ts @@ -0,0 +1,113 @@ +import { useApolloClient } from '@apollo/client'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; +import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; +import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isDefined } from '~/utils/isDefined'; + +type useAttachRelatedRecordFromRecordProps = { + recordObjectNameSingular: string; + fieldNameOnRecordObject: string; +}; + +export const useAttachRelatedRecordFromRecord = ({ + recordObjectNameSingular, + fieldNameOnRecordObject, +}: useAttachRelatedRecordFromRecordProps) => { + const apolloClient = useApolloClient(); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: recordObjectNameSingular, + }); + + const fieldOnObject = objectMetadataItem.fields.find((field) => { + return field.name === fieldNameOnRecordObject; + }); + + const relatedRecordObjectNameSingular = + fieldOnObject?.relationDefinition?.targetObjectMetadata.nameSingular; + + if (!relatedRecordObjectNameSingular) { + throw new Error( + `Could not find record related to ${recordObjectNameSingular}`, + ); + } + const { objectMetadataItem: relatedObjectMetadataItem } = + useObjectMetadataItem({ + objectNameSingular: relatedRecordObjectNameSingular, + }); + + const fieldOnRelatedObject = + fieldOnObject?.relationDefinition?.targetFieldMetadata.name; + + if (!fieldOnRelatedObject) { + throw new Error(`Missing target field for ${fieldNameOnRecordObject}`); + } + + const { updateOneRecord } = useUpdateOneRecord({ + objectNameSingular: relatedRecordObjectNameSingular, + }); + + const getRecordFromCache = useGetRecordFromCache({ + objectNameSingular: recordObjectNameSingular, + }); + + const getRelatedRecordFromCache = useGetRecordFromCache({ + objectNameSingular: relatedRecordObjectNameSingular, + }); + + const { objectMetadataItems } = useObjectMetadataItems(); + + const updateOneRecordAndAttachRelations = async ({ + recordId, + relatedRecordId, + }: { + recordId: string; + relatedRecordId: string; + }) => { + const cachedRelatedRecord = + getRelatedRecordFromCache(relatedRecordId); + + if (!cachedRelatedRecord) { + throw new Error('could not find cached related record'); + } + + const previousRecordId = cachedRelatedRecord?.[`${fieldOnRelatedObject}Id`]; + + if (isDefined(previousRecordId)) { + const previousRecord = getRecordFromCache(previousRecordId); + + const previousRecordWithRelation = { + ...cachedRelatedRecord, + [fieldOnRelatedObject]: previousRecord, + }; + const gqlFields = generateDepthOneRecordGqlFields({ + objectMetadataItem: relatedObjectMetadataItem, + record: previousRecordWithRelation, + }); + updateRecordFromCache({ + objectMetadataItems, + objectMetadataItem: relatedObjectMetadataItem, + cache: apolloClient.cache, + record: { + ...cachedRelatedRecord, + [fieldOnRelatedObject]: previousRecord, + }, + recordGqlFields: gqlFields, + }); + } + + await updateOneRecord({ + idToUpdate: relatedRecordId, + updateOneRecordInput: { + [`${fieldOnRelatedObject}Id`]: recordId, + }, + }); + }; + + return { updateOneRecordAndAttachRelations }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDetachRelatedRecordFromRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useDetachRelatedRecordFromRecord.ts new file mode 100644 index 000000000..b441ab37a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useDetachRelatedRecordFromRecord.ts @@ -0,0 +1,88 @@ +import { Reference, useApolloClient } from '@apollo/client'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { getRefName } from '@/object-record/cache/utils/getRefName'; +import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; + +type useDetachRelatedRecordFromRecordProps = { + recordObjectNameSingular: string; + fieldNameOnRecordObject: string; +}; + +export const useDetachRelatedRecordFromRecord = ({ + recordObjectNameSingular, + fieldNameOnRecordObject, +}: useDetachRelatedRecordFromRecordProps) => { + const apolloClient = useApolloClient(); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: recordObjectNameSingular, + }); + + const fieldOnObject = objectMetadataItem.fields.find((field) => { + return field.name === fieldNameOnRecordObject; + }); + + const relatedRecordObjectNameSingular = + fieldOnObject?.relationDefinition?.targetObjectMetadata.nameSingular; + + const fieldOnRelatedObject = + fieldOnObject?.relationDefinition?.targetFieldMetadata.name; + + if (!relatedRecordObjectNameSingular) { + throw new Error( + `Could not find record related to ${recordObjectNameSingular}`, + ); + } + + const { updateOneRecord } = useUpdateOneRecord({ + objectNameSingular: relatedRecordObjectNameSingular, + }); + + const updateOneRecordAndDetachRelations = async ({ + recordId, + relatedRecordId, + }: { + recordId: string; + relatedRecordId: string; + }) => { + modifyRecordFromCache({ + objectMetadataItem, + cache: apolloClient.cache, + fieldModifiers: { + [fieldNameOnRecordObject]: ( + fieldNameOnRecordObjectConnection, + { readField }, + ) => { + const edges = readField<{ node: Reference }[]>( + 'edges', + fieldNameOnRecordObjectConnection, + ); + + if (!edges) return fieldNameOnRecordObjectConnection; + + return { + ...fieldNameOnRecordObjectConnection, + edges: edges.filter( + (edge) => + !( + edge.node.__ref === + getRefName(relatedRecordObjectNameSingular, relatedRecordId) + ), + ), + }; + }, + }, + recordId, + }); + await updateOneRecord({ + idToUpdate: relatedRecordId, + updateOneRecordInput: { + [`${fieldOnRelatedObject}Id`]: null, + }, + }); + }; + + return { updateOneRecordAndDetachRelations }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFieldContext.tsx b/packages/twenty-front/src/modules/object-record/hooks/useFieldContext.tsx index cbd002af7..9a4be7849 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFieldContext.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/useFieldContext.tsx @@ -19,6 +19,7 @@ export const useFieldContext = ({ objectNameSingular, objectRecordId, customUseUpdateOneObjectHook, + overridenIsFieldEmpty, }: { clearable?: boolean; fieldMetadataName: string; @@ -27,6 +28,7 @@ export const useFieldContext = ({ objectNameSingular: string; objectRecordId: string; customUseUpdateOneObjectHook?: RecordUpdateHook; + overridenIsFieldEmpty?: boolean; }) => { const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, @@ -78,6 +80,7 @@ export const useFieldContext = ({ customUseUpdateOneObjectHook ?? useUpdateOneObjectMutation, hotkeyScope: InlineCellHotkeyScope.InlineCell, clearable, + overridenIsFieldEmpty, }} > {children} diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts index fc0b5a1eb..427c48547 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts @@ -47,9 +47,11 @@ export const useUpdateOneRecord = < const updateOneRecord = async ({ idToUpdate, updateOneRecordInput, + optimisticRecord, }: { idToUpdate: string; updateOneRecordInput: Partial>; + optimisticRecord?: Partial; }) => { const sanitizedInput = { ...sanitizeRecordInput({ @@ -68,16 +70,16 @@ export const useUpdateOneRecord = < computeReferences: true, }); - const optimisticRecord = { + const computedOptimisticRecord = { ...cachedRecord, - ...sanitizedInput, + ...(optimisticRecord ?? sanitizedInput), ...{ id: idToUpdate }, ...{ __typename: capitalize(objectMetadataItem.nameSingular) }, }; const optimisticRecordWithConnection = getRecordNodeFromRecord({ - record: optimisticRecord, + record: computedOptimisticRecord, objectMetadataItem, objectMetadataItems, recordGqlFields: computedRecordGqlFields, @@ -92,7 +94,7 @@ export const useUpdateOneRecord = < objectMetadataItems, objectMetadataItem, cache: apolloClient.cache, - record: optimisticRecord, + record: computedOptimisticRecord, }); triggerUpdateRecordOptimisticEffect({ diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx index 58642260a..c852eafa4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx @@ -3,10 +3,13 @@ import { useContext } from 'react'; import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/display/components/BooleanFieldDisplay'; import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay'; import { RatingFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RatingFieldDisplay'; +import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay'; import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean'; import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating'; +import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects'; +import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject'; import { isFieldChipDisplay } from '@/object-record/utils/getRecordChipGeneratorPerObjectPerField'; import { FieldContext } from '../contexts/FieldContext'; @@ -22,7 +25,7 @@ import { LinkFieldDisplay } from '../meta-types/display/components/LinkFieldDisp import { MultiSelectFieldDisplay } from '../meta-types/display/components/MultiSelectFieldDisplay'; import { NumberFieldDisplay } from '../meta-types/display/components/NumberFieldDisplay'; import { PhoneFieldDisplay } from '../meta-types/display/components/PhoneFieldDisplay'; -import { RelationFieldDisplay } from '../meta-types/display/components/RelationFieldDisplay'; +import { RelationToOneFieldDisplay } from '../meta-types/display/components/RelationToOneFieldDisplay'; import { SelectFieldDisplay } from '../meta-types/display/components/SelectFieldDisplay'; import { TextFieldDisplay } from '../meta-types/display/components/TextFieldDisplay'; import { UuidFieldDisplay } from '../meta-types/display/components/UuidFieldDisplay'; @@ -37,7 +40,6 @@ import { isFieldMultiSelect } from '../types/guards/isFieldMultiSelect'; import { isFieldNumber } from '../types/guards/isFieldNumber'; import { isFieldPhone } from '../types/guards/isFieldPhone'; import { isFieldRawJson } from '../types/guards/isFieldRawJson'; -import { isFieldRelation } from '../types/guards/isFieldRelation'; import { isFieldSelect } from '../types/guards/isFieldSelect'; import { isFieldText } from '../types/guards/isFieldText'; import { isFieldUuid } from '../types/guards/isFieldUuid'; @@ -49,8 +51,10 @@ export const FieldDisplay = () => { return isChipDisplay ? ( - ) : isFieldRelation(fieldDefinition) ? ( - + ) : isFieldRelationToOneObject(fieldDefinition) ? ( + + ) : isFieldRelationFromManyObjects(fieldDefinition) ? ( + ) : isFieldPhone(fieldDefinition) || isFieldDisplayedAsPhone(fieldDefinition) ? ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx index f13e59e3a..5ac286d7e 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx @@ -6,7 +6,7 @@ import { FullNameFieldInput } from '@/object-record/record-field/meta-types/inpu import { LinksFieldInput } from '@/object-record/record-field/meta-types/input/components/LinksFieldInput'; import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput'; import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput'; -import { RelationManyFieldInput } from '@/object-record/record-field/meta-types/input/components/RelationManyFieldInput'; +import { RelationFromManyFieldInput } from '@/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput'; import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput'; import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope'; import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate'; @@ -16,6 +16,7 @@ import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldL import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect'; import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects'; +import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject'; import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; @@ -28,7 +29,7 @@ import { LinkFieldInput } from '../meta-types/input/components/LinkFieldInput'; import { NumberFieldInput } from '../meta-types/input/components/NumberFieldInput'; import { PhoneFieldInput } from '../meta-types/input/components/PhoneFieldInput'; import { RatingFieldInput } from '../meta-types/input/components/RatingFieldInput'; -import { RelationFieldInput } from '../meta-types/input/components/RelationFieldInput'; +import { RelationToOneFieldInput } from '../meta-types/input/components/RelationToOneFieldInput'; import { TextFieldInput } from '../meta-types/input/components/TextFieldInput'; import { FieldInputEvent } from '../types/FieldInputEvent'; import { isFieldAddress } from '../types/guards/isFieldAddress'; @@ -40,7 +41,6 @@ import { isFieldLink } from '../types/guards/isFieldLink'; import { isFieldNumber } from '../types/guards/isFieldNumber'; import { isFieldPhone } from '../types/guards/isFieldPhone'; import { isFieldRating } from '../types/guards/isFieldRating'; -import { isFieldRelation } from '../types/guards/isFieldRelation'; import { isFieldText } from '../types/guards/isFieldText'; type FieldInputProps = { @@ -72,16 +72,10 @@ export const FieldInput = ({ - {isFieldRelation(fieldDefinition) ? ( - isFieldRelationFromManyObjects(fieldDefinition) ? ( - - ) : ( - - ) + {isFieldRelationToOneObject(fieldDefinition) ? ( + + ) : isFieldRelationFromManyObjects(fieldDefinition) ? ( + ) : isFieldPhone(fieldDefinition) || isFieldDisplayedAsPhone(fieldDefinition) ? ( ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/useIsFieldEmpty.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/useIsFieldEmpty.ts index 01c4573c0..9126d761a 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/useIsFieldEmpty.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/useIsFieldEmpty.ts @@ -2,17 +2,22 @@ import { useContext } from 'react'; import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty'; import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; +import { isDefined } from '~/utils/isDefined'; import { FieldContext } from '../contexts/FieldContext'; export const useIsFieldEmpty = () => { - const { entityId, fieldDefinition } = useContext(FieldContext); - + const { entityId, fieldDefinition, overridenIsFieldEmpty } = + useContext(FieldContext); const fieldValue = useRecordFieldValue( entityId, - fieldDefinition.metadata.fieldName, + fieldDefinition?.metadata?.fieldName ?? '', ); + if (isDefined(overridenIsFieldEmpty)) { + return overridenIsFieldEmpty; + } + return isFieldValueEmpty({ fieldDefinition, fieldValue, diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts index d2f1a1db8..c839b6053 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts @@ -15,7 +15,8 @@ import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/is import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue'; import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue'; -import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects'; +import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject'; +import { isFieldRelationToOneValue } from '@/object-record/record-field/types/guards/isFieldRelationToOneValue'; import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; import { isFieldSelectValue } from '@/object-record/record-field/types/guards/isFieldSelectValue'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; @@ -38,8 +39,6 @@ import { isFieldPhone } from '../types/guards/isFieldPhone'; import { isFieldPhoneValue } from '../types/guards/isFieldPhoneValue'; import { isFieldRating } from '../types/guards/isFieldRating'; import { isFieldRatingValue } from '../types/guards/isFieldRatingValue'; -import { isFieldRelation } from '../types/guards/isFieldRelation'; -import { isFieldRelationValue } from '../types/guards/isFieldRelationValue'; import { isFieldText } from '../types/guards/isFieldText'; import { isFieldTextValue } from '../types/guards/isFieldTextValue'; @@ -55,14 +54,10 @@ export const usePersistField = () => { const persistField = useRecoilCallback( ({ set }) => (valueToPersist: unknown) => { - const fieldIsRelation = - isFieldRelation(fieldDefinition) && - isFieldRelationValue(valueToPersist); - - const fieldIsRelationFromManyObjects = - isFieldRelationFromManyObjects( + const fieldIsRelationToOneObject = + isFieldRelationToOneObject( fieldDefinition as FieldDefinition, - ) && isFieldRelationValue(valueToPersist); + ) && isFieldRelationToOneValue(valueToPersist); const fieldIsText = isFieldText(fieldDefinition) && isFieldTextValue(valueToPersist); @@ -120,7 +115,7 @@ export const usePersistField = () => { isFieldRawJsonValue(valueToPersist); const isValuePersistable = - (fieldIsRelation && !fieldIsRelationFromManyObjects) || + fieldIsRelationToOneObject || fieldIsText || fieldIsBoolean || fieldIsEmail || @@ -145,7 +140,7 @@ export const usePersistField = () => { valueToPersist, ); - if (fieldIsRelation && !fieldIsRelationFromManyObjects) { + if (fieldIsRelationToOneObject) { const value = valueToPersist as EntityForSelect; updateRecord?.({ variables: { diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFieldDisplay.tsx deleted file mode 100644 index dace01237..000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFieldDisplay.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { isArray } from '@sniptt/guards'; -import { EntityChip } from 'twenty-ui'; - -import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay'; -import { useRelationFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFieldDisplay'; -import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64'; - -export const RelationFieldDisplay = () => { - const { fieldValue, fieldDefinition, generateRecordChipData } = - useRelationFieldDisplay(); - - if ( - !fieldValue || - !fieldDefinition?.metadata.relationObjectMetadataNameSingular - ) { - return null; - } - - if (isArray(fieldValue) && isFieldRelationFromManyObjects(fieldDefinition)) { - return ( - - ); - } - - const recordChipData = generateRecordChipData(fieldValue); - - return ( - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx index 808565a5a..44b05125d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx @@ -1,18 +1,21 @@ import { EntityChip } from 'twenty-ui'; import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus'; -import { useRelationFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFieldDisplay'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay'; import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64'; -export const RelationFromManyFieldDisplay = ({ - fieldValue, -}: { - fieldValue: ObjectRecord[]; -}) => { +export const RelationFromManyFieldDisplay = () => { + const { fieldValue, fieldDefinition, generateRecordChipData } = + useRelationFromManyFieldDisplay(); const { isFocused } = useFieldFocus(); - const { generateRecordChipData } = useRelationFieldDisplay(); + + if ( + !fieldValue || + !fieldDefinition?.metadata.relationObjectMetadataNameSingular + ) { + return null; + } const recordChipsData = fieldValue.map((fieldValueItem) => generateRecordChipData(fieldValueItem), diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationToOneFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationToOneFieldDisplay.tsx new file mode 100644 index 000000000..b0af7bdee --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationToOneFieldDisplay.tsx @@ -0,0 +1,28 @@ +import { EntityChip } from 'twenty-ui'; + +import { useRelationToOneFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay'; +import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64'; + +export const RelationToOneFieldDisplay = () => { + const { fieldValue, fieldDefinition, generateRecordChipData } = + useRelationToOneFieldDisplay(); + + if ( + !fieldValue || + !fieldDefinition?.metadata.relationObjectMetadataNameSingular + ) { + return null; + } + + const recordChipData = generateRecordChipData(fieldValue); + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/RelationFromManyFieldDisplay.perf.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/RelationFromManyFieldDisplay.perf.stories.tsx index 7c9ce09da..f4f2a3393 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/RelationFromManyFieldDisplay.perf.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/RelationFromManyFieldDisplay.perf.stories.tsx @@ -9,7 +9,7 @@ import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinit import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { RecordFieldValueSelectorContextProvider, - useSetRecordValue, + useSetRecordFieldValue, } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { ChipGeneratorsDecorator } from '~/testing/decorators/ChipGeneratorsDecorator'; @@ -18,6 +18,7 @@ import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory'; import { fieldValue, + otherPersonMock, relationFromManyFieldDisplayMock, } from './relationFromManyFieldDisplayMock'; @@ -30,21 +31,26 @@ const RelationFieldValueSetterEffect = () => { recordStoreFamilyState(relationFromManyFieldDisplayMock.relationEntityId), ); - const setRecordValue = useSetRecordValue(); + const setRecordFieldValue = useSetRecordFieldValue(); useEffect(() => { setEntity(relationFromManyFieldDisplayMock.entityValue); setRelationEntity(relationFromManyFieldDisplayMock.relationFieldValue); - setRecordValue( + setRecordFieldValue( relationFromManyFieldDisplayMock.entityValue.id, - relationFromManyFieldDisplayMock.entityValue, + 'company', + [relationFromManyFieldDisplayMock.entityValue], ); - setRecordValue( + setRecordFieldValue(otherPersonMock.entityValue.id, 'company', [ + relationFromManyFieldDisplayMock.entityValue, + ]); + setRecordFieldValue( relationFromManyFieldDisplayMock.relationFieldValue.id, + 'company', relationFromManyFieldDisplayMock.relationFieldValue, ); - }, [setEntity, setRelationEntity, setRecordValue]); + }, [setEntity, setRelationEntity, setRecordFieldValue]); return null; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/RelationFieldDisplay.perf.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/RelationToOneFieldDisplay.perf.stories.tsx similarity index 80% rename from packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/RelationFieldDisplay.perf.stories.tsx rename to packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/RelationToOneFieldDisplay.perf.stories.tsx index 6c985926d..49a076d80 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/RelationFieldDisplay.perf.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/RelationToOneFieldDisplay.perf.stories.tsx @@ -1,7 +1,7 @@ import { Meta, StoryObj } from '@storybook/react'; import { ComponentDecorator } from 'twenty-ui'; -import { RelationFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFieldDisplay'; +import { RelationToOneFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationToOneFieldDisplay'; import { ChipGeneratorsDecorator } from '~/testing/decorators/ChipGeneratorsDecorator'; import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator'; import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; @@ -15,7 +15,7 @@ const meta: Meta = { getFieldDecorator('person', 'company'), ComponentDecorator, ], - component: RelationFieldDisplay, + component: RelationToOneFieldDisplay, args: {}, parameters: { chromatic: { disableSnapshot: true }, @@ -24,7 +24,7 @@ const meta: Meta = { export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = {}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/relationFromManyFieldDisplayMock.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/relationFromManyFieldDisplayMock.ts index cbc63cb40..674d00fe9 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/relationFromManyFieldDisplayMock.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/relationFromManyFieldDisplayMock.ts @@ -59,6 +59,60 @@ export const fieldValue = [ }, ]; +export const otherPersonMock = { + entityValue: { + __typename: 'Person', + asd: '', + city: 'Paris', + jobTitle: '', + name: 'John Doe', + createdAt: '2024-05-01T13:16:29.046Z', + company: { + __typename: 'Company', + domainName: 'google.com', + xLink: { + __typename: 'Link', + label: '', + url: '', + }, + name: 'Google', + annualRecurringRevenue: { + __typename: 'Currency', + amountMicros: null, + currencyCode: '', + }, + employees: null, + accountOwnerId: null, + address: '', + idealCustomerProfile: false, + createdAt: '2024-05-01T13:16:29.046Z', + id: '20202020-c21e-4ec2-873b-de4264d89025', + position: 6, + updatedAt: '2024-05-01T13:16:29.046Z', + linkedinLink: { + __typename: 'Link', + label: '', + url: '', + }, + }, + id: 'd3e70589-c449-4e64-8268-065640fdaff0', + email: 'john.doe@google.com', + phone: '+33744332211', + linkedinLink: { + __typename: 'Link', + label: '', + url: '', + }, + xLink: { + __typename: 'Link', + label: '', + url: '', + }, + tEst: '', + position: 14, + }, +}; + export const relationFromManyFieldDisplayMock = { entityId: '20202020-2d40-4e49-8df4-9c6a049191df', relationEntityId: '20202020-c21e-4ec2-873b-de4264d89025', @@ -67,11 +121,7 @@ export const relationFromManyFieldDisplayMock = { asd: '', city: 'Seattle', jobTitle: '', - name: { - __typename: 'FullName', - firstName: 'Lorie', - lastName: 'Vladim', - }, + name: 'Lorie Vladim', createdAt: '2024-05-01T13:16:29.046Z', company: { __typename: 'Company', diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay.ts new file mode 100644 index 000000000..251852afd --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay.ts @@ -0,0 +1,63 @@ +import { useContext } from 'react'; +import { isNonEmptyString } from '@sniptt/guards'; + +import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext'; +import { generateDefaultRecordChipData } from '@/object-metadata/utils/generateDefaultRecordChipData'; +import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { FIELD_EDIT_BUTTON_WIDTH } from '@/ui/field/display/constants/FieldEditButtonWidth'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined'; + +import { FieldContext } from '../../contexts/FieldContext'; +import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; +import { isFieldRelation } from '../../types/guards/isFieldRelation'; + +export const useRelationFromManyFieldDisplay = () => { + const { entityId, fieldDefinition, maxWidth } = useContext(FieldContext); + + const { chipGeneratorPerObjectPerField } = useContext( + PreComputedChipGeneratorsContext, + ); + + if (!isDefined(chipGeneratorPerObjectPerField)) { + throw new Error('Chip generator per object per field is not defined'); + } + + assertFieldMetadata( + FieldMetadataType.Relation, + isFieldRelation, + fieldDefinition, + ); + + const button = fieldDefinition.editButtonIcon; + + const fieldName = fieldDefinition.metadata.fieldName; + + const fieldValue = useRecordFieldValue( + entityId, + fieldName, + ); + + const maxWidthForField = + isDefined(button) && isDefined(maxWidth) + ? maxWidth - FIELD_EDIT_BUTTON_WIDTH + : maxWidth; + + if (!isNonEmptyString(fieldDefinition.metadata.objectMetadataNameSingular)) { + throw new Error('Object metadata name singular is not a non-empty string'); + } + + const generateRecordChipData = + chipGeneratorPerObjectPerField[ + fieldDefinition.metadata.objectMetadataNameSingular + ]?.[fieldDefinition.metadata.fieldName] ?? generateDefaultRecordChipData; + + return { + fieldDefinition, + fieldValue, + maxWidth: maxWidthForField, + entityId, + generateRecordChipData, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay.ts similarity index 97% rename from packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFieldDisplay.ts rename to packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay.ts index 510cd7fbc..c33464dc3 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFieldDisplay.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay.ts @@ -13,7 +13,7 @@ import { FieldContext } from '../../contexts/FieldContext'; import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; import { isFieldRelation } from '../../types/guards/isFieldRelation'; -export const useRelationFieldDisplay = () => { +export const useRelationToOneFieldDisplay = () => { const { entityId, fieldDefinition, maxWidth } = useContext(FieldContext); const { chipGeneratorPerObjectPerField } = useContext( diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput.tsx new file mode 100644 index 000000000..92e1dc80c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput.tsx @@ -0,0 +1,37 @@ +import { useContext } from 'react'; + +import { ObjectMetadataItemsRelationPickerEffect } from '@/object-metadata/components/ObjectMetadataItemsRelationPickerEffect'; +import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { RelationFromManyFieldInputMultiRecordsEffect } from '@/object-record/record-field/meta-types/input/components/RelationFromManyFieldInputMultiRecordsEffect'; +import { useUpdateRelationFromManyFieldInput } from '@/object-record/record-field/meta-types/input/hooks/useUpdateRelationFromManyFieldInput'; +import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; +import { MultiRecordSelect } from '@/object-record/relation-picker/components/MultiRecordSelect'; +import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope'; + +export type RelationFromManyFieldInputProps = { + onSubmit?: FieldInputEvent; +}; + +export const RelationFromManyFieldInput = ({ + onSubmit, +}: RelationFromManyFieldInputProps) => { + const { fieldDefinition } = useContext(FieldContext); + const relationPickerScopeId = `relation-picker-${fieldDefinition.fieldMetadataId}`; + const { updateRelation } = useUpdateRelationFromManyFieldInput({ + scopeId: relationPickerScopeId, + }); + + const handleSubmit = () => { + onSubmit?.(() => {}); + }; + + return ( + <> + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInputMultiRecordsEffect.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInputMultiRecordsEffect.tsx new file mode 100644 index 000000000..060576f16 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInputMultiRecordsEffect.tsx @@ -0,0 +1,124 @@ +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 { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; +import { useRelationPickerEntitiesOptions } from '@/object-record/relation-picker/hooks/useRelationPickerEntitiesOptions'; +import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext'; +import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; +import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +export const RelationFromManyFieldInputMultiRecordsEffect = () => { + const { fieldValue, fieldDefinition } = useRelationField(); + const scopeId = useAvailableScopeIdOrThrow( + RelationPickerScopeInternalContext, + ); + const { + objectRecordsIdsMultiSelectState, + objectRecordMultiSelectCheckedRecordsIdsState, + recordMultiSelectIsLoadingState, + } = useObjectRecordMultiSelectScopedStates(scopeId); + const [objectRecordsIdsMultiSelect, setObjectRecordsIdsMultiSelect] = + useRecoilState(objectRecordsIdsMultiSelectState); + + const { entities } = useRelationPickerEntitiesOptions({ + relationObjectNameSingular: + fieldDefinition.metadata.relationObjectMetadataNameSingular, + }); + + const setRecordMultiSelectIsLoading = useSetRecoilState( + recordMultiSelectIsLoadingState, + ); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: + fieldDefinition.metadata.relationObjectMetadataNameSingular, + }); + + const allRecords = useMemo( + () => [ + ...entities.entitiesToSelect.map((entity) => { + const { record, ...recordIdentifier } = entity; + return { + objectMetadataItem: objectMetadataItem, + record: record, + recordIdentifier: recordIdentifier, + }; + }), + ], + [entities.entitiesToSelect, objectMetadataItem], + ); + + const [ + objectRecordMultiSelectCheckedRecordsIds, + setObjectRecordMultiSelectCheckedRecordsIds, + ] = useRecoilState(objectRecordMultiSelectCheckedRecordsIdsState); + + const updateRecords = useRecoilCallback( + ({ snapshot, set }) => + (newRecords: ObjectRecordForSelect[]) => { + for (const newRecord of newRecords) { + const currentRecord = snapshot + .getLoadable( + objectRecordMultiSelectComponentFamilyState({ + scopeId: scopeId, + familyKey: newRecord.record.id, + }), + ) + .getValue(); + + const newRecordWithSelected = { + ...newRecord, + selected: objectRecordMultiSelectCheckedRecordsIds.includes( + newRecord.record.id, + ), + }; + + if ( + !isDeeplyEqual( + newRecordWithSelected.selected, + currentRecord?.selected, + ) + ) { + set( + objectRecordMultiSelectComponentFamilyState({ + scopeId: scopeId, + familyKey: newRecordWithSelected.record.id, + }), + newRecordWithSelected, + ); + } + } + }, + [objectRecordMultiSelectCheckedRecordsIds, scopeId], + ); + + useEffect(() => { + updateRecords(allRecords); + const allRecordsIds = allRecords.map((record) => record.record.id); + if (!isDeeplyEqual(allRecordsIds, objectRecordsIdsMultiSelect)) { + setObjectRecordsIdsMultiSelect(allRecordsIds); + } + }, [ + allRecords, + objectRecordsIdsMultiSelect, + setObjectRecordsIdsMultiSelect, + updateRecords, + ]); + + useEffect(() => { + setObjectRecordMultiSelectCheckedRecordsIds( + fieldValue.map((fieldValueItem: EntityForSelect) => fieldValueItem.id), + ); + }, [fieldValue, setObjectRecordMultiSelectCheckedRecordsIds]); + + useEffect(() => { + setRecordMultiSelectIsLoading(entities.loading); + }, [entities.loading, setRecordMultiSelectIsLoading]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationManyFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationManyFieldInput.tsx deleted file mode 100644 index 9e3629528..000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationManyFieldInput.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useMemo } from 'react'; - -import { ObjectMetadataItemsRelationPickerEffect } from '@/object-metadata/components/ObjectMetadataItemsRelationPickerEffect'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { useUpdateRelationManyFieldInput } from '@/object-record/record-field/meta-types/input/hooks/useUpdateRelationManyFieldInput'; -import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell'; -import { MultiRecordSelect } from '@/object-record/relation-picker/components/MultiRecordSelect'; -import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker'; -import { useRelationPickerEntitiesOptions } from '@/object-record/relation-picker/hooks/useRelationPickerEntitiesOptions'; -import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; - -import { useRelationField } from '../../hooks/useRelationField'; - -export const RelationManyFieldInput = ({ - relationPickerScopeId = 'relation-picker', -}: { - relationPickerScopeId?: string; -}) => { - const { closeInlineCell: closeEditableField } = useInlineCell(); - - const { fieldDefinition, fieldValue } = useRelationField(); - const { entities, relationPickerSearchFilter } = - useRelationPickerEntitiesOptions({ - relationObjectNameSingular: - fieldDefinition.metadata.relationObjectMetadataNameSingular, - relationPickerScopeId, - }); - - const { setRelationPickerSearchFilter } = useRelationPicker({ - relationPickerScopeId, - }); - - const { handleChange } = useUpdateRelationManyFieldInput({ entities }); - - const { objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular: - fieldDefinition.metadata.relationObjectMetadataNameSingular, - }); - const allRecords = useMemo( - () => [ - ...entities.entitiesToSelect.map((entity) => { - const { record, ...recordIdentifier } = entity; - return { - objectMetadataItem: objectMetadataItem, - record: record, - recordIdentifier: recordIdentifier, - }; - }), - ], - [entities.entitiesToSelect, objectMetadataItem], - ); - - const selectedRecords = useMemo( - () => - allRecords.filter( - (entity) => - fieldValue?.some((f) => { - return f.id === entity.recordIdentifier.id; - }), - ), - [allRecords, fieldValue], - ); - - return ( - <> - - { - closeEditableField(); - }} - onChange={handleChange} - /> - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationToOneFieldInput.tsx similarity index 90% rename from packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFieldInput.tsx rename to packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationToOneFieldInput.tsx index ed752bef9..5366907c9 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationToOneFieldInput.tsx @@ -14,15 +14,15 @@ const StyledRelationPickerContainer = styled.div` top: -1px; `; -export type RelationFieldInputProps = { +export type RelationToOneFieldInputProps = { onSubmit?: FieldInputEvent; onCancel?: () => void; }; -export const RelationFieldInput = ({ +export const RelationToOneFieldInput = ({ onSubmit, onCancel, -}: RelationFieldInputProps) => { +}: RelationToOneFieldInputProps) => { const { fieldDefinition, initialSearchValue, fieldValue } = useRelationField(); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationManyFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationManyFieldInput.stories.tsx index b768056cb..04ee47744 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationManyFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationManyFieldInput.stories.tsx @@ -5,7 +5,7 @@ import { useSetRecoilState } from 'recoil'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { RelationManyFieldInput } from '@/object-record/record-field/meta-types/input/components/RelationManyFieldInput'; +import { RelationFromManyFieldInput } from '@/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { FieldMetadataType } from '~/generated/graphql'; import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator'; @@ -60,7 +60,7 @@ const RelationManyFieldInputWithContext = () => { entityId={'entityId'} > - +
diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationToOneFieldInput.stories.tsx similarity index 83% rename from packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationFieldInput.stories.tsx rename to packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationToOneFieldInput.stories.tsx index 9874373e8..30b723880 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationToOneFieldInput.stories.tsx @@ -13,6 +13,7 @@ import { useSetRecoilState } from 'recoil'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { FieldMetadataType } from '~/generated/graphql'; import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator'; @@ -26,9 +27,9 @@ import { import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; import { - RelationFieldInput, - RelationFieldInputProps, -} from '../RelationFieldInput'; + RelationToOneFieldInput, + RelationToOneFieldInputProps, +} from '../RelationToOneFieldInput'; const RelationWorkspaceSetterEffect = () => { const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); @@ -44,16 +45,16 @@ const RelationWorkspaceSetterEffect = () => { return <>; }; -type RelationFieldInputWithContextProps = RelationFieldInputProps & { +type RelationToOneFieldInputWithContextProps = RelationToOneFieldInputProps & { value: number; entityId?: string; }; -const RelationFieldInputWithContext = ({ +const RelationToOneFieldInputWithContext = ({ entityId, onSubmit, onCancel, -}: RelationFieldInputWithContextProps) => { +}: RelationToOneFieldInputWithContextProps) => { const setHotKeyScope = useSetHotkeyScope(); useEffect(() => { @@ -79,8 +80,12 @@ const RelationFieldInputWithContext = ({ }} entityId={entityId} > - - + + + +
@@ -99,8 +104,8 @@ const clearMocksDecorator: Decorator = (Story, context) => { }; const meta: Meta = { - title: 'UI/Data/Field/Input/RelationFieldInput', - component: RelationFieldInputWithContext, + title: 'UI/Data/Field/Input/RelationToOneFieldInput', + component: RelationToOneFieldInputWithContext, args: { useEditButton: true, onSubmit: submitJestFn, @@ -123,7 +128,7 @@ const meta: Meta = { export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { decorators: [ComponentWithRecoilScopeDecorator], diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useUpdateRelationFromManyFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useUpdateRelationFromManyFieldInput.tsx new file mode 100644 index 000000000..ec07c96c2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useUpdateRelationFromManyFieldInput.tsx @@ -0,0 +1,93 @@ +import { useContext } from 'react'; +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 { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const useUpdateRelationFromManyFieldInput = ({ + scopeId, +}: { + scopeId: string; +}) => { + const { entityId, fieldDefinition } = useContext(FieldContext); + + assertFieldMetadata( + FieldMetadataType.Relation, + isFieldRelation, + fieldDefinition, + ); + + if (!fieldDefinition.metadata.objectMetadataNameSingular) { + throw new Error('ObjectMetadataNameSingular is required'); + } + + const { updateOneRecordAndDetachRelations } = + useDetachRelatedRecordFromRecord({ + recordObjectNameSingular: + fieldDefinition.metadata.objectMetadataNameSingular, + fieldNameOnRecordObject: fieldDefinition.metadata.fieldName, + }); + + const { updateOneRecordAndAttachRelations } = + useAttachRelatedRecordFromRecord({ + recordObjectNameSingular: + fieldDefinition.metadata.objectMetadataNameSingular, + fieldNameOnRecordObject: fieldDefinition.metadata.fieldName, + }); + + 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: entityId, + relatedRecordId: objectRecordId, + }); + } else { + await updateOneRecordAndDetachRelations({ + recordId: entityId, + relatedRecordId: objectRecordId, + }); + } + }, + [ + entityId, + scopeId, + updateOneRecordAndAttachRelations, + updateOneRecordAndDetachRelations, + ], + ); + + return { updateRelation }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useUpdateRelationManyFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useUpdateRelationManyFieldInput.tsx deleted file mode 100644 index a0d274418..000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useUpdateRelationManyFieldInput.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; -import { useRelationField } from '@/object-record/record-field/meta-types/hooks/useRelationField'; -import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; -import { EntitiesForMultipleEntitySelect } from '@/object-record/relation-picker/types/EntitiesForMultipleEntitySelect'; -import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; -import { isDefined } from '~/utils/isDefined'; - -export const useUpdateRelationManyFieldInput = ({ - entities, -}: { - entities: EntitiesForMultipleEntitySelect; -}) => { - const { fieldDefinition, fieldValue, setFieldValue, entityId } = - useRelationField(); - - const { updateOneRecord } = useUpdateOneRecord({ - objectNameSingular: - fieldDefinition.metadata.relationObjectMetadataNameSingular, - }); - - const fieldName = fieldDefinition.metadata.targetFieldMetadataName; - - const handleChange = ( - objectRecord: ObjectRecordForSelect | null, - isSelected: boolean, - ) => { - const entityToAddOrRemove = entities.entitiesToSelect.find( - (entity) => entity.id === objectRecord?.recordIdentifier.id, - ); - - const updatedFieldValue = isSelected - ? [...(fieldValue ?? []), entityToAddOrRemove] - : (fieldValue ?? []).filter( - (value) => value.id !== objectRecord?.recordIdentifier.id, - ); - setFieldValue( - updatedFieldValue.filter((value) => - isDefined(value), - ) as EntityForSelect[], - ); - if (isDefined(objectRecord)) { - updateOneRecord({ - idToUpdate: objectRecord.record?.id, - updateOneRecordInput: { - [`${fieldName}Id`]: isSelected ? entityId : null, - }, - }); - } - }; - - return { handleChange }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/states/activityTargetObjectRecordFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-field/states/activityTargetObjectRecordFamilyState.ts new file mode 100644 index 000000000..45d54e009 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/states/activityTargetObjectRecordFamilyState.ts @@ -0,0 +1,13 @@ +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 }, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/states/objectRecordMultiSelectCheckedRecordsIdsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-field/states/objectRecordMultiSelectCheckedRecordsIdsComponentState.ts new file mode 100644 index 000000000..4f51db21d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/states/objectRecordMultiSelectCheckedRecordsIdsComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const objectRecordMultiSelectCheckedRecordsIdsComponentState = + createComponentState({ + key: 'objectRecordMultiSelectCheckedRecordsIdsComponentState', + defaultValue: [], + }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/states/objectRecordMultiSelectComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-field/states/objectRecordMultiSelectComponentFamilyState.ts new file mode 100644 index 000000000..0e15c962e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/states/objectRecordMultiSelectComponentFamilyState.ts @@ -0,0 +1,12 @@ +import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; +import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; + +export type ObjectRecordAndSelected = ObjectRecordForSelect & { + selected: boolean; +}; + +export const objectRecordMultiSelectComponentFamilyState = + createComponentFamilyState({ + key: 'objectRecordMultiSelectComponentFamilyState', + defaultValue: undefined, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/states/recordMultiSelectIsLoadingComponentState.ts b/packages/twenty-front/src/modules/object-record/record-field/states/recordMultiSelectIsLoadingComponentState.ts new file mode 100644 index 000000000..880207ecb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/states/recordMultiSelectIsLoadingComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordMultiSelectIsLoadingComponentState = + createComponentState({ + key: 'recordMultiSelectIsLoadingComponentState', + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts index 0282ccd02..ae1d63e54 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts @@ -13,12 +13,12 @@ import { FieldNumberValue, FieldPhoneValue, FieldRatingValue, - FieldRelationValue, + FieldRelationFromManyValue, + FieldRelationToOneValue, FieldSelectValue, FieldTextValue, FieldUUidValue, } from '@/object-record/record-field/types/FieldMetadata'; -import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; export type FieldTextDraftValue = string; export type FieldNumberDraftValue = string; @@ -28,6 +28,7 @@ export type FieldEmailDraftValue = string; export type FieldSelectDraftValue = string; export type FieldMultiSelectDraftValue = string[]; export type FieldRelationDraftValue = string; +export type FieldRelationManyDraftValue = string[]; export type FieldLinkDraftValue = { url: string; label: string }; export type FieldLinksDraftValue = { primaryLinkLabel: string; @@ -79,12 +80,12 @@ export type FieldInputDraftValue = FieldValue extends FieldTextValue ? FieldSelectDraftValue : FieldValue extends FieldMultiSelectValue ? FieldMultiSelectDraftValue - : FieldValue extends - | FieldRelationValue - | FieldRelationValue + : FieldValue extends FieldRelationToOneValue ? FieldRelationDraftValue - : FieldValue extends FieldAddressValue - ? FieldAddressDraftValue - : FieldValue extends FieldJsonValue - ? FieldJsonDraftValue - : never; + : FieldValue extends FieldRelationFromManyValue + ? FieldRelationManyDraftValue + : FieldValue extends FieldAddressValue + ? FieldAddressDraftValue + : FieldValue extends FieldJsonValue + ? FieldJsonDraftValue + : never; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts index f54794613..fc7ff10c5 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts @@ -1,7 +1,9 @@ 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 { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; +import { WithNarrowedStringLiteralProperty } from '~/types/WithNarrowedStringLiteralProperty'; import { CurrencyCode } from './CurrencyCode'; @@ -110,6 +112,18 @@ export type FieldRelationMetadata = { useEditButton?: boolean; }; +export type FieldRelationOneMetadata = WithNarrowedStringLiteralProperty< + FieldRelationMetadata, + 'relationType', + 'TO_ONE_OBJECT' +>; + +export type FieldRelationManyMetadata = WithNarrowedStringLiteralProperty< + FieldRelationMetadata, + 'relationType', + 'FROM_MANY_OBJECTS' +>; + export type FieldSelectMetadata = { objectMetadataNameSingular?: string; fieldName: string; @@ -174,10 +188,13 @@ export type FieldRatingValue = (typeof RATING_VALUES)[number]; export type FieldSelectValue = string | null; export type FieldMultiSelectValue = string[] | null; -export type FieldRelationValue = - T | null; +export type FieldRelationToOneValue = EntityForSelect | null; -// See https://zod.dev/?id=json-type -type Literal = string | number | boolean | null; -export type Json = Literal | { [key: string]: Json } | Json[]; +export type FieldRelationFromManyValue = EntityForSelect[] | []; + +export type FieldRelationValue< + T extends FieldRelationToOneValue | FieldRelationFromManyValue, +> = T; + +export type Json = ZodHelperLiteral | { [key: string]: Json } | Json[]; export type FieldJsonValue = Record | Json[] | null; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/ZodHelperLiteral.ts b/packages/twenty-front/src/modules/object-record/record-field/types/ZodHelperLiteral.ts new file mode 100644 index 000000000..81c9b3dbd --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/ZodHelperLiteral.ts @@ -0,0 +1,2 @@ +/** See https://zod.dev/?id=json-type */ +export type ZodHelperLiteral = string | number | boolean | null; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationFromManyObjects.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationFromManyObjects.ts index 03b56892a..03559df5d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationFromManyObjects.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationFromManyObjects.ts @@ -1,10 +1,9 @@ -import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; import { FieldDefinition } from '../FieldDefinition'; -import { FieldRelationMetadata } from '../FieldMetadata'; +import { FieldMetadata, FieldRelationManyMetadata } from '../FieldMetadata'; export const isFieldRelationFromManyObjects = ( - field: Pick, 'type' | 'metadata'>, -): field is FieldDefinition => - field.type === FieldMetadataType.Relation && - field.metadata.relationType === 'FROM_MANY_OBJECTS'; + field: Pick, 'type' | 'metadata'>, +): field is FieldDefinition => + isFieldRelation(field) && field.metadata.relationType === 'FROM_MANY_OBJECTS'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationFromManyValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationFromManyValue.ts new file mode 100644 index 000000000..a2d0df7d9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationFromManyValue.ts @@ -0,0 +1,9 @@ +import { isNull, isObject, isUndefined } from '@sniptt/guards'; + +import { FieldRelationFromManyValue } from '@/object-record/record-field/types/FieldMetadata'; + +// TODO: add zod +export const isFieldRelationFromManyValue = ( + fieldValue: unknown, +): fieldValue is FieldRelationFromManyValue => + !isUndefined(fieldValue) && (isObject(fieldValue) || isNull(fieldValue)); diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationToOneObject.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationToOneObject.ts new file mode 100644 index 000000000..d1a1bb365 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationToOneObject.ts @@ -0,0 +1,9 @@ +import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; + +import { FieldDefinition } from '../FieldDefinition'; +import { FieldMetadata, FieldRelationOneMetadata } from '../FieldMetadata'; + +export const isFieldRelationToOneObject = ( + field: Pick, 'type' | 'metadata'>, +): field is FieldDefinition => + isFieldRelation(field) && field.metadata.relationType === 'TO_ONE_OBJECT'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationToOneValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationToOneValue.ts new file mode 100644 index 000000000..dc1f5b361 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationToOneValue.ts @@ -0,0 +1,9 @@ +import { isNull, isObject, isUndefined } from '@sniptt/guards'; + +import { FieldRelationToOneValue } from '@/object-record/record-field/types/FieldMetadata'; + +// TODO: add zod +export const isFieldRelationToOneValue = ( + fieldValue: unknown, +): fieldValue is FieldRelationToOneValue => + !isUndefined(fieldValue) && (isObject(fieldValue) || isNull(fieldValue)); diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationValue.ts deleted file mode 100644 index 1919c493e..000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelationValue.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { isNull, isObject, isUndefined } from '@sniptt/guards'; - -import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; - -import { FieldRelationValue } from '../FieldMetadata'; - -// TODO: add zod -export const isFieldRelationValue = < - T extends EntityForSelect | EntityForSelect[], ->( - fieldValue: unknown, -): fieldValue is FieldRelationValue => - !isUndefined(fieldValue) && (isObject(fieldValue) || isNull(fieldValue)); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts index 9cd621eff..297f1dcf8 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts @@ -11,7 +11,7 @@ import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/s import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState'; import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; -import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; import { useSetRecordCountInCurrentView } from '@/views/hooks/useSetRecordCountInCurrentView'; type UseLoadRecordIndexBoardProps = { @@ -33,7 +33,7 @@ export const useLoadRecordIndexBoard = ({ setFieldDefinitions, isCompactModeActiveState, } = useRecordBoard(recordBoardId); - const { setRecords: setRecordsInStore } = useSetRecordInStore(); + const { upsertRecords: upsertRecordsInStore } = useUpsertRecordsInStore(); const recordIndexFieldDefinitions = useRecoilValue( recordIndexFieldDefinitionsState, @@ -82,8 +82,8 @@ export const useLoadRecordIndexBoard = ({ }, [records, setRecordIdsInBoard]); useEffect(() => { - setRecordsInStore(records); - }, [records, setRecordsInStore]); + upsertRecordsInStore(records); + }, [records, upsertRecordsInStore]); useEffect(() => { setRecordCountInCurrentView(totalCount); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts index a65d67abf..03376161f 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts @@ -9,7 +9,7 @@ import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record- import { useRecordBoardRecordGqlFields } from '@/object-record/record-index/hooks/useRecordBoardRecordGqlFields'; import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; -import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; type UseLoadRecordIndexBoardProps = { objectNameSingular: string; @@ -30,7 +30,7 @@ export const useLoadRecordIndexBoardColumn = ({ objectNameSingular, }); const { setRecordIdsForColumn } = useRecordBoard(recordBoardId); - const { setRecords: setRecordsInStore } = useSetRecordInStore(); + const { upsertRecords: upsertRecordsInStore } = useUpsertRecordsInStore(); const recordIndexFilters = useRecoilValue(recordIndexFiltersState); const recordIndexSorts = useRecoilValue(recordIndexSortsState); @@ -75,8 +75,8 @@ export const useLoadRecordIndexBoardColumn = ({ }, [records, setRecordIdsForColumn, columnId]); useEffect(() => { - setRecordsInStore(records); - }, [records, setRecordsInStore]); + upsertRecordsInStore(records); + }, [records, upsertRecordsInStore]); return { records, diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx index 8eef4e126..f7927be24 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx @@ -6,7 +6,6 @@ import { FieldInput } from '@/object-record/record-field/components/FieldInput'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { FieldFocusContextProvider } from '@/object-record/record-field/contexts/FieldFocusContextProvider'; import { useGetButtonIcon } from '@/object-record/record-field/hooks/useGetButtonIcon'; -import { useIsFieldEmpty } from '@/object-record/record-field/hooks/useIsFieldEmpty'; 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'; @@ -26,11 +25,8 @@ export const RecordInlineCell = ({ loading, }: RecordInlineCellProps) => { const { fieldDefinition, entityId } = useContext(FieldContext); - const buttonIcon = useGetButtonIcon(); - const isFieldEmpty = useIsFieldEmpty(); - const isFieldInputOnly = useIsFieldInputOnly(); const { closeInlineCell } = useInlineCell(); @@ -104,7 +100,6 @@ export const RecordInlineCell = ({ /> } displayModeContent={} - isDisplayModeContentEmpty={isFieldEmpty} isDisplayModeFixHeight editModeContentOnly={isFieldInputOnly} loading={loading} diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx index 2cb77520f..4e6073c9c 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx @@ -69,7 +69,6 @@ export type RecordInlineCellContainerProps = { editModeContentOnly?: boolean; displayModeContent: ReactElement; customEditHotkeyScope?: HotkeyScope; - isDisplayModeContentEmpty?: boolean; isDisplayModeFixHeight?: boolean; disableHoverEffect?: boolean; loading?: boolean; @@ -85,7 +84,6 @@ export const RecordInlineCellContainer = ({ editModeContent, displayModeContent, customEditHotkeyScope, - isDisplayModeContentEmpty, editModeContentOnly, isDisplayModeFixHeight, disableHoverEffect, @@ -149,7 +147,6 @@ export const RecordInlineCellContainer = ({ disableHoverEffect, editModeContent, editModeContentOnly, - isDisplayModeContentEmpty, isDisplayModeFixHeight, buttonIcon, label, diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellDisplayMode.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellDisplayMode.tsx index 0b8770973..5c236d261 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellDisplayMode.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellDisplayMode.tsx @@ -1,13 +1,15 @@ import { css } from '@emotion/react'; import styled from '@emotion/styled'; +import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus'; +import { useIsFieldEmpty } from '@/object-record/record-field/hooks/useIsFieldEmpty'; +import { RecordInlineCellContainerProps } from '@/object-record/record-inline-cell/components/RecordInlineCellContainer'; +import { RecordInlineCellButton } from '@/object-record/record-inline-cell/components/RecordInlineCellEditButton'; + const StyledRecordInlineCellNormalModeOuterContainer = styled.div< Pick< RecordInlineCellDisplayModeProps, - | 'isDisplayModeContentEmpty' - | 'disableHoverEffect' - | 'isDisplayModeFixHeight' - | 'isHovered' + 'disableHoverEffect' | 'isDisplayModeFixHeight' | 'isHovered' > >` align-items: center; @@ -51,33 +53,45 @@ const StyledEmptyField = styled.div` `; type RecordInlineCellDisplayModeProps = { - isDisplayModeContentEmpty?: boolean; disableHoverEffect?: boolean; isDisplayModeFixHeight?: boolean; isHovered?: boolean; emptyPlaceholder?: string; -}; +} & Pick; export const RecordInlineCellDisplayMode = ({ children, - isDisplayModeContentEmpty, disableHoverEffect, isDisplayModeFixHeight, emptyPlaceholder = 'Empty', isHovered, -}: React.PropsWithChildren) => ( - - - {isDisplayModeContentEmpty || !children ? ( - {emptyPlaceholder} - ) : ( - children - )} - - -); + buttonIcon, + editModeContentOnly, +}: React.PropsWithChildren) => { + const { isFocused } = useFieldFocus(); + const isDisplayModeContentEmpty = useIsFieldEmpty(); + const showEditButton = + buttonIcon && + isFocused && + !isDisplayModeContentEmpty && + !editModeContentOnly; + + return ( + <> + + + {isDisplayModeContentEmpty || !children ? ( + {emptyPlaceholder} + ) : ( + children + )} + + + {showEditButton && } + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellValue.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellValue.tsx index 08a5cadba..152ef8927 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellValue.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellValue.tsx @@ -4,7 +4,6 @@ import styled from '@emotion/styled'; import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus'; import { RecordInlineCellContainerProps } from '@/object-record/record-inline-cell/components/RecordInlineCellContainer'; import { RecordInlineCellDisplayMode } from '@/object-record/record-inline-cell/components/RecordInlineCellDisplayMode'; -import { RecordInlineCellButton } from '@/object-record/record-inline-cell/components/RecordInlineCellEditButton'; import { RecordInlineCellEditMode } from '@/object-record/record-inline-cell/components/RecordInlineCellEditMode'; import { RecordInlineCellSkeletonLoader } from '@/object-record/record-inline-cell/components/RecordInlineCellSkeletonLoader'; import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell'; @@ -27,7 +26,6 @@ type RecordInlineCellValueProps = Pick< | 'customEditHotkeyScope' | 'editModeContent' | 'editModeContentOnly' - | 'isDisplayModeContentEmpty' | 'isDisplayModeFixHeight' | 'disableHoverEffect' | 'readonly' @@ -43,7 +41,6 @@ export const RecordInlineCellValue = ({ disableHoverEffect, editModeContent, editModeContentOnly, - isDisplayModeContentEmpty, isDisplayModeFixHeight, readonly, buttonIcon, @@ -61,13 +58,6 @@ export const RecordInlineCellValue = ({ } }; - const showEditButton = - buttonIcon && - !isInlineCellInEditMode && - isFocused && - !editModeContentOnly && - !isDisplayModeContentEmpty; - if (loading === true) { return ; } @@ -81,7 +71,6 @@ export const RecordInlineCellValue = ({ {displayModeContent} - {showEditButton && } )} diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsList.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsList.tsx index c8ce45c0b..28d1d0a13 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsList.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsList.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { RecordDetailRecordsList } from '@/object-record/record-show/record-detail-section/components/RecordDetailRecordsList'; import { RecordDetailRelationRecordsListItem } from '@/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem'; +import { RecordDetailRelationRecordsListItemEffect } from '@/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItemEffect'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; type RecordDetailRelationRecordsListProps = { @@ -19,12 +20,18 @@ export const RecordDetailRelationRecordsList = ({ return ( {relationRecords.slice(0, 5).map((relationRecord) => ( - + <> + + + ))} ); diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx index 8e35a01b0..a0653f933 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx @@ -15,7 +15,6 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition'; import { RecordChip } from '@/object-record/components/RecordChip'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; -import { useLazyFindOneRecord } from '@/object-record/hooks/useLazyFindOneRecord'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { FieldContext, @@ -29,7 +28,6 @@ import { PropertyBox } from '@/object-record/record-inline-cell/property-box/com import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope'; import { RecordDetailRecordsListItem } from '@/object-record/record-show/record-detail-section/components/RecordDetailRecordsListItem'; import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect'; -import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; @@ -99,12 +97,6 @@ export const RecordDetailRelationRecordsListItem = ({ const persistField = usePersistField(); - const { - called: hasFetchedRelationRecord, - findOneRecord: findOneRelationRecord, - } = useLazyFindOneRecord({ - objectNameSingular: relationObjectMetadataNameSingular, - }); const { updateOneRecord: updateOneRelationRecord } = useUpdateOneRecord({ objectNameSingular: relationObjectMetadataNameSingular, }); @@ -168,8 +160,6 @@ export const RecordDetailRelationRecordsListItem = ({ return [updateEntity, { loading: false }]; }; - const { setRecords } = useSetRecordInStore(); - const handleClick = () => onClick(relationRecord.id); const AnimatedIconChevronDown = useCallback( @@ -194,16 +184,7 @@ export const RecordDetailRelationRecordsListItem = ({ record={relationRecord} objectNameSingular={relationObjectMetadataItem.nameSingular} /> - - !hasFetchedRelationRecord && - findOneRelationRecord({ - objectRecordId: relationRecord.id, - onCompleted: (record) => setRecords([record]), - }) - } - > + { + const { fieldDefinition } = useContext(FieldContext); + + const { relationObjectMetadataNameSingular } = + fieldDefinition.metadata as FieldRelationMetadata; + + const { record } = useFindOneRecord({ + objectNameSingular: relationObjectMetadataNameSingular, + objectRecordId: relationRecordId, + }); + + const { upsertRecords } = useUpsertRecordsInStore(); + + useEffect(() => { + if (isDefined(record)) { + upsertRecords([record]); + } + }, [record, upsertRecords]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-store/hooks/useSetRecordInStore.ts b/packages/twenty-front/src/modules/object-record/record-store/hooks/useUpsertRecordsInStore.ts similarity index 85% rename from packages/twenty-front/src/modules/object-record/record-store/hooks/useSetRecordInStore.ts rename to packages/twenty-front/src/modules/object-record/record-store/hooks/useUpsertRecordsInStore.ts index 63c4b9836..0c7692749 100644 --- a/packages/twenty-front/src/modules/object-record/record-store/hooks/useSetRecordInStore.ts +++ b/packages/twenty-front/src/modules/object-record/record-store/hooks/useUpsertRecordsInStore.ts @@ -3,8 +3,8 @@ import { useRecoilCallback } from 'recoil'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -export const useSetRecordInStore = () => { - const setRecords = useRecoilCallback( +export const useUpsertRecordsInStore = () => { + const upsertRecords = useRecoilCallback( ({ set, snapshot }) => (records: ObjectRecord[]) => { for (const record of records) { @@ -21,6 +21,6 @@ export const useSetRecordInStore = () => { ); return { - setRecords, + upsertRecords, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeader.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeader.tsx index b9dbc506b..5310ae1f1 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeader.tsx @@ -55,7 +55,8 @@ export const RecordTableHeader = ({ }: { createRecord: () => void; }) => { - const { visibleTableColumnsSelector } = useRecordTableStates(); + const { visibleTableColumnsSelector, hiddenTableColumnsSelector } = + useRecordTableStates(); const scrollWrapper = useScrollWrapperScopedRef(); const isTableWiderThanScreen = @@ -63,7 +64,7 @@ export const RecordTableHeader = ({ (scrollWrapper.current?.scrollWidth ?? 0); const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector()); - const hiddenTableColumns = useRecoilValue(visibleTableColumnsSelector()); + const hiddenTableColumns = useRecoilValue(hiddenTableColumnsSelector()); const theme = useTheme(); diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/ActivityTargetInlineCellEditModeMultiRecordsEffect.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/ActivityTargetInlineCellEditModeMultiRecordsEffect.tsx new file mode 100644 index 000000000..440bab3c7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/ActivityTargetInlineCellEditModeMultiRecordsEffect.tsx @@ -0,0 +1,132 @@ +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 { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates'; +import { + ObjectRecordForSelect, + SelectedObjectRecordId, + useMultiObjectSearch, +} from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; +import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext'; +import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +export const ActivityTargetInlineCellEditModeMultiRecordsEffect = ({ + selectedObjectRecordIds, +}: { + selectedObjectRecordIds: SelectedObjectRecordId[]; +}) => { + const scopeId = useAvailableScopeIdOrThrow( + RelationPickerScopeInternalContext, + ); + const { + objectRecordsIdsMultiSelectState, + objectRecordMultiSelectCheckedRecordsIdsState, + recordMultiSelectIsLoadingState, + } = useObjectRecordMultiSelectScopedStates(scopeId); + const [objectRecordsIdsMultiSelect, setObjectRecordsIdsMultiSelect] = + useRecoilState(objectRecordsIdsMultiSelectState); + + const setRecordMultiSelectIsLoading = useSetRecoilState( + recordMultiSelectIsLoadingState, + ); + + const relationPickerScopedId = useAvailableScopeIdOrThrow( + RelationPickerScopeInternalContext, + ); + + const { relationPickerSearchFilterState } = useRelationPickerScopedStates({ + relationPickerScopedId, + }); + const relationPickerSearchFilter = useRecoilValue( + relationPickerSearchFilterState, + ); + const { filteredSelectedObjectRecords, loading, objectRecordsToSelect } = + useMultiObjectSearch({ + searchFilterValue: relationPickerSearchFilter, + selectedObjectRecordIds, + excludedObjectRecordIds: [], + limit: 10, + }); + + const [ + objectRecordMultiSelectCheckedRecordsIds, + setObjectRecordMultiSelectCheckedRecordsIds, + ] = useRecoilState(objectRecordMultiSelectCheckedRecordsIdsState); + + const updateRecords = useRecoilCallback( + ({ snapshot, set }) => + (newRecords: ObjectRecordForSelect[]) => { + for (const newRecord of newRecords) { + const currentRecord = snapshot + .getLoadable( + objectRecordMultiSelectComponentFamilyState({ + scopeId: scopeId, + familyKey: newRecord.record.id, + }), + ) + .getValue(); + + const newRecordWithSelected = { + ...newRecord, + selected: objectRecordMultiSelectCheckedRecordsIds.some( + (checkedRecordId) => checkedRecordId === newRecord.record.id, + ), + }; + + if ( + !isDeeplyEqual( + newRecordWithSelected.selected, + currentRecord?.selected, + ) + ) { + set( + objectRecordMultiSelectComponentFamilyState({ + scopeId: scopeId, + familyKey: newRecordWithSelected.record.id, + }), + newRecordWithSelected, + ); + } + } + }, + [objectRecordMultiSelectCheckedRecordsIds, scopeId], + ); + + useEffect(() => { + const allRecords = [ + ...(filteredSelectedObjectRecords ?? []), + ...(objectRecordsToSelect ?? []), + ]; + updateRecords(allRecords); + const allRecordsIds = allRecords.map((record) => record.record.id); + if (!isDeeplyEqual(allRecordsIds, objectRecordsIdsMultiSelect)) { + setObjectRecordsIdsMultiSelect(allRecordsIds); + } + }, [ + filteredSelectedObjectRecords, + objectRecordsIdsMultiSelect, + objectRecordsToSelect, + setObjectRecordsIdsMultiSelect, + updateRecords, + ]); + + useEffect(() => { + setObjectRecordMultiSelectCheckedRecordsIds( + selectedObjectRecordIds.map((rec) => rec.id), + ); + }, [selectedObjectRecordIds, setObjectRecordMultiSelectCheckedRecordsIds]); + + useEffect(() => { + setRecordMultiSelectIsLoading(loading); + }, [loading, setRecordMultiSelectIsLoading]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultiRecordSelect.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultiRecordSelect.tsx index 049176e6c..fe0f3453a 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultiRecordSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultiRecordSelect.tsx @@ -1,12 +1,14 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useRef } from 'react'; import styled from '@emotion/styled'; -import { isNonEmptyString } from '@sniptt/guards'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useDebouncedCallback } from 'use-debounce'; +import { useObjectRecordMultiSelectScopedStates } from '@/activities/hooks/useObjectRecordMultiSelectScopedStates'; import { MultipleObjectRecordOnClickOutsideEffect } from '@/object-record/relation-picker/components/MultipleObjectRecordOnClickOutsideEffect'; import { MultipleObjectRecordSelectItem } from '@/object-record/relation-picker/components/MultipleObjectRecordSelectItem'; import { MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordSelectSelectableListId'; -import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; +import { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates'; +import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext'; import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; @@ -15,7 +17,7 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem'; import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; -import { isDefined } from '~/utils/isDefined'; +import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; export const StyledSelectableItem = styled(SelectableItem)` height: 100%; @@ -24,134 +26,80 @@ export const StyledSelectableItem = styled(SelectableItem)` export const MultiRecordSelect = ({ onChange, onSubmit, - selectedObjectRecords, - allRecords, - loading, - searchFilter, - setSearchFilter, }: { - onChange?: ( - changedRecordForSelect: ObjectRecordForSelect, - newSelectedValue: boolean, - ) => void; - onSubmit?: (objectRecordsForSelect: ObjectRecordForSelect[]) => void; - selectedObjectRecords: ObjectRecordForSelect[]; - allRecords: ObjectRecordForSelect[]; - loading: boolean; - searchFilter: string; - setSearchFilter: (searchFilter: string) => void; + onChange?: (changedRecordForSelectId: string) => void; + onSubmit?: () => void; }) => { const containerRef = useRef(null); - const [internalSelectedRecords, setInternalSelectedRecords] = useState< - ObjectRecordForSelect[] - >([]); + const relationPickerScopedId = useAvailableScopeIdOrThrow( + RelationPickerScopeInternalContext, + ); - useEffect(() => { - if (!loading) { - setInternalSelectedRecords(selectedObjectRecords); - } - }, [selectedObjectRecords, loading]); + const { objectRecordsIdsMultiSelectState, recordMultiSelectIsLoadingState } = + useObjectRecordMultiSelectScopedStates(relationPickerScopedId); + const recordMultiSelectIsLoading = useRecoilValue( + recordMultiSelectIsLoadingState, + ); + const objectRecordsIdsMultiSelect = useRecoilValue( + objectRecordsIdsMultiSelectState, + ); + + const { relationPickerSearchFilterState } = useRelationPickerScopedStates({ + relationPickerScopedId, + }); + + const setSearchFilter = useSetRecoilState(relationPickerSearchFilterState); + const relationPickerSearchFilter = useRecoilValue( + relationPickerSearchFilterState, + ); const debouncedSetSearchFilter = useDebouncedCallback(setSearchFilter, 100, { leading: true, }); - const handleFilterChange = (event: React.ChangeEvent) => { - debouncedSetSearchFilter(event.currentTarget.value); - }; - - const handleSelectChange = ( - changedRecordForSelect: ObjectRecordForSelect, - newSelectedValue: boolean, - ) => { - const newSelectedRecords = newSelectedValue - ? [...internalSelectedRecords, changedRecordForSelect] - : internalSelectedRecords.filter( - (selectedRecord) => - selectedRecord.record.id !== changedRecordForSelect.record.id, - ); - - setInternalSelectedRecords(newSelectedRecords); - - onChange?.(changedRecordForSelect, newSelectedValue); - }; - - const entitiesInDropdown = useMemo( - () => - [...(allRecords ?? [])].filter((entity) => - isNonEmptyString(entity.recordIdentifier.id), - ), - [allRecords], + const handleFilterChange = useCallback( + (event: React.ChangeEvent) => { + debouncedSetSearchFilter(event.currentTarget.value); + }, + [debouncedSetSearchFilter], ); - - const selectableItemIds = entitiesInDropdown.map( - (entity) => entity.record.id, - ); - return ( <> { - onSubmit?.(internalSelectedRecords); + onSubmit?.(); }} /> - {loading ? ( + {recordMultiSelectIsLoading ? ( ) : ( <> { - const recordIsSelected = internalSelectedRecords?.some( - (selectedRecord) => selectedRecord.record.id === recordId, - ); - - const correspondingRecordForSelect = entitiesInDropdown?.find( - (entity) => entity.record.id === recordId, - ); - - if (isDefined(correspondingRecordForSelect)) { - handleSelectChange( - correspondingRecordForSelect, - !recordIsSelected, - ); - } - }} > - {entitiesInDropdown?.map((objectRecordForSelect) => ( - - handleSelectChange( - objectRecordForSelect, - newSelectedValue, - ) - } - selected={internalSelectedRecords?.some( - (selectedRecord) => { - return ( - selectedRecord.record.id === - objectRecordForSelect.record.id - ); - }, - )} - /> - ))} + {objectRecordsIdsMultiSelect?.map((recordId) => { + return ( + + ); + })} - {entitiesInDropdown?.length === 0 && ( + {objectRecordsIdsMultiSelect?.length === 0 && ( )} diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelect.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelect.tsx deleted file mode 100644 index 659461a30..000000000 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelect.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useMemo, useState } from 'react'; -import styled from '@emotion/styled'; - -import { MultiRecordSelect } from '@/object-record/relation-picker/components/MultiRecordSelect'; -import { - ObjectRecordForSelect, - SelectedObjectRecordId, - useMultiObjectSearch, -} from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; -import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem'; - -export const StyledSelectableItem = styled(SelectableItem)` - height: 100%; - width: 100%; -`; -export const MultipleObjectRecordSelect = ({ - onChange, - onSubmit, - selectedObjectRecordIds, -}: { - onChange?: ( - changedRecordForSelect: ObjectRecordForSelect, - newSelectedValue: boolean, - ) => void; - onSubmit?: (objectRecordsForSelect: ObjectRecordForSelect[]) => void; - selectedObjectRecordIds: SelectedObjectRecordId[]; -}) => { - const [searchFilter, setSearchFilter] = useState(''); - - const { - filteredSelectedObjectRecords, - loading, - objectRecordsToSelect, - selectedObjectRecords, - } = useMultiObjectSearch({ - searchFilterValue: searchFilter, - selectedObjectRecordIds, - excludedObjectRecordIds: [], - limit: 10, - }); - - const selectedObjectRecordsForSelect = useMemo( - () => - selectedObjectRecords.filter((selectedObjectRecord) => - selectedObjectRecordIds.some( - (selectedObjectRecordId) => - selectedObjectRecordId.id === - selectedObjectRecord.recordIdentifier.id, - ), - ), - [selectedObjectRecords, selectedObjectRecordIds], - ); - - return ( - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx index ed975e2bb..e46f7d036 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx @@ -3,12 +3,15 @@ import { useRecoilValue } from 'recoil'; import { Avatar } from 'twenty-ui'; import { v4 } from 'uuid'; +import { useObjectRecordMultiSelectScopedStates } from '@/activities/hooks/useObjectRecordMultiSelectScopedStates'; import { MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordSelectSelectableListId'; -import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; +import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext'; import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { MenuItemMultiSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectAvatar'; +import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64'; +import { isDefined } from '~/utils/isDefined'; export const StyledSelectableItem = styled(SelectableItem)` height: 100%; @@ -16,45 +19,60 @@ export const StyledSelectableItem = styled(SelectableItem)` `; export const MultipleObjectRecordSelectItem = ({ - objectRecordForSelect, - onSelectedChange, - selected, + objectRecordId, + onChange, }: { - objectRecordForSelect: ObjectRecordForSelect; - onSelectedChange?: (selected: boolean) => void; - selected: boolean; + objectRecordId: string; + onChange?: (changedRecordForSelectId: string) => void; }) => { const { isSelectedItemIdSelector } = useSelectableList( MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID, ); const isSelectedByKeyboard = useRecoilValue( - isSelectedItemIdSelector(objectRecordForSelect.record.id), + isSelectedItemIdSelector(objectRecordId), + ); + const scopeId = useAvailableScopeIdOrThrow( + RelationPickerScopeInternalContext, ); + const { objectRecordMultiSelectFamilyState } = + useObjectRecordMultiSelectScopedStates(scopeId); + + const record = useRecoilValue( + objectRecordMultiSelectFamilyState(objectRecordId), + ); + + if (!record) { + return null; + } + + const handleSelectChange = () => { + onChange?.(objectRecordId); + }; + + const { selected, recordIdentifier } = record; + + if (!isDefined(recordIdentifier)) { + return null; + } + return ( - + handleSelectChange()} isKeySelected={isSelectedByKeyboard} + selected={selected} avatar={ } - text={objectRecordForSelect.recordIdentifier.name} + text={recordIdentifier.name} /> ); diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx index 70c777c7b..537c2fd08 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx @@ -44,7 +44,6 @@ export const SingleEntitySelectMenuItemsWithSearch = ({ const { entities, relationPickerSearchFilter } = useRelationPickerEntitiesOptions({ relationObjectNameSingular, - relationPickerScopeId, selectedRelationRecordIds, excludedRelationRecordIds, }); diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/constants/TableColumnsDenyList.ts b/packages/twenty-front/src/modules/object-record/relation-picker/constants/TableColumnsDenyList.ts new file mode 100644 index 000000000..8feab843f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/relation-picker/constants/TableColumnsDenyList.ts @@ -0,0 +1,5 @@ +export const TABLE_COLUMNS_DENY_LIST = [ + 'attachments', + 'activities', + 'timelineActivities', +]; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useRelationPickerEntitiesOptions.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useRelationPickerEntitiesOptions.ts index 9eb5f990c..efb3f9934 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useRelationPickerEntitiesOptions.ts +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useRelationPickerEntitiesOptions.ts @@ -1,22 +1,26 @@ import { useRecoilValue } from 'recoil'; import { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates'; +import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext'; import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery'; +import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; export const useRelationPickerEntitiesOptions = ({ relationObjectNameSingular, - relationPickerScopeId = 'relation-picker', selectedRelationRecordIds = [], excludedRelationRecordIds = [], }: { relationObjectNameSingular: string; - relationPickerScopeId?: string; selectedRelationRecordIds?: string[]; excludedRelationRecordIds?: string[]; }) => { + const scopeId = useAvailableScopeIdOrThrow( + RelationPickerScopeInternalContext, + ); + const { searchQueryState, relationPickerSearchFilterState } = useRelationPickerScopedStates({ - relationPickerScopedId: relationPickerScopeId, + relationPickerScopedId: scopeId, }); const relationPickerSearchFilter = useRecoilValue( relationPickerSearchFilterState, diff --git a/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts b/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts index bb7cc3578..eadb16fd4 100644 --- a/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts +++ b/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts @@ -1,17 +1,23 @@ import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; +import { TABLE_COLUMNS_DENY_LIST } from '@/object-record/relation-picker/constants/TableColumnsDenyList'; export const filterAvailableTableColumns = ( columnDefinition: ColumnDefinition, ): boolean => { if ( isFieldRelation(columnDefinition) && - columnDefinition.metadata?.relationType !== 'TO_ONE_OBJECT' + columnDefinition.metadata?.relationType !== 'TO_ONE_OBJECT' && + columnDefinition.metadata?.relationType !== 'FROM_MANY_OBJECTS' ) { return false; } + if (TABLE_COLUMNS_DENY_LIST.includes(columnDefinition.metadata.fieldName)) { + return false; + } + if (columnDefinition.type === 'UUID') { return false; } diff --git a/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts b/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts index fce402359..314fa1e91 100644 --- a/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts +++ b/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts @@ -2,8 +2,7 @@ import { isString } from '@sniptt/guards'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { isFieldRelationValue } from '@/object-record/record-field/types/guards/isFieldRelationValue'; -import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; +import { isFieldRelationToOneValue } from '@/object-record/record-field/types/guards/isFieldRelationToOneValue'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { FieldMetadataType } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; @@ -31,7 +30,7 @@ export const sanitizeRecordInput = ({ if ( fieldMetadataItem.type === FieldMetadataType.Relation && - isFieldRelationValue(fieldValue) + isFieldRelationToOneValue(fieldValue) ) { const relationIdFieldName = `${fieldMetadataItem.name}Id`; const relationIdFieldMetadataItem = objectMetadataItem.fields.find( diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect.tsx index c34a07f77..7ef2d28c7 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; type SettingsDataModelSetRecordEffectProps = { @@ -10,11 +10,11 @@ type SettingsDataModelSetRecordEffectProps = { export const SettingsDataModelSetRecordEffect = ({ record, }: SettingsDataModelSetRecordEffectProps) => { - const { setRecords: setRecordsInStore } = useSetRecordInStore(); + const { upsertRecords: upsertRecordsInStore } = useUpsertRecordsInStore(); useEffect(() => { - setRecordsInStore([record]); - }, [record, setRecordsInStore]); + upsertRecordsInStore([record]); + }, [record, upsertRecordsInStore]); return null; }; diff --git a/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockColumnDefinitions.ts b/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockColumnDefinitions.ts index d6072c52b..2245a1fc1 100644 --- a/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockColumnDefinitions.ts +++ b/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockColumnDefinitions.ts @@ -64,7 +64,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = ( type: FieldMetadataType.Relation, metadata: { fieldName: 'favorites', - placeHolder: 'Favorites', relationType: 'FROM_MANY_OBJECTS', relationObjectMetadataNameSingular: '', relationObjectMetadataNamePlural: '', @@ -99,7 +98,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = ( type: FieldMetadataType.Relation, metadata: { fieldName: 'accountOwner', - placeHolder: 'Account Owner', relationType: 'TO_ONE_OBJECT', relationObjectMetadataNameSingular: 'workspaceMember', relationObjectMetadataNamePlural: 'workspaceMembers', @@ -117,7 +115,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = ( type: FieldMetadataType.Relation, metadata: { fieldName: 'people', - placeHolder: 'People', relationType: 'FROM_MANY_OBJECTS', relationObjectMetadataNameSingular: '', relationObjectMetadataNamePlural: '', @@ -135,7 +132,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = ( type: FieldMetadataType.Relation, metadata: { fieldName: 'attachments', - placeHolder: 'Attachments', relationType: 'FROM_MANY_OBJECTS', relationObjectMetadataNameSingular: '', relationObjectMetadataNamePlural: '', @@ -204,7 +200,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = ( type: FieldMetadataType.Relation, metadata: { fieldName: 'opportunities', - placeHolder: 'Opportunities', relationType: 'FROM_MANY_OBJECTS', relationObjectMetadataNameSingular: '', relationObjectMetadataNamePlural: '', @@ -239,7 +234,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = ( type: FieldMetadataType.Relation, metadata: { fieldName: 'activityTargets', - placeHolder: 'Activities', relationType: 'FROM_MANY_OBJECTS', relationObjectMetadataNameSingular: '', relationObjectMetadataNamePlural: '', diff --git a/packages/twenty-front/src/modules/ui/utilities/recoil-scope/hooks/__tests__/useContextScopeId.test.tsx b/packages/twenty-front/src/modules/ui/utilities/recoil-scope/hooks/__tests__/useContextScopeId.test.tsx index 83ad28a3f..b14092d3b 100644 --- a/packages/twenty-front/src/modules/ui/utilities/recoil-scope/hooks/__tests__/useContextScopeId.test.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/recoil-scope/hooks/__tests__/useContextScopeId.test.tsx @@ -20,8 +20,8 @@ describe('useContextScopeId', () => { ), }); - const scopedId = result.current; - expect(scopedId).toBe(mockedContextValue); + const scopeId = result.current; + expect(scopeId).toBe(mockedContextValue); }); it('Should throw an error when used outside of the specified context', () => { diff --git a/packages/twenty-front/src/modules/ui/utilities/recoil-scope/hooks/__tests__/useRecoilScopeId.test.tsx b/packages/twenty-front/src/modules/ui/utilities/recoil-scope/hooks/__tests__/useRecoilScopeId.test.tsx index 80e690b24..a8d5eafd1 100644 --- a/packages/twenty-front/src/modules/ui/utilities/recoil-scope/hooks/__tests__/useRecoilScopeId.test.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/recoil-scope/hooks/__tests__/useRecoilScopeId.test.tsx @@ -20,8 +20,8 @@ describe('useRecoilScopeId', () => { ), }); - const scopedId = result.current; - expect(scopedId).toBe(mockedContextValue); + const scopeId = result.current; + expect(scopeId).toBe(mockedContextValue); }); it('Should throw an error when used outside of the specified context', () => { diff --git a/packages/twenty-front/src/testing/decorators/RecordStoreDecorator.tsx b/packages/twenty-front/src/testing/decorators/RecordStoreDecorator.tsx index 96fcda4ab..77705e775 100644 --- a/packages/twenty-front/src/testing/decorators/RecordStoreDecorator.tsx +++ b/packages/twenty-front/src/testing/decorators/RecordStoreDecorator.tsx @@ -1,15 +1,15 @@ import { useEffect } from 'react'; import { Decorator } from '@storybook/react'; -import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; export const RecordStoreDecorator: Decorator = (Story, context) => { const { records } = context.parameters; - const { setRecords } = useSetRecordInStore(); + const { upsertRecords } = useUpsertRecordsInStore(); useEffect(() => { - setRecords(records); + upsertRecords(records); }); return ; diff --git a/packages/twenty-front/src/types/WithNarrowedStringLiteralProperty.ts b/packages/twenty-front/src/types/WithNarrowedStringLiteralProperty.ts new file mode 100644 index 000000000..7e0ff52b4 --- /dev/null +++ b/packages/twenty-front/src/types/WithNarrowedStringLiteralProperty.ts @@ -0,0 +1,7 @@ +export type WithNarrowedStringLiteralProperty< + T, + K extends keyof T, + Sub extends T[K], +> = Omit & { + [P in K]: Extract; +}; diff --git a/packages/twenty-front/src/utils/isDeeplyEqual.ts b/packages/twenty-front/src/utils/isDeeplyEqual.ts index 1a887595a..62f100263 100644 --- a/packages/twenty-front/src/utils/isDeeplyEqual.ts +++ b/packages/twenty-front/src/utils/isDeeplyEqual.ts @@ -1,3 +1,4 @@ import deepEqual from 'deep-equal'; -export const isDeeplyEqual = (a: T, b: T) => deepEqual(a, b); +export const isDeeplyEqual = (a: T, b: T, options?: { strict: boolean }) => + deepEqual(a, b, options);