Skip to content

Commit b84042d

Browse files
authored
Display and update fields from fromManyObjects relations in Show card (#5801)
In this PR, we implement the display and update of fields from fromManyObjects (e.g update Employees for a Company). Product requirement - update should be triggered at each box check/uncheck, not at lose of focus Left to do in upcoming PRs - add the column in the table views (e.g. column "Employees" on "Companies" table view) - add "Add new" possibility when there is no records (as is currently exists for "one" side of relations:) <img width="374" alt="Capture d’écran 2024-06-10 à 17 38 02" src="https://github.com/twentyhq/twenty/assets/51697796/6f0cc494-e44f-4620-a762-d7b438951eec"> - update cache after an update affecting other records (e.g "Listings" have one "Person"; if listing A belonged to Person A but then we attribute listing A to Person B, Person A is no longer owner of Listing A. For the moment that would not be reflected immediatly leading, to potential false information if information is accessed from cache) - try to get rid of the glitch - we also have it on the task page example. (probably) due to the fact that we are using a recoil state to read, update then re-read https://github.com/twentyhq/twenty/assets/51697796/54f71674-237a-4946-866e-b8d96353c458
1 parent 4994a9c commit b84042d

23 files changed

+823
-186
lines changed

packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetInlineCellEditMode.tsx

-5
Original file line numberDiff line numberDiff line change
@@ -171,15 +171,10 @@ export const ActivityTargetInlineCellEditMode = ({
171171
});
172172
};
173173

