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:
@ -171,15 +171,10 @@ export const ActivityTargetInlineCellEditMode = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
closeEditableField();
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledSelectContainer>
|
||||
<MultipleObjectRecordSelect
|
||||
selectedObjectRecordIds={selectedTargetObjectIds}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</StyledSelectContainer>
|
||||
|
||||
@ -37,6 +37,8 @@ export const formatFieldMetadataItemAsFieldDefinition = ({
|
||||
relationObjectMetadataNamePlural:
|
||||
relationObjectMetadataItem?.namePlural ?? '',
|
||||
objectMetadataNameSingular: objectMetadataItem.nameSingular ?? '',
|
||||
targetFieldMetadataName:
|
||||
field.relationDefinition?.targetFieldMetadata?.name ?? '',
|
||||
options: field.options,
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -24,7 +24,7 @@ export const RelationFieldInput = ({
|
||||
onCancel,
|
||||
}: RelationFieldInputProps) => {
|
||||
const { fieldDefinition, initialSearchValue, fieldValue } =
|
||||
useRelationField();
|
||||
useRelationField<EntityForSelect>();
|
||||
|
||||
const persistField = usePersistField();
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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],
|
||||
};
|
||||
@ -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 };
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
@ -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));
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
@ -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(
|
||||
|
||||
Reference in New Issue
Block a user