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
82 changed files with 1531 additions and 751 deletions

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,109 +83,181 @@ export const ActivityTargetInlineCellEditMode = ({
objectNameSingular: CoreObjectNameSingular.ActivityTarget,
});
const handleSubmit = async (selectedRecords: ObjectRecordForSelect[]) => {
closeEditableField();
const handleSubmit = useRecoilCallback(
({ snapshot }) =>
async () => {
const activityTargetsAfterUpdate =
activityTargetWithTargetRecords.filter((activityTarget) => {
const record = snapshot
.getLoadable(
objectRecordMultiSelectComponentFamilyState({
scopeId: relationPickerScopeId,
familyKey: activityTarget.targetObject.id,
}),
)
.getValue() as ObjectRecordAndSelected;
const activityTargetsToDelete = activityTargetWithTargetRecords.filter(
(activityTargetObjectRecord) =>
!selectedRecords.some(
(selectedRecord) =>
selectedRecord.recordIdentifier.id ===
activityTargetObjectRecord.targetObject.id,
),
);
return record.selected;
});
setActivityFromStore((currentActivity) => {
if (isNull(currentActivity)) {
return null;
}
const selectedTargetObjectsToCreate = selectedRecords.filter(
(selectedRecord) =>
!activityTargetWithTargetRecords.some(
(activityTargetWithTargetRecord) =>
activityTargetWithTargetRecord.targetObject.id ===
selectedRecord.recordIdentifier.id,
),
);
const existingActivityTargets = activityTargetWithTargetRecords.map(
(activityTargetObjectRecord) => activityTargetObjectRecord.activityTarget,
);
let activityTargetsAfterUpdate = Array.from(existingActivityTargets);
const activityTargetsToCreate = selectedTargetObjectsToCreate.map(
(selectedRecord) => {
const emptyActivityTarget = prefillRecord<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 {
...currentActivity,
activityTargets: activityTargetsAfterUpdate,
};
});
return emptyActivityTarget;
closeEditableField();
},
);
[
activityTargetWithTargetRecords,
closeEditableField,
relationPickerScopeId,
setActivityFromStore,
],
);
activityTargetsAfterUpdate.push(...activityTargetsToCreate);
if (isNonEmptyArray(activityTargetsToDelete)) {
activityTargetsAfterUpdate = activityTargetsAfterUpdate.filter(
(activityTarget) =>
!activityTargetsToDelete.some(
(activityTargetToDelete) =>
activityTargetToDelete.activityTarget.id === activityTarget.id,
),
);
}
if (isActivityInCreateMode) {
createManyActivityTargetsInCache(activityTargetsToCreate);
upsertActivity({
activity,
input: {
activityTargets: activityTargetsAfterUpdate,
},
});
} else {
if (activityTargetsToCreate.length > 0) {
await createManyActivityTargets(activityTargetsToCreate);
}
if (activityTargetsToDelete.length > 0) {
await deleteManyActivityTargets(
activityTargetsToDelete.map(
(activityTargetObjectRecord) =>
activityTargetObjectRecord.activityTarget.id,
),
const handleChange = useRecoilCallback(
({ snapshot, set }) =>
async (recordId: string) => {
const existingActivityTargets = activityTargetWithTargetRecords.map(
(activityTargetObjectRecord) =>
activityTargetObjectRecord.activityTarget,
);
}
}
setActivityFromStore((currentActivity) => {
if (isNull(currentActivity)) {
return null;
}
let activityTargetsAfterUpdate = Array.from(existingActivityTargets);
return {
...currentActivity,
activityTargets: activityTargetsAfterUpdate,
};
});
};
const previouslyCheckedRecordsIds = snapshot
.getLoadable(
objectRecordMultiSelectCheckedRecordsIdsComponentState({
scopeId: relationPickerScopeId,
}),
)
.getValue();
const isNewlySelected = !previouslyCheckedRecordsIds.includes(recordId);
if (isNewlySelected) {
const record = snapshot
.getLoadable(
objectRecordMultiSelectComponentFamilyState({
scopeId: relationPickerScopeId,
familyKey: recordId,
}),
)
.getValue();
if (!record) {
throw new Error(
`Could not find selected record with id ${recordId}`,
);
}
set(
objectRecordMultiSelectCheckedRecordsIdsComponentState({
scopeId: relationPickerScopeId,
}),
(prev) => [...prev, recordId],
);
const newActivityTargetId = v4();
const fieldName = getActivityTargetObjectFieldName({
nameSingular: record.objectMetadataItem.nameSingular,
});
const fieldNameWithIdSuffix = getActivityTargetObjectFieldIdName({
nameSingular: record.objectMetadataItem.nameSingular,
});
const newActivityTarget = prefillRecord<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,33 +43,45 @@ export const ActivityTargetsInlineCell = ({
ActivityEditorHotkeyScope.ActivityTargets,
);
const { FieldContextProvider: ActivityTargetsContextProvider } =
useFieldContext({
objectNameSingular: CoreObjectNameSingular.Activity,
objectRecordId: activity.id,
fieldMetadataName: 'activityTargets',
fieldPosition: 3,
overridenIsFieldEmpty: activityTargetObjectRecords.length === 0,
});
return (
<RecordFieldInputScope recordFieldInputScopeId={activity?.id ?? ''}>
<FieldFocusContextProvider>
<RecordInlineCellContainer
buttonIcon={IconPencil}
customEditHotkeyScope={{
scope: ActivityEditorHotkeyScope.ActivityTargets,
}}
IconLabel={showLabel ? IconArrowUpRight : undefined}
showLabel={showLabel}
readonly={readonly}
labelWidth={fieldDefinition?.labelWidth}
editModeContent={
<ActivityTargetInlineCellEditMode
activity={activity}
activityTargetWithTargetRecords={activityTargetObjectRecords}
{ActivityTargetsContextProvider && (
<ActivityTargetsContextProvider>
<RecordInlineCellContainer
buttonIcon={IconPencil}
customEditHotkeyScope={{
scope: ActivityEditorHotkeyScope.ActivityTargets,
}}
IconLabel={showLabel ? IconArrowUpRight : undefined}
showLabel={showLabel}
readonly={readonly}
labelWidth={fieldDefinition?.labelWidth}
editModeContent={
<ActivityTargetInlineCellEditMode
activity={activity}
activityTargetWithTargetRecords={activityTargetObjectRecords}
/>
}
label="Relations"
displayModeContent={
<ActivityTargetChips
activityTargetObjectRecords={activityTargetObjectRecords}
maxWidth={maxWidth}
/>
}
/>
}
label="Relations"
displayModeContent={
<ActivityTargetChips
activityTargetObjectRecords={activityTargetObjectRecords}
maxWidth={maxWidth}
/>
}
isDisplayModeContentEmpty={activityTargetObjectRecords.length === 0}
/>
</ActivityTargetsContextProvider>
)}
</FieldFocusContextProvider>
</RecordFieldInputScope>
);