174-
const handleCancel = () => {
175-
closeEditableField();
176-
};
177-
178174
return (
179175
<StyledSelectContainer>
180176
<MultipleObjectRecordSelect
181177
selectedObjectRecordIds={selectedTargetObjectIds}
182-
onCancel={handleCancel}
183178
onSubmit={handleSubmit}
184179
/>
185180
</StyledSelectContainer>

packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts

+2
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export const formatFieldMetadataItemAsFieldDefinition = ({
3737
relationObjectMetadataNamePlural:
3838
relationObjectMetadataItem?.namePlural ?? '',
3939
objectMetadataNameSingular: objectMetadataItem.nameSingular ?? '',
40+
targetFieldMetadataName:
41+
field.relationDefinition?.targetFieldMetadata?.name ?? '',
4042
options: field.options,
4143
};
4244

packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx

+11-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { FullNameFieldInput } from '@/object-record/record-field/meta-types/inpu
66
import { LinksFieldInput } from '@/object-record/record-field/meta-types/input/components/LinksFieldInput';
77
import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput';
88
import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput';
9+
import { RelationManyFieldInput } from '@/object-record/record-field/meta-types/input/components/RelationManyFieldInput';
910
import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput';
1011
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
1112
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
@@ -14,6 +15,7 @@ import { isFieldFullName } from '@/object-record/record-field/types/guards/isFie
1415
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
1516
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
1617
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
18+
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
1719
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
1820
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
1921

@@ -71,7 +73,15 @@ export const FieldInput = ({
7173
recordFieldInputScopeId={getScopeIdFromComponentId(recordFieldInputdId)}
7274
>
7375
{isFieldRelation(fieldDefinition) ? (
74-
<RelationFieldInput onSubmit={onSubmit} onCancel={onCancel} />
76+
isFieldRelationFromManyObjects(fieldDefinition) ? (
77+
<RelationManyFieldInput
78+
relationPickerScopeId={getScopeIdFromComponentId(
79+
`relation-picker-${fieldDefinition.fieldMetadataId}`,
80+
)}
81+
/>
82+
) : (
83+
<RelationFieldInput onSubmit={onSubmit} onCancel={onCancel} />
84+
)
7585
) : isFieldPhone(fieldDefinition) ||
7686
isFieldDisplayedAsPhone(fieldDefinition) ? (
7787
<PhoneFieldInput

packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts

+14-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useContext } from 'react';
22
import { useRecoilCallback } from 'recoil';
33

4+
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
5+
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
46
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
57
import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue';
68
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
@@ -13,9 +15,11 @@ import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/is
1315
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue';
1416
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
1517
import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue';
18+
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
1619
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
1720
import { isFieldSelectValue } from '@/object-record/record-field/types/guards/isFieldSelectValue';
1821
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
22+
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
1923

2024
import { FieldContext } from '../contexts/FieldContext';
2125
import { isFieldBoolean } from '../types/guards/isFieldBoolean';
@@ -55,6 +59,11 @@ export const usePersistField = () => {
5559
isFieldRelation(fieldDefinition) &&
5660
isFieldRelationValue(valueToPersist);
5761

62+
const fieldIsRelationFromManyObjects =
63+
isFieldRelationFromManyObjects(
64+
fieldDefinition as FieldDefinition<FieldRelationMetadata>,
65+
) && isFieldRelationValue(valueToPersist);
66+
5867
const fieldIsText =
5968
isFieldText(fieldDefinition) && isFieldTextValue(valueToPersist);
6069

@@ -111,7 +120,7 @@ export const usePersistField = () => {
111120
isFieldRawJsonValue(valueToPersist);
112121

113122
const isValuePersistable =
114-
fieldIsRelation ||
123+
(fieldIsRelation && !fieldIsRelationFromManyObjects) ||
115124
fieldIsText ||
116125
fieldIsBoolean ||
117126
fieldIsEmail ||
@@ -136,13 +145,14 @@ export const usePersistField = () => {
136145
valueToPersist,
137146
);
138147

139-
if (fieldIsRelation) {
148+
if (fieldIsRelation && !fieldIsRelationFromManyObjects) {
149+
const value = valueToPersist as EntityForSelect;
140150
updateRecord?.({
141151
variables: {
142152
where: { id: entityId },
143153
updateOneRecordInput: {
144-
[fieldName]: valueToPersist,
145-
[`${fieldName}Id`]: valueToPersist?.id ?? null,
154+
[fieldName]: value,
155+
[`${fieldName}Id`]: value?.id ?? null,
146156
},
147157
},
148158
});

packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFieldDisplay.tsx

+10
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import { isArray } from '@sniptt/guards';
12
import { EntityChip } from 'twenty-ui';
23

4+
import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay';
35
import { useRelationFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFieldDisplay';
6+
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
7+
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
48
import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
59

610
export const RelationFieldDisplay = () => {
@@ -14,6 +18,12 @@ export const RelationFieldDisplay = () => {
1418
return null;
1519
}
1620

21+
if (isArray(fieldValue) && isFieldRelationFromManyObjects(fieldDefinition)) {
22+
return (
23+
<RelationFromManyFieldDisplay fieldValue={fieldValue as ObjectRecord[]} />
24+
);
25+
}
26+
1727
const recordChipData = generateRecordChipData(fieldValue);
1828

1929
return (
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { EntityChip } from 'twenty-ui';
2+
3+
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
4+
import { useRelationFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFieldDisplay';
5+
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
6+
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
7+
import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
8+
9+
export const RelationFromManyFieldDisplay = ({
10+
fieldValue,
11+
}: {
12+
fieldValue: ObjectRecord[];
13+
}) => {
14+
const { isFocused } = useFieldFocus();
15+
const { generateRecordChipData } = useRelationFieldDisplay();
16+
17+
const recordChipsData = fieldValue.map((fieldValueItem) =>
18+
generateRecordChipData(fieldValueItem),
19+
);
20+
21+
return (
22+
<ExpandableList isChipCountDisplayed={isFocused}>
23+
{recordChipsData.map((record) => {
24+
return (
25+
<EntityChip
26+
key={record.id}
27+
entityId={record.id}
28+
name={record.name as any}
29+
avatarType={record.avatarType}
30+
avatarUrl={getImageAbsoluteURIOrBase64(record.avatarUrl) || ''}
31+
linkToEntity={record.linkToShowPage}
32+
/>
33+
);
34+
})}
35+
</ExpandableList>
36+
);
37+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { useEffect } from 'react';
2+
import { Meta, StoryObj } from '@storybook/react';
3+
import { useSetRecoilState } from 'recoil';
4+
import { ComponentDecorator } from 'twenty-ui';
5+
6+
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
7+
import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay';
8+
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
9+
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
10+
import {
11+
RecordFieldValueSelectorContextProvider,
12+
useSetRecordValue,
13+
} from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
14+
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
15+
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
16+
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
17+
18+
import {
19+
fieldValue,
20+
relationFromManyFieldDisplayMock,
21+
} from './relationFromManyFieldDisplayMock';
22+
23+
const RelationFieldValueSetterEffect = () => {
24+
const setEntity = useSetRecoilState(
25+
recordStoreFamilyState(relationFromManyFieldDisplayMock.entityId),
26+
);
27+
28+
const setRelationEntity = useSetRecoilState(
29+
recordStoreFamilyState(relationFromManyFieldDisplayMock.relationEntityId),
30+
);
31+
32+
const setRecordValue = useSetRecordValue();
33+
34+
useEffect(() => {
35+
setEntity(relationFromManyFieldDisplayMock.entityValue);
36+
setRelationEntity(relationFromManyFieldDisplayMock.relationFieldValue);
37+
38+
setRecordValue(
39+
relationFromManyFieldDisplayMock.entityValue.id,
40+
relationFromManyFieldDisplayMock.entityValue,
41+
);
42+
setRecordValue(
43+
relationFromManyFieldDisplayMock.relationFieldValue.id,
44+
relationFromManyFieldDisplayMock.relationFieldValue,
45+
);
46+
}, [setEntity, setRelationEntity, setRecordValue]);
47+
48+
return null;
49+
};
50+
51+
const meta: Meta = {
52+
title: 'UI/Data/Field/Display/RelationFromManyFieldDisplay',
53+
decorators: [
54+
MemoryRouterDecorator,
55+
(Story) => (
56+
<RecordFieldValueSelectorContextProvider>
57+
<FieldContext.Provider
58+
value={{
59+
entityId: relationFromManyFieldDisplayMock.entityId,
60+
basePathToShowPage: '/object-record/',
61+
isLabelIdentifier: false,
62+
fieldDefinition: {
63+
...relationFromManyFieldDisplayMock.fieldDefinition,
64+
} as unknown as FieldDefinition<FieldMetadata>,
65+
hotkeyScope: 'hotkey-scope',
66+
}}
67+
>
68+
<RelationFieldValueSetterEffect />
69+
<Story />
70+
</FieldContext.Provider>
71+
</RecordFieldValueSelectorContextProvider>
72+
),
73+
ComponentDecorator,
74+
],
75+
component: RelationFromManyFieldDisplay,
76+
argTypes: { value: { control: 'date' } },
77+
args: { fieldValue: fieldValue },
78+
parameters: {
79+
chromatic: { disableSnapshot: true },
80+
},
81+
};
82+
83+
export default meta;
84+
85+
type Story = StoryObj<typeof RelationFromManyFieldDisplay>;
86+
87+
export const Default: Story = {};
88+
89+
export const Performance = getProfilingStory({
90+
componentName: 'RelationFromManyFieldDisplay',
91+
averageThresholdInMs: 0.5,
92+
numberOfRuns: 20,
93+
numberOfTestsPerRun: 100,
94+
});

0 commit comments

Comments
 (0)