Relations many in table view (#5842)

Closes #5924.

Adding the "many" side of relations in the table view, and fixing some
issues (glitch in Multi record select, cache update after update).

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Marie 2024-06-27 11:28:03 +02:00 committed by GitHub
parent dcb709feee
commit 7eb69a78ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
82 changed files with 1531 additions and 751 deletions

View File

@ -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<CalendarEvent>({
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) {

View File

@ -86,14 +86,6 @@ export const ActivityEditorFields = ({
customUseUpdateOneObjectHook: useUpsertOneActivityMutation,
});
const { FieldContextProvider: ActivityTargetsContextProvider } =
useFieldContext({
objectNameSingular: CoreObjectNameSingular.Activity,
objectRecordId: activityId,
fieldMetadataName: 'activityTargets',
fieldPosition: 3,
});
return (
<StyledPropertyBox>
{activity.type === 'Task' &&
@ -112,15 +104,11 @@ export const ActivityEditorFields = ({
</AssigneeFieldContextProvider>
</>
)}
{ActivityTargetsContextProvider &&
isDefined(activityFromCache) &&
isRightDrawerAnimationCompleted && (
<ActivityTargetsContextProvider>
{isDefined(activityFromCache) && isRightDrawerAnimationCompleted && (
<ActivityTargetsInlineCell
activity={activityFromCache}
maxWidth={340}
/>
</ActivityTargetsContextProvider>
)}
</StyledPropertyBox>
);

View File

@ -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<EmailThread>({
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 =

View File

@ -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,
};
};

View File

@ -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,91 +83,22 @@ 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,
),
);
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<ActivityTarget>({
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 record.selected;
});
return emptyActivityTarget;
},
);
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,
),
);
}
}
setActivityFromStore((currentActivity) => {
if (isNull(currentActivity)) {
return null;
@ -169,14 +109,155 @@ export const ActivityTargetInlineCellEditMode = ({
activityTargets: activityTargetsAfterUpdate,
};
});
};
closeEditableField();
},
[
activityTargetWithTargetRecords,
closeEditableField,
relationPickerScopeId,
setActivityFromStore,
],
);
const handleChange = useRecoilCallback(
({ snapshot, set }) =>
async (recordId: string) => {
const existingActivityTargets = activityTargetWithTargetRecords.map(
(activityTargetObjectRecord) =>
activityTargetObjectRecord.activityTarget,
);
let activityTargetsAfterUpdate = Array.from(existingActivityTargets);
const previouslyCheckedRecordsIds = snapshot
.getLoadable(
objectRecordMultiSelectCheckedRecordsIdsComponentState({
scopeId: 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<ActivityTarget>({
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 (
<StyledSelectContainer>
<MultipleObjectRecordSelect
selectedObjectRecordIds={selectedTargetObjectIds}
onSubmit={handleSubmit}
<RelationPickerScope relationPickerScopeId={relationPickerScopeId}>
<ActivityTargetObjectRecordEffect
activityTargetWithTargetRecords={activityTargetWithTargetRecords}
/>
<ActivityTargetInlineCellEditModeMultiRecordsEffect
selectedObjectRecordIds={selectedTargetObjectIds}
/>
<MultiRecordSelect onSubmit={handleSubmit} onChange={handleChange} />
</RelationPickerScope>
</StyledSelectContainer>
);
};

View File

@ -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 <></>;
};

View File

@ -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,9 +43,20 @@ export const ActivityTargetsInlineCell = ({
ActivityEditorHotkeyScope.ActivityTargets,
);
const { FieldContextProvider: ActivityTargetsContextProvider } =
useFieldContext({
objectNameSingular: CoreObjectNameSingular.Activity,
objectRecordId: activity.id,
fieldMetadataName: 'activityTargets',
fieldPosition: 3,
overridenIsFieldEmpty: activityTargetObjectRecords.length === 0,
});
return (
<RecordFieldInputScope recordFieldInputScopeId={activity?.id ?? ''}>
<FieldFocusContextProvider>
{ActivityTargetsContextProvider && (
<ActivityTargetsContextProvider>
<RecordInlineCellContainer
buttonIcon={IconPencil}
customEditHotkeyScope={{
@ -66,8 +79,9 @@ export const ActivityTargetsInlineCell = ({
maxWidth={maxWidth}
/>
}
isDisplayModeContentEmpty={activityTargetObjectRecords.length === 0}
/>
</ActivityTargetsContextProvider>
)}
</FieldFocusContextProvider>
</RecordFieldInputScope>
);

View File

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

View File

@ -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

View File

@ -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,
});

View File

@ -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]);
},
});

View File

@ -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]);
},
});

