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
This commit is contained in:
Marie
2024-06-11 15:53:17 +02:00
committed by GitHub
parent 4994a9c3a9
commit b84042ddbb
23 changed files with 823 additions and 186 deletions

View File

@ -171,15 +171,10 @@ export const ActivityTargetInlineCellEditMode = ({
});
};
const handleCancel = () => {
closeEditableField();
};
return (
<StyledSelectContainer>
<MultipleObjectRecordSelect
selectedObjectRecordIds={selectedTargetObjectIds}
onCancel={handleCancel}
onSubmit={handleSubmit}
/>
</StyledSelectContainer>

View File

@ -37,6 +37,8 @@ export const formatFieldMetadataItemAsFieldDefinition = ({
relationObjectMetadataNamePlural:
relationObjectMetadataItem?.namePlural ?? '',
objectMetadataNameSingular: objectMetadataItem.nameSingular ?? '',
targetFieldMetadataName:
field.relationDefinition?.targetFieldMetadata?.name ?? '',
options: field.options,
};

View File

@ -6,6 +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 { 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';
@ -14,6 +15,7 @@ import { isFieldFullName } from '@/object-record/record-field/types/guards/isFie
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
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 { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
@ -71,7 +73,15 @@ export const FieldInput = ({
recordFieldInputScopeId={getScopeIdFromComponentId(recordFieldInputdId)}
>
{isFieldRelation(fieldDefinition) ? (
<RelationFieldInput onSubmit={onSubmit} onCancel={onCancel} />
isFieldRelationFromManyObjects(fieldDefinition) ? (
<RelationManyFieldInput
relationPickerScopeId={getScopeIdFromComponentId(
`relation-picker-${fieldDefinition.fieldMetadataId}`,
)}
/>
) : (
<RelationFieldInput onSubmit={onSubmit} onCancel={onCancel} />
)
) : isFieldPhone(fieldDefinition) ||
isFieldDisplayedAsPhone(fieldDefinition) ? (
<PhoneFieldInput

View File

@ -1,6 +1,8 @@
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue';
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
@ -13,9 +15,11 @@ 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 { 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';
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
import { FieldContext } from '../contexts/FieldContext';
import { isFieldBoolean } from '../types/guards/isFieldBoolean';
@ -55,6 +59,11 @@ export const usePersistField = () => {
isFieldRelation(fieldDefinition) &&
isFieldRelationValue(valueToPersist);
const fieldIsRelationFromManyObjects =
isFieldRelationFromManyObjects(
fieldDefinition as FieldDefinition<FieldRelationMetadata>,
) && isFieldRelationValue(valueToPersist);
const fieldIsText =
isFieldText(fieldDefinition) && isFieldTextValue(valueToPersist);
@ -111,7 +120,7 @@ export const usePersistField = () => {
isFieldRawJsonValue(valueToPersist);
const isValuePersistable =
fieldIsRelation ||
(fieldIsRelation && !fieldIsRelationFromManyObjects) ||
fieldIsText ||
fieldIsBoolean ||
fieldIsEmail ||
@ -136,13 +145,14 @@ export const usePersistField = () => {
valueToPersist,
);
if (fieldIsRelation) {
if (fieldIsRelation && !fieldIsRelationFromManyObjects) {
const value = valueToPersist as EntityForSelect;
updateRecord?.({
variables: {
where: { id: entityId },
updateOneRecordInput: {
[fieldName]: valueToPersist,
[`${fieldName}Id`]: valueToPersist?.id ?? null,
[fieldName]: value,
[`${fieldName}Id`]: value?.id ?? null,
},
},
});

View File

@ -1,6 +1,10 @@
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 = () => {
@ -14,6 +18,12 @@ export const RelationFieldDisplay = () => {
return null;
}
if (isArray(fieldValue) && isFieldRelationFromManyObjects(fieldDefinition)) {
return (
<RelationFromManyFieldDisplay fieldValue={fieldValue as ObjectRecord[]} />
);
}
const recordChipData = generateRecordChipData(fieldValue);
return (

View File

@ -0,0 +1,37 @@
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 { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
export const RelationFromManyFieldDisplay = ({
fieldValue,
}: {
fieldValue: ObjectRecord[];
}) => {
const { isFocused } = useFieldFocus();
const { generateRecordChipData } = useRelationFieldDisplay();
const recordChipsData = fieldValue.map((fieldValueItem) =>
generateRecordChipData(fieldValueItem),
);
return (
<ExpandableList isChipCountDisplayed={isFocused}>
{recordChipsData.map((record) => {
return (
<EntityChip
key={record.id}
entityId={record.id}
name={record.name as any}
avatarType={record.avatarType}
avatarUrl={getImageAbsoluteURIOrBase64(record.avatarUrl) || ''}
linkToEntity={record.linkToShowPage}
/>
);
})}
</ExpandableList>
);
};

View File

@ -0,0 +1,94 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { useSetRecoilState } from 'recoil';
import { ComponentDecorator } from 'twenty-ui';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import {
RecordFieldValueSelectorContextProvider,
useSetRecordValue,
} from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
import {
fieldValue,
relationFromManyFieldDisplayMock,
} from './relationFromManyFieldDisplayMock';
const RelationFieldValueSetterEffect = () => {
const setEntity = useSetRecoilState(
recordStoreFamilyState(relationFromManyFieldDisplayMock.entityId),
);
const setRelationEntity = useSetRecoilState(
recordStoreFamilyState(relationFromManyFieldDisplayMock.relationEntityId),
);
const setRecordValue = useSetRecordValue();
useEffect(() => {
setEntity(relationFromManyFieldDisplayMock.entityValue);
setRelationEntity(relationFromManyFieldDisplayMock.relationFieldValue);
setRecordValue(
relationFromManyFieldDisplayMock.entityValue.id,
relationFromManyFieldDisplayMock.entityValue,
);
setRecordValue(
relationFromManyFieldDisplayMock.relationFieldValue.id,
relationFromManyFieldDisplayMock.relationFieldValue,
);
}, [setEntity, setRelationEntity, setRecordValue]);
return null;
};
const meta: Meta = {
title: 'UI/Data/Field/Display/RelationFromManyFieldDisplay',
decorators: [
MemoryRouterDecorator,
(Story) => (
<RecordFieldValueSelectorContextProvider>
<FieldContext.Provider
value={{
entityId: relationFromManyFieldDisplayMock.entityId,
basePathToShowPage: '/object-record/',
isLabelIdentifier: false,
fieldDefinition: {
...relationFromManyFieldDisplayMock.fieldDefinition,
} as unknown as FieldDefinition<FieldMetadata>,
hotkeyScope: 'hotkey-scope',
}}
>
<RelationFieldValueSetterEffect />
<Story />
</FieldContext.Provider>
</RecordFieldValueSelectorContextProvider>
),
ComponentDecorator,
],
component: RelationFromManyFieldDisplay,
argTypes: { value: { control: 'date' } },
args: { fieldValue: fieldValue },
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof RelationFromManyFieldDisplay>;
export const Default: Story = {};
export const Performance = getProfilingStory({
componentName: 'RelationFromManyFieldDisplay',
averageThresholdInMs: 0.5,
numberOfRuns: 20,
numberOfTestsPerRun: 100,
});

View File

@ -0,0 +1,172 @@
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const fieldValue = [
{
__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: '',
},
},
{
__typename: 'Company',
domainName: 'airbnb.com',
xLink: {
__typename: 'Link',
label: '',
url: '',
},
name: 'Airbnb',
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
currencyCode: '',
},
employees: null,
accountOwnerId: null,
address: '',
idealCustomerProfile: false,
createdAt: '2024-05-01T13:16:29.046Z',
id: '20202020-171e-4bcc-9cf7-43448d6fb278',
position: 6,
updatedAt: '2024-05-01T13:16:29.046Z',
linkedinLink: {
__typename: 'Link',
label: '',
url: '',
},
},
];
export const relationFromManyFieldDisplayMock = {
entityId: '20202020-2d40-4e49-8df4-9c6a049191df',
relationEntityId: '20202020-c21e-4ec2-873b-de4264d89025',
entityValue: {
__typename: 'Person',
asd: '',
city: 'Seattle',
jobTitle: '',
name: {
__typename: 'FullName',
firstName: 'Lorie',
lastName: 'Vladim',
},
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: '20202020-2d40-4e49-8df4-9c6a049191df',
email: 'lorie.vladim@google.com',
phone: '+33788901235',
linkedinLink: {
__typename: 'Link',
label: '',
url: '',
},
xLink: {
__typename: 'Link',
label: '',
url: '',
},
tEst: '',
position: 15,
},
relationFieldValue: {
__typename: 'Company',
domainName: 'microsoft.com',
xLink: {
__typename: 'Link',
label: '',
url: '',
},
name: 'Microsoft',
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
currencyCode: '',
},
employees: null,
accountOwnerId: null,
address: '',
idealCustomerProfile: false,
createdAt: '2024-05-01T13:16:29.046Z',
id: '20202020-ed89-413a-b31a-962986e67bb4',
position: 4,
updatedAt: '2024-05-01T13:16:29.046Z',
linkedinLink: {
__typename: 'Link',
label: '',
url: '',
},
},
fieldDefinition: {
fieldMetadataId: '4e79f0b7-d100-4e89-a07b-315a710b8059',
label: 'Company',
metadata: {
fieldName: 'company',
placeHolder: 'Company',
relationType: 'FROM_MANY_OBJECTS',
relationFieldMetadataId: '01fa2247-7937-4493-b7e2-3d72f05d6d25',
relationObjectMetadataNameSingular: 'company',
relationObjectMetadataNamePlural: 'companies',
objectMetadataNameSingular: 'person',
options: null,
},
type: FieldMetadataType.Relation,
iconName: 'IconUsers',
defaultValue: null,
editButtonIcon: {
propTypes: {},
},
position: 3,
size: 100,
isLabelIdentifier: false,
isVisible: true,
},
};

View File

@ -5,14 +5,16 @@ import { useGetButtonIcon } from '@/object-record/record-field/hooks/useGetButto
import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput';
import { FieldRelationValue } from '@/object-record/record-field/types/FieldMetadata';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldContext } from '../../contexts/FieldContext';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldRelation } from '../../types/guards/isFieldRelation';
// TODO: we will be able to type more precisely when we will have custom field and custom entities support
export const useRelationField = () => {
export const useRelationField = <
T extends EntityForSelect | EntityForSelect[],
>() => {
const { entityId, fieldDefinition, maxWidth } = useContext(FieldContext);
const button = useGetButtonIcon();
@ -24,11 +26,11 @@ export const useRelationField = () => {
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<FieldRelationValue>(
const [fieldValue, setFieldValue] = useRecoilState<FieldRelationValue<T>>(
recordStoreFamilySelector({ recordId: entityId, fieldName }),
);
const { getDraftValueSelector } = useRecordFieldInput<FieldRelationValue>(
const { getDraftValueSelector } = useRecordFieldInput<FieldRelationValue<T>>(
`${entityId}-${fieldName}`,
);
const draftValue = useRecoilValue(getDraftValueSelector());
@ -41,5 +43,6 @@ export const useRelationField = () => {
initialSearchValue,
setFieldValue,
maxWidth: button && maxWidth ? maxWidth - 28 : maxWidth,
entityId,
};
};

View File

@ -24,7 +24,7 @@ export const RelationFieldInput = ({
onCancel,
}: RelationFieldInputProps) => {
const { fieldDefinition, initialSearchValue, fieldValue } =
useRelationField();
useRelationField<EntityForSelect>();
const persistField = usePersistField();

View File

@ -0,0 +1,82 @@
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

@ -0,0 +1,86 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
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 { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { FieldMetadataType } from '~/generated/graphql';
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import {
mockDefaultWorkspace,
mockedWorkspaceMemberData,
} from '~/testing/mock-data/users';
import { FieldContextProvider } from '../../../__stories__/FieldContextProvider';
const RelationWorkspaceSetterEffect = () => {
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
);
useEffect(() => {
setCurrentWorkspace(mockDefaultWorkspace);
setCurrentWorkspaceMember(mockedWorkspaceMemberData);
}, [setCurrentWorkspace, setCurrentWorkspaceMember]);
return <></>;
};
const RelationManyFieldInputWithContext = () => {
const setHotKeyScope = useSetHotkeyScope();
useEffect(() => {
setHotKeyScope('hotkey-scope');
}, [setHotKeyScope]);
return (
<div>
<FieldContextProvider
fieldDefinition={{
fieldMetadataId: 'relation',
label: 'Relation',
type: FieldMetadataType.Relation,
iconName: 'IconLink',
metadata: {
fieldName: 'Relation',
relationObjectMetadataNamePlural: 'workspaceMembers',
relationObjectMetadataNameSingular:
CoreObjectNameSingular.WorkspaceMember,
objectMetadataNameSingular: 'person',
relationFieldMetadataId: '20202020-8c37-4163-ba06-1dada334ce3e',
},
}}
entityId={'entityId'}
>
<RelationWorkspaceSetterEffect />
<RelationManyFieldInput />
</FieldContextProvider>
<div data-testid="data-field-input-click-outside-div" />
</div>
);
};
const meta: Meta = {
title: 'UI/Data/Field/Input/RelationManyFieldInput',
component: RelationManyFieldInputWithContext,
args: {},
decorators: [ObjectMetadataItemsDecorator, SnackBarDecorator],
parameters: {
clearMocks: true,
msw: graphqlMocks,
},
};
export default meta;
type Story = StoryObj<typeof RelationManyFieldInputWithContext>;
export const Default: Story = {
decorators: [ComponentWithRecoilScopeDecorator],
};

View File

@ -0,0 +1,52 @@
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

@ -18,6 +18,7 @@ import {
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;
@ -78,7 +79,9 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
? FieldSelectDraftValue
: FieldValue extends FieldMultiSelectValue
? FieldMultiSelectDraftValue
: FieldValue extends FieldRelationValue
: FieldValue extends
| FieldRelationValue<EntityForSelect>
| FieldRelationValue<EntityForSelect[]>
? FieldRelationDraftValue
: FieldValue extends FieldAddressValue
? FieldAddressDraftValue

View File

@ -106,6 +106,7 @@ export type FieldRelationMetadata = {
relationObjectMetadataNamePlural: string;
relationObjectMetadataNameSingular: string;
relationType?: FieldDefinitionRelationType;
targetFieldMetadataName?: string;
useEditButton?: boolean;
};
@ -173,7 +174,8 @@ export type FieldRatingValue = (typeof RATING_VALUES)[number];
export type FieldSelectValue = string | null;
export type FieldMultiSelectValue = string[] | null;
export type FieldRelationValue = EntityForSelect | null;
export type FieldRelationValue<T extends EntityForSelect | EntityForSelect[]> =
T | null;
// See https://zod.dev/?id=json-type
type Literal = string | number | boolean | null;

View File

@ -0,0 +1,10 @@
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldDefinition } from '../FieldDefinition';
import { FieldRelationMetadata } 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';

View File

@ -1,9 +1,13 @@
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 = (
export const isFieldRelationValue = <
T extends EntityForSelect | EntityForSelect[],
>(
fieldValue: unknown,
): fieldValue is FieldRelationValue =>
): fieldValue is FieldRelationValue<T> =>
!isUndefined(fieldValue) && (isObject(fieldValue) || isNull(fieldValue));

View File

@ -0,0 +1,163 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useDebouncedCallback } from 'use-debounce';
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 { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { isDefined } from '~/utils/isDefined';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
width: 100%;
`;
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;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [internalSelectedRecords, setInternalSelectedRecords] = useState<
ObjectRecordForSelect[]
>([]);
useEffect(() => {
if (!loading) {
setInternalSelectedRecords(selectedObjectRecords);
}
}, [selectedObjectRecords, loading]);
const debouncedSetSearchFilter = useDebouncedCallback(setSearchFilter, 100, {
leading: true,
});
const handleFilterChange = (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,
);
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);
}}
/>
<DropdownMenu ref={containerRef} data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={handleFilterChange}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{loading ? (
<MenuItem text="Loading..." />
) : (
<>
<SelectableList
selectableListId={MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID}
selectableItemIdArray={selectableItemIds}
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) => {
return (
selectedRecord.record.id ===
objectRecordForSelect.record.id
);
},
)}
/>
))}
</SelectableList>
{entitiesInDropdown?.length === 0 && (
<MenuItem text="No result" />
)}
</>
)}
</DropdownMenuItemsContainer>
</DropdownMenu>
</>
);
};

View File

@ -1,37 +1,18 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMemo, useState } from 'react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useDebouncedCallback } from 'use-debounce';
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 { MultiRecordSelect } from '@/object-record/relation-picker/components/MultiRecordSelect';
import {
ObjectRecordForSelect,
SelectedObjectRecordId,
useMultiObjectSearch,
} from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
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';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { isDefined } from '~/utils/isDefined';
export const StyledSelectableItem = styled(SelectableItem)`
height: 100%;
width: 100%;
`;
export type EntitiesForMultipleObjectRecordSelect = {
filteredSelectedObjectRecords: ObjectRecordForSelect[];
objectRecordsToSelect: ObjectRecordForSelect[];
loading: boolean;
};
export const MultipleObjectRecordSelect = ({
onChange,
onSubmit,
@ -41,12 +22,9 @@ export const MultipleObjectRecordSelect = ({
changedRecordForSelect: ObjectRecordForSelect,
newSelectedValue: boolean,
) => void;
onCancel?: (objectRecordsForSelect: ObjectRecordForSelect[]) => void;
onSubmit?: (objectRecordsForSelect: ObjectRecordForSelect[]) => void;
selectedObjectRecordIds: SelectedObjectRecordId[];
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [searchFilter, setSearchFilter] = useState<string>('');
const {
@ -73,122 +51,18 @@ export const MultipleObjectRecordSelect = ({
[selectedObjectRecords, selectedObjectRecordIds],
);
const [internalSelectedRecords, setInternalSelectedRecords] = useState<
ObjectRecordForSelect[]
>([]);
useEffect(() => {
if (!loading) {
setInternalSelectedRecords(selectedObjectRecordsForSelect);
}
}, [selectedObjectRecordsForSelect, loading]);
const debouncedSetSearchFilter = useDebouncedCallback(setSearchFilter, 100, {
leading: true,
});
const handleFilterChange = (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,
);
setInternalSelectedRecords(newSelectedRecords);
onChange?.(changedRecordForSelect, newSelectedValue);
};
const entitiesInDropdown = useMemo(
() =>
[
return (
<MultiRecordSelect
onChange={onChange}
onSubmit={onSubmit}
selectedObjectRecords={selectedObjectRecordsForSelect}
allRecords={[
...(filteredSelectedObjectRecords ?? []),
...(objectRecordsToSelect ?? []),
].filter((entity) => isNonEmptyString(entity.recordIdentifier.id)),
[filteredSelectedObjectRecords, objectRecordsToSelect],
);
const selectableItemIds = entitiesInDropdown.map(
(entity) => entity.record.id,
);
return (
<>
<MultipleObjectRecordOnClickOutsideEffect
containerRef={containerRef}
onClickOutside={() => {
onSubmit?.(internalSelectedRecords);
}}
/>
<DropdownMenu ref={containerRef} data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={handleFilterChange}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{loading ? (
<MenuItem text="Loading..." />
) : (
<>
<SelectableList
selectableListId={MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID}
selectableItemIdArray={selectableItemIds}
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) => {
return (
selectedRecord.record.id ===
objectRecordForSelect.record.id
);
},
)}
/>
))}
</SelectableList>
{entitiesInDropdown?.length === 0 && (
<MenuItem text="No result" />
)}
</>
)}
</DropdownMenuItemsContainer>
</DropdownMenu>
</>
]}
loading={loading}
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
/>
);
};

View File

@ -1,12 +1,9 @@
import { useRecoilValue } from 'recoil';
import { ObjectMetadataItemsRelationPickerEffect } from '@/object-metadata/components/ObjectMetadataItemsRelationPickerEffect';
import {
SingleEntitySelectMenuItems,
SingleEntitySelectMenuItemsProps,
} from '@/object-record/relation-picker/components/SingleEntitySelectMenuItems';
import { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import { useRelationPickerEntitiesOptions } from '@/object-record/relation-picker/hooks/useRelationPickerEntitiesOptions';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { isDefined } from '~/utils/isDefined';
@ -44,32 +41,16 @@ export const SingleEntitySelectMenuItemsWithSearch = ({
relationPickerScopeId,
});
const { searchQueryState, relationPickerSearchFilterState } =
useRelationPickerScopedStates({
relationPickerScopedId: relationPickerScopeId,
const { entities, relationPickerSearchFilter } =
useRelationPickerEntitiesOptions({
relationObjectNameSingular,
relationPickerScopeId,
selectedRelationRecordIds,
excludedRelationRecordIds,
});
const searchQuery = useRecoilValue(searchQueryState);
const relationPickerSearchFilter = useRecoilValue(
relationPickerSearchFilterState,
);
const showCreateButton = isDefined(onCreate);
const entities = useFilteredSearchEntityQuery({
filters: [
{
fieldNames:
searchQuery?.computeFilterFields?.(relationObjectNameSingular) ?? [],
filter: relationPickerSearchFilter,
},
],
orderByField: 'createdAt',
selectedIds: selectedRelationRecordIds,
excludeEntityIds: excludedRelationRecordIds,
objectNameSingular: relationObjectNameSingular,
});
let onCreateWithInput = undefined;
if (isDefined(onCreate)) {

View File

@ -1,4 +1,4 @@
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates';
import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext';
@ -28,6 +28,10 @@ export const useRelationPicker = (props?: useRelationPickeProps) => {
relationPickerSearchFilterState,
);
const relationPickerSearchFilter = useRecoilValue(
relationPickerSearchFilterState,
);
const [relationPickerPreselectedId, setRelationPickerPreselectedId] =
useRecoilState(relationPickerPreselectedIdState);
@ -37,5 +41,6 @@ export const useRelationPicker = (props?: useRelationPickeProps) => {
setRelationPickerSearchFilter,
relationPickerPreselectedId,
setRelationPickerPreselectedId,
relationPickerSearchFilter,
};
};

View File

@ -0,0 +1,41 @@
import { useRecoilValue } from 'recoil';
import { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
export const useRelationPickerEntitiesOptions = ({
relationObjectNameSingular,
relationPickerScopeId = 'relation-picker',
selectedRelationRecordIds = [],
excludedRelationRecordIds = [],
}: {
relationObjectNameSingular: string;
relationPickerScopeId?: string;
selectedRelationRecordIds?: string[];
excludedRelationRecordIds?: string[];
}) => {
const { searchQueryState, relationPickerSearchFilterState } =
useRelationPickerScopedStates({
relationPickerScopedId: relationPickerScopeId,
});
const relationPickerSearchFilter = useRecoilValue(
relationPickerSearchFilterState,
);
const searchQuery = useRecoilValue(searchQueryState);
const entities = useFilteredSearchEntityQuery({
filters: [
{
fieldNames:
searchQuery?.computeFilterFields?.(relationObjectNameSingular) ?? [],
filter: relationPickerSearchFilter,
},
],
orderByField: 'createdAt',
selectedIds: selectedRelationRecordIds,
excludeEntityIds: excludedRelationRecordIds,
objectNameSingular: relationObjectNameSingular,
});
return { entities, relationPickerSearchFilter };
};

View File

@ -3,6 +3,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 { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
@ -30,7 +31,7 @@ export const sanitizeRecordInput = ({
if (
fieldMetadataItem.type === FieldMetadataType.Relation &&
isFieldRelationValue(fieldValue)
isFieldRelationValue<EntityForSelect>(fieldValue)
) {
const relationIdFieldName = `${fieldMetadataItem.name}Id`;
const relationIdFieldMetadataItem = objectMetadataItem.fields.find(