View File

@ -70,6 +70,7 @@ export const triggerUpdateRelationsOptimisticEffect = ({
isDeeplyEqual(
currentFieldValueOnSourceRecord,
updatedFieldValueOnSourceRecord,
{ strict: true },
)
) {
return;

View File

@ -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<ObjectMetadataItem['fields'][0]>;
}): 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),
});

View File

@ -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 = <T extends ObjectRecord>({
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
}

View File

@ -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}`;
};

View File

@ -13,11 +13,13 @@ export const updateRecordFromCache = <T extends ObjectRecord>({
objectMetadataItems,
objectMetadataItem,
cache,
recordGqlFields = undefined,
record,
}: {
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: ObjectMetadataItem;
cache: ApolloCache<object>;
recordGqlFields?: Record<string, any>;
record: T;
}) => {
if (isUndefinedOrNull(objectMetadataItem)) {
@ -32,6 +34,7 @@ export const updateRecordFromCache = <T extends ObjectRecord>({
objectMetadataItems,
objectMetadataItem,
computeReferences: true,
recordGqlFields,
},
)}
`;

View File

@ -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<string, any>;
}) => {
return objectMetadataItem.fields.reduce((acc, field) => {
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;
};

View File

@ -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<ObjectRecord>(relatedRecordId);
if (!cachedRelatedRecord) {
throw new Error('could not find cached related record');
}
const previousRecordId = cachedRelatedRecord?.[`${fieldOnRelatedObject}Id`];
if (isDefined(previousRecordId)) {
const previousRecord = getRecordFromCache<ObjectRecord>(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 };
};

View File

@ -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 };
};

View File

@ -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}

View File

@ -47,9 +47,11 @@ export const useUpdateOneRecord = <
const updateOneRecord = async ({
idToUpdate,
updateOneRecordInput,
optimisticRecord,
}: {
idToUpdate: string;
updateOneRecordInput: Partial<Omit<UpdatedObjectRecord, 'id'>>;
optimisticRecord?: Partial<ObjectRecord>;
}) => {
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<ObjectRecord>({
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({

View File

@ -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 ? (
<ChipFieldDisplay />
) : isFieldRelation(fieldDefinition) ? (
<RelationFieldDisplay />
) : isFieldRelationToOneObject(fieldDefinition) ? (
<RelationToOneFieldDisplay />
) : isFieldRelationFromManyObjects(fieldDefinition) ? (
<RelationFromManyFieldDisplay />
) : isFieldPhone(fieldDefinition) ||
isFieldDisplayedAsPhone(fieldDefinition) ? (
<PhoneFieldDisplay />

View File

@ -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 = ({
<RecordFieldInputScope
recordFieldInputScopeId={getScopeIdFromComponentId(recordFieldInputdId)}
>
{isFieldRelation(fieldDefinition) ? (
isFieldRelationFromManyObjects(fieldDefinition) ? (
<RelationManyFieldInput
relationPickerScopeId={getScopeIdFromComponentId(
`relation-picker-${fieldDefinition.fieldMetadataId}`,
)}
/>
) : (
<RelationFieldInput onSubmit={onSubmit} onCancel={onCancel} />
)
{isFieldRelationToOneObject(fieldDefinition) ? (
<RelationToOneFieldInput onSubmit={onSubmit} onCancel={onCancel} />
) : isFieldRelationFromManyObjects(fieldDefinition) ? (
<RelationFromManyFieldInput onSubmit={onSubmit} />
) : isFieldPhone(fieldDefinition) ||
isFieldDisplayedAsPhone(fieldDefinition) ? (
<PhoneFieldInput

View File

@ -30,6 +30,7 @@ export type GenericFieldContextType = {
clearable?: boolean;
maxWidth?: number;
isCentered?: boolean;
overridenIsFieldEmpty?: boolean;
};
export const FieldContext = createContext<GenericFieldContextType>(

View File

@ -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,

View File

@ -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<FieldRelationMetadata>,
) && 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: {

View File

@ -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 (
<RelationFromManyFieldDisplay fieldValue={fieldValue as ObjectRecord[]} />
);
}
const recordChipData = generateRecordChipData(fieldValue);
return (
<EntityChip
entityId={fieldValue.id}
name={recordChipData.name as any}
avatarType={recordChipData.avatarType}
avatarUrl={getImageAbsoluteURIOrBase64(recordChipData.avatarUrl) || ''}
linkToEntity={recordChipData.linkToShowPage}
/>
);
};

View File

@ -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),

View File

@ -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 (
<EntityChip
entityId={fieldValue.id}
name={recordChipData.name as any}
avatarType={recordChipData.avatarType}
avatarUrl={getImageAbsoluteURIOrBase64(recordChipData.avatarUrl) || ''}
linkToEntity={recordChipData.linkToShowPage}
/>
);
};

View File

@ -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;
};

View File

@ -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<typeof RelationFieldDisplay>;
type Story = StoryObj<typeof RelationToOneFieldDisplay>;
export const Default: Story = {};

View File

@ -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',

View File

@ -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<ObjectRecord[] | undefined>(
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,
};
};

View File

@ -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(

View File

@ -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 (
<>
<RelationPickerScope relationPickerScopeId={relationPickerScopeId}>
<ObjectMetadataItemsRelationPickerEffect />
<RelationFromManyFieldInputMultiRecordsEffect />
<MultiRecordSelect onSubmit={handleSubmit} onChange={updateRelation} />
</RelationPickerScope>
</>
);
};

View File

@ -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<EntityForSelect[]>();
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 <></>;
};

View File

@ -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<EntityForSelect[]>();
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 (
<>
<ObjectMetadataItemsRelationPickerEffect
relationPickerScopeId={relationPickerScopeId}
/>
<MultiRecordSelect
allRecords={allRecords}
selectedObjectRecords={selectedRecords}
loading={entities.loading}
searchFilter={relationPickerSearchFilter}
setSearchFilter={setRelationPickerSearchFilter}
onSubmit={() => {
closeEditableField();
}}
onChange={handleChange}
/>
</>
);
};

View File

@ -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<EntityForSelect>();

View File

@ -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'}
>
<RelationWorkspaceSetterEffect />
<RelationManyFieldInput />
<RelationFromManyFieldInput />
</FieldContextProvider>
<div data-testid="data-field-input-click-outside-div" />
</div>

View File

@ -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(() => {
@ -78,9 +79,13 @@ const RelationFieldInputWithContext = ({
},
}}
entityId={entityId}
>
<RelationPickerScope
relationPickerScopeId={'relation-to-one-field-input'}
>
<RelationWorkspaceSetterEffect />
<RelationFieldInput onSubmit={onSubmit} onCancel={onCancel} />
<RelationToOneFieldInput onSubmit={onSubmit} onCancel={onCancel} />
</RelationPickerScope>
</FieldContextProvider>
<div data-testid="data-field-input-click-outside-div" />
</div>
@ -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<typeof RelationFieldInputWithContext>;
type Story = StoryObj<typeof RelationToOneFieldInputWithContext>;
export const Default: Story = {
decorators: [ComponentWithRecoilScopeDecorator],

View File

@ -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 };
};

View File

@ -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<EntityForSelect>;
}) => {
const { fieldDefinition, fieldValue, setFieldValue, entityId } =
useRelationField<EntityForSelect[]>();
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 };
};

View File

@ -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 },
});

View File

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

View File

@ -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<ObjectRecordAndSelected | undefined, string>({
key: 'objectRecordMultiSelectComponentFamilyState',
defaultValue: undefined,
});

View File

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

View File

@ -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,10 +80,10 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
? FieldSelectDraftValue
: FieldValue extends FieldMultiSelectValue
? FieldMultiSelectDraftValue
: FieldValue extends
| FieldRelationValue<EntityForSelect>
| FieldRelationValue<EntityForSelect[]>
: FieldValue extends FieldRelationToOneValue
? FieldRelationDraftValue
: FieldValue extends FieldRelationFromManyValue
? FieldRelationManyDraftValue
: FieldValue extends FieldAddressValue
? FieldAddressDraftValue
: FieldValue extends FieldJsonValue

View File

@ -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 extends EntityForSelect | EntityForSelect[]> =
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<string, Json> | Json[] | null;

View File

@ -0,0 +1,2 @@
/** See https://zod.dev/?id=json-type */
export type ZodHelperLiteral = string | number | boolean | null;

View File

@ -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<FieldDefinition<FieldRelationMetadata>, 'type' | 'metadata'>,
): field is FieldDefinition<FieldRelationMetadata> =>
field.type === FieldMetadataType.Relation &&
field.metadata.relationType === 'FROM_MANY_OBJECTS';
field: Pick<FieldDefinition<FieldMetadata>, 'type' | 'metadata'>,
): field is FieldDefinition<FieldRelationManyMetadata> =>
isFieldRelation(field) && field.metadata.relationType === 'FROM_MANY_OBJECTS';

View File

@ -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));

View File

@ -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<FieldDefinition<FieldMetadata>, 'type' | 'metadata'>,
): field is FieldDefinition<FieldRelationOneMetadata> =>
isFieldRelation(field) && field.metadata.relationType === 'TO_ONE_OBJECT';

View File

@ -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));

View File

@ -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<T> =>
!isUndefined(fieldValue) && (isObject(fieldValue) || isNull(fieldValue));

View File

@ -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);

View File

@ -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,

View File

@ -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={<FieldDisplay />}
isDisplayModeContentEmpty={isFieldEmpty}
isDisplayModeFixHeight
editModeContentOnly={isFieldInputOnly}
loading={loading}

View File

@ -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,

View File

@ -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,23 +53,32 @@ const StyledEmptyField = styled.div`
`;
type RecordInlineCellDisplayModeProps = {
isDisplayModeContentEmpty?: boolean;
disableHoverEffect?: boolean;
isDisplayModeFixHeight?: boolean;
isHovered?: boolean;
emptyPlaceholder?: string;
};
} & Pick<RecordInlineCellContainerProps, 'buttonIcon' | 'editModeContentOnly'>;
export const RecordInlineCellDisplayMode = ({
children,
isDisplayModeContentEmpty,
disableHoverEffect,
isDisplayModeFixHeight,
emptyPlaceholder = 'Empty',
isHovered,
}: React.PropsWithChildren<RecordInlineCellDisplayModeProps>) => (
buttonIcon,
editModeContentOnly,
}: React.PropsWithChildren<RecordInlineCellDisplayModeProps>) => {
const { isFocused } = useFieldFocus();
const isDisplayModeContentEmpty = useIsFieldEmpty();
const showEditButton =
buttonIcon &&
isFocused &&
!isDisplayModeContentEmpty &&
!editModeContentOnly;
return (
<>
<StyledRecordInlineCellNormalModeOuterContainer
isDisplayModeContentEmpty={isDisplayModeContentEmpty}
disableHoverEffect={disableHoverEffect}
isDisplayModeFixHeight={isDisplayModeFixHeight}
isHovered={isHovered}
@ -80,4 +91,7 @@ export const RecordInlineCellDisplayMode = ({
)}
</StyledRecordInlineCellNormalModeInnerContainer>
</StyledRecordInlineCellNormalModeOuterContainer>
{showEditButton && <RecordInlineCellButton Icon={buttonIcon} />}
</>
);
};

View File

@ -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 <RecordInlineCellSkeletonLoader />;
}
@ -81,7 +71,6 @@ export const RecordInlineCellValue = ({
<StyledClickableContainer readonly={readonly}>
<RecordInlineCellDisplayMode
disableHoverEffect={disableHoverEffect}
isDisplayModeContentEmpty={isDisplayModeContentEmpty}
isDisplayModeFixHeight={isDisplayModeFixHeight}
isHovered={isFocused}
emptyPlaceholder={showLabel ? 'Empty' : label}
@ -96,14 +85,14 @@ export const RecordInlineCellValue = ({
>
<RecordInlineCellDisplayMode
disableHoverEffect={disableHoverEffect}
isDisplayModeContentEmpty={isDisplayModeContentEmpty}
isDisplayModeFixHeight={isDisplayModeFixHeight}
isHovered={isFocused}
emptyPlaceholder={showLabel ? 'Empty' : label}
buttonIcon={buttonIcon}
editModeContentOnly={editModeContentOnly}
>
{displayModeContent}
</RecordInlineCellDisplayMode>
{showEditButton && <RecordInlineCellButton Icon={buttonIcon} />}
</StyledClickableContainer>
)}
</>

View File

@ -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 (
<RecordDetailRecordsList>
{relationRecords.slice(0, 5).map((relationRecord) => (
<>
<RecordDetailRelationRecordsListItemEffect
key={`${relationRecord.id}-effect`}
relationRecordId={relationRecord.id}
/>
<RecordDetailRelationRecordsListItem
key={relationRecord.id}
isExpanded={expandedItem === relationRecord.id}
onClick={handleItemClick}
relationRecord={relationRecord}
/>
</>
))}
</RecordDetailRecordsList>
);

View File

@ -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<IconComponent>(
@ -194,16 +184,7 @@ export const RecordDetailRelationRecordsListItem = ({
record={relationRecord}
objectNameSingular={relationObjectMetadataItem.nameSingular}
/>
<StyledClickableZone
onClick={handleClick}
onMouseOver={() =>
!hasFetchedRelationRecord &&
findOneRelationRecord({
objectRecordId: relationRecord.id,
onCompleted: (record) => setRecords([record]),
})
}
>
<StyledClickableZone onClick={handleClick}>
<LightIconButton
className="displayOnHover"
Icon={AnimatedIconChevronDown}

View File

@ -0,0 +1,35 @@
import { useContext, useEffect } from 'react';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { isDefined } from '~/utils/isDefined';
type RecordDetailRelationRecordsListItemEffectProps = {
relationRecordId: string;
};
export const RecordDetailRelationRecordsListItemEffect = ({
relationRecordId,
}: RecordDetailRelationRecordsListItemEffectProps) => {
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;
};

View File

@ -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,
};
};

View File

@ -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();

View File

@ -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 <></>;
};

View File

@ -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<HTMLDivElement>(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<HTMLInputElement>) => {
const handleFilterChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
debouncedSetSearchFilter(event.currentTarget.value);
};
const handleSelectChange = (
changedRecordForSelect: ObjectRecordForSelect,
newSelectedValue: boolean,
) => {
const newSelectedRecords = newSelectedValue
? [...internalSelectedRecords, changedRecordForSelect]
: internalSelectedRecords.filter(
(selectedRecord) =>
selectedRecord.record.id !== changedRecordForSelect.record.id,
},
[debouncedSetSearchFilter],
);
setInternalSelectedRecords(newSelectedRecords);
onChange?.(changedRecordForSelect, newSelectedValue);
};
const entitiesInDropdown = useMemo(
() =>
[...(allRecords ?? [])].filter((entity) =>
isNonEmptyString(entity.recordIdentifier.id),
),
[allRecords],
);
const selectableItemIds = entitiesInDropdown.map(
(entity) => entity.record.id,
);
return (
<>
<MultipleObjectRecordOnClickOutsideEffect
containerRef={containerRef}
onClickOutside={() => {
onSubmit?.(internalSelectedRecords);
onSubmit?.();
}}
/>
<DropdownMenu ref={containerRef} data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
value={relationPickerSearchFilter}
onChange={handleFilterChange}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{loading ? (
{recordMultiSelectIsLoading ? (
<MenuItem text="Loading..." />
) : (
<>
<SelectableList
selectableListId={MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID}
selectableItemIdArray={selectableItemIds}
selectableItemIdArray={objectRecordsIdsMultiSelect}
hotkeyScope={RelationPickerHotkeyScope.RelationPicker}
onEnter={(recordId) => {
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) => (
<MultipleObjectRecordSelectItem
key={objectRecordForSelect.record.id}
objectRecordForSelect={objectRecordForSelect}
onSelectedChange={(newSelectedValue) =>
handleSelectChange(
objectRecordForSelect,
newSelectedValue,
)
}
selected={internalSelectedRecords?.some(
(selectedRecord) => {
{objectRecordsIdsMultiSelect?.map((recordId) => {
return (
selectedRecord.record.id ===
objectRecordForSelect.record.id
);
},
)}
<MultipleObjectRecordSelectItem
key={recordId}
objectRecordId={recordId}
onChange={onChange}
/>
))}
);
})}
</SelectableList>
{entitiesInDropdown?.length === 0 && (
{objectRecordsIdsMultiSelect?.length === 0 && (
<MenuItem text="No result" />
)}
</>

View File

@ -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<string>('');
const {
filteredSelectedObjectRecords,
loading,
objectRecordsToSelect,
selectedObjectRecords,
} = useMultiObjectSearch({
searchFilterValue: searchFilter,
selectedObjectRecordIds,
excludedObjectRecordIds: [],
limit: 10,
});
const selectedObjectRecordsForSelect = useMemo(
() =>
selectedObjectRecords.filter((selectedObjectRecord) =>
selectedObjectRecordIds.some(
(selectedObjectRecordId) =>
selectedObjectRecordId.id ===
selectedObjectRecord.recordIdentifier.id,
),
),
[selectedObjectRecords, selectedObjectRecordIds],
);
return (
<MultiRecordSelect
onChange={onChange}
onSubmit={onSubmit}
selectedObjectRecords={selectedObjectRecordsForSelect}
allRecords={[
...(filteredSelectedObjectRecords ?? []),
...(objectRecordsToSelect ?? []),
]}
loading={loading}
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
/>
);
};

View File

@ -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 (
<StyledSelectableItem
itemId={objectRecordForSelect.record.id}
key={objectRecordForSelect.record.id + v4()}
>
<StyledSelectableItem itemId={objectRecordId} key={objectRecordId + v4()}>
<MenuItemMultiSelectAvatar
selected={selected}
onSelectChange={onSelectedChange}
onSelectChange={(_isNewlySelectedValue) => handleSelectChange()}
isKeySelected={isSelectedByKeyboard}
selected={selected}
avatar={
<Avatar
avatarUrl={getImageAbsoluteURIOrBase64(
objectRecordForSelect.recordIdentifier.avatarUrl,
)}
entityId={objectRecordForSelect.record.id}
placeholder={objectRecordForSelect.recordIdentifier.name}
avatarUrl={getImageAbsoluteURIOrBase64(recordIdentifier.avatarUrl)}
entityId={objectRecordId}
placeholder={recordIdentifier.name}
size="md"
type={
objectRecordForSelect.recordIdentifier.avatarType ?? 'rounded'
}
type={recordIdentifier.avatarType ?? 'rounded'}
/>
}
text={objectRecordForSelect.recordIdentifier.name}
text={recordIdentifier.name}
/>
</StyledSelectableItem>
);

View File

@ -44,7 +44,6 @@ export const SingleEntitySelectMenuItemsWithSearch = ({
const { entities, relationPickerSearchFilter } =
useRelationPickerEntitiesOptions({
relationObjectNameSingular,
relationPickerScopeId,
selectedRelationRecordIds,
excludedRelationRecordIds,
});

View File

@ -0,0 +1,5 @@
export const TABLE_COLUMNS_DENY_LIST = [
'attachments',
'activities',
'timelineActivities',
];

View File

@ -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,

View File

@ -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<FieldMetadata>,
): 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;
}

View File

@ -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<EntityForSelect>(fieldValue)
isFieldRelationToOneValue(fieldValue)
) {
const relationIdFieldName = `${fieldMetadataItem.name}Id`;
const relationIdFieldMetadataItem = objectMetadataItem.fields.find(

View File

@ -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;
};

View File

@ -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: '',

View File

@ -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', () => {

View File

@ -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', () => {

View File

@ -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 <Story />;

View File

@ -0,0 +1,7 @@
export type WithNarrowedStringLiteralProperty<
T,
K extends keyof T,
Sub extends T[K],
> = Omit<T, K> & {
[P in K]: Extract<T[K], Sub>;
};

View File

@ -1,3 +1,4 @@
import deepEqual from 'deep-equal';
export const isDeeplyEqual = <T>(a: T, b: T) => deepEqual(a, b);
export const isDeeplyEqual = <T>(a: T, b: T, options?: { strict: boolean }) =>
deepEqual(a, b, options);