refactor: apply relation optimistic effects on record update (#3556)

* refactor: apply relation optimistic effects on record update

Related to #3509

* refactor: remove need to pass relation id field to create and update mutations

* fix: fix tests

* fix: fix SingleEntitySelect glitch

* fix: fix usePersistField tests

* fix: fix wrong import after rebase

* fix: fix several tests

* fix: fix test types
This commit is contained in:
Thaïs
2024-01-29 08:00:00 -03:00
committed by GitHub
parent d66d8c9907
commit a58b4cf437
43 changed files with 970 additions and 1109 deletions

View File

@ -28,21 +28,27 @@ export const useCreateOneRecord = <
});
const createOneRecord = async (input: Partial<CreatedObjectRecord>) => {
const optimisticallyCreatedRecord =
generateCachedObjectRecord<CreatedObjectRecord>(input);
const sanitizedCreateOneRecordInput = sanitizeRecordInput({
objectMetadataItem,
recordInput: { ...input, id: optimisticallyCreatedRecord.id },
recordInput: input,
});
const optimisticallyCreatedRecord =
generateCachedObjectRecord<CreatedObjectRecord>({
...input,
...sanitizedCreateOneRecordInput,
});
const mutationResponseField =
getCreateOneRecordMutationResponseField(objectNameSingular);
const createdObject = await apolloClient.mutate({
mutation: createOneRecordMutation,
variables: {
input: sanitizedCreateOneRecordInput,
input: {
...sanitizedCreateOneRecordInput,
id: optimisticallyCreatedRecord.id,
},
},
optimisticResponse: {
[mutationResponseField]: optimisticallyCreatedRecord,

View File

@ -37,7 +37,7 @@ export const useGetRecordFromCache = ({
id: recordId,
});
return cache.readFragment<CachedObjectRecord>({
return cache.readFragment<CachedObjectRecord & { __typename: string }>({
id: cachedRecordId,
fragment: cacheReadFragment,
});

View File

@ -12,7 +12,7 @@ export const useModifyRecordFromCache = ({
}) => {
const { cache } = useApolloClient();
return <CachedObjectRecord extends ObjectRecord>(
return <CachedObjectRecord extends ObjectRecord = ObjectRecord>(
recordId: string,
fieldModifiers: Modifiers<CachedObjectRecord>,
) => {

View File

@ -1,6 +1,8 @@
import { useApolloClient } from '@apollo/client';
import { useGetRelationFieldsToOptimisticallyUpdate } from '@/apollo/optimistic-effect/hooks/useGetRelationFieldsToOptimisticallyUpdate';
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { triggerUpdateRelationFieldOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
@ -19,6 +21,9 @@ export const useUpdateOneRecord = <
const { objectMetadataItem, updateOneRecordMutation, getRecordFromCache } =
useObjectMetadataItem({ objectNameSingular });
const getRelationFieldsToOptimisticallyUpdate =
useGetRelationFieldsToOptimisticallyUpdate();
const apolloClient = useApolloClient();
const updateOneRecord = async ({
@ -30,18 +35,27 @@ export const useUpdateOneRecord = <
}) => {
const cachedRecord = getRecordFromCache<UpdatedObjectRecord>(idToUpdate);
const optimisticallyUpdatedRecord = {
...(cachedRecord ?? {}),
...updateOneRecordInput,
__typename: capitalize(objectNameSingular),
id: idToUpdate,
};
const sanitizedUpdateOneRecordInput = sanitizeRecordInput({
objectMetadataItem,
recordInput: updateOneRecordInput,
});
const optimisticallyUpdatedRecord = {
...(cachedRecord ?? {}),
...updateOneRecordInput,
...sanitizedUpdateOneRecordInput,
__typename: capitalize(objectNameSingular),
id: idToUpdate,
};
const updatedRelationFields = cachedRecord
? getRelationFieldsToOptimisticallyUpdate({
cachedRecord,
objectMetadataItem,
updateRecordInput: updateOneRecordInput,
})
: [];
const mutationResponseField =
getUpdateOneRecordMutationResponseField(objectNameSingular);
@ -64,6 +78,24 @@ export const useUpdateOneRecord = <
objectMetadataItem,
record,
});
updatedRelationFields.forEach(
({
relationObjectMetadataNameSingular,
relationFieldName,
previousRelationRecord,
nextRelationRecord,
}) =>
triggerUpdateRelationFieldOptimisticEffect({
cache,
objectNameSingular,
record,
relationObjectMetadataNameSingular,
relationFieldName,
previousRelationRecord,
nextRelationRecord,
}),
);
},
});

View File

@ -46,7 +46,6 @@ const mocks = [
variables: {
input: {
id: mockedUuid,
name: 'Opportunity',
pipelineStepId: 'pipelineStepId',
companyId: 'New Opportunity',
},

View File

@ -1,17 +1,28 @@
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import {
FieldBooleanMetadata,
FieldFullNameMetadata,
FieldLinkMetadata,
FieldPhoneMetadata,
FieldRatingMetadata,
FieldRelationMetadata,
FieldSelectMetadata,
FieldTextMetadata,
} from '@/object-record/record-field/types/FieldMetadata';
import {
mockedCompaniesMetadata,
mockedPeopleMetadata,
} from '~/testing/mock-data/metadata';
export const fieldMetadataId = 'fieldMetadataId';
const mockedPersonObjectMetadataItem = {
...mockedPeopleMetadata.node,
fields: mockedPeopleMetadata.node.fields.edges.map(({ node }) => node),
};
const mockedCompanyObjectMetadataItem = {
...mockedCompaniesMetadata.node,
fields: mockedCompaniesMetadata.node.fields.edges.map(({ node }) => node),
};
export const textfieldDefinition: FieldDefinition<FieldTextMetadata> = {
fieldMetadataId,
label: 'User Name',
@ -20,29 +31,15 @@ export const textfieldDefinition: FieldDefinition<FieldTextMetadata> = {
metadata: { placeHolder: 'John Doe', fieldName: 'userName' },
};
export const booleanFieldDefinition: FieldDefinition<FieldBooleanMetadata> = {
fieldMetadataId,
label: 'Is Active?',
iconName: 'iconName',
type: 'BOOLEAN',
metadata: {
objectMetadataNameSingular: 'person',
fieldName: 'isActive',
const relationFieldMetadataItem = mockedPersonObjectMetadataItem.fields.find(
({ name }) => name === 'company',
);
export const relationFieldDefinition = formatFieldMetadataItemAsFieldDefinition(
{
field: relationFieldMetadataItem!,
objectMetadataItem: mockedPersonObjectMetadataItem,
},
};
export const relationFieldDefinition: FieldDefinition<FieldRelationMetadata> = {
fieldMetadataId,
label: 'Contact',
iconName: 'Phone',
type: 'RELATION',
metadata: {
fieldName: 'contact',
relationFieldMetadataId: 'relationFieldMetadataId',
relationObjectMetadataNamePlural: 'users',
relationObjectMetadataNameSingular: 'user',
},
};
);
export const selectFieldDefinition: FieldDefinition<FieldSelectMetadata> = {
fieldMetadataId,
@ -77,17 +74,13 @@ export const linkFieldDefinition: FieldDefinition<FieldLinkMetadata> = {
},
};
export const phoneFieldDefinition: FieldDefinition<FieldPhoneMetadata> = {
fieldMetadataId,
label: 'Contact',
iconName: 'Phone',
type: 'TEXT',
metadata: {
objectMetadataNameSingular: 'person',
placeHolder: '(+256)-712-345-6789',
fieldName: 'phone',
},
};
const phoneFieldMetadataItem = mockedPersonObjectMetadataItem.fields.find(
({ name }) => name === 'phone',
);
export const phoneFieldDefinition = formatFieldMetadataItemAsFieldDefinition({
field: phoneFieldMetadataItem!,
objectMetadataItem: mockedPersonObjectMetadataItem,
});
export const ratingfieldDefinition: FieldDefinition<FieldRatingMetadata> = {
fieldMetadataId,
@ -98,3 +91,11 @@ export const ratingfieldDefinition: FieldDefinition<FieldRatingMetadata> = {
fieldName: 'rating',
},
};
const booleanFieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find(
({ name }) => name === 'idealCustomerProfile',
);
export const booleanFieldDefinition = formatFieldMetadataItemAsFieldDefinition({
field: booleanFieldMetadataItem!,
objectMetadataItem: mockedCompanyObjectMetadataItem,
});

View File

@ -25,11 +25,8 @@ jest.mock('@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery', () => ({
}));
const query = gql`
mutation UpdateOneWorkspaceMember(
$idToUpdate: ID!
$input: WorkspaceMemberUpdateInput!
) {
updateWorkspaceMember(id: $idToUpdate, data: $input) {
mutation UpdateOnePerson($idToUpdate: ID!, $input: PersonUpdateInput!) {
updatePerson(id: $idToUpdate, data: $input) {
id
}
}
@ -43,7 +40,7 @@ const mocks: MockedResponse[] = [
},
result: jest.fn(() => ({
data: {
updateWorkspaceMember: {
updatePerson: {
id: 'entityId',
},
},
@ -54,12 +51,12 @@ const mocks: MockedResponse[] = [
query,
variables: {
idToUpdate: 'entityId',
input: { contactId: null, contact: { foo: 'bar' } },
input: { companyId: 'companyId' },
},
},
result: jest.fn(() => ({
data: {
updateWorkspaceMember: {
updatePerson: {
id: 'entityId',
},
},
@ -68,14 +65,13 @@ const mocks: MockedResponse[] = [
];
const entityId = 'entityId';
const fieldName = 'phone';
const getWrapper =
(fieldDefinition: FieldDefinition<FieldMetadata>) =>
({ children }: { children: ReactNode }) => {
const useUpdateOneRecordMutation: RecordUpdateHook = () => {
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
objectNameSingular: CoreObjectNameSingular.Person,
});
const updateEntity = ({ variables }: RecordUpdateHookParams) => {
@ -113,7 +109,7 @@ describe('usePersistField', () => {
const { result } = renderHook(
() => {
const entityFields = useRecoilValue(
recordStoreFamilySelector({ recordId: entityId, fieldName }),
recordStoreFamilySelector({ recordId: entityId, fieldName: 'phone' }),
);
return {
@ -137,7 +133,10 @@ describe('usePersistField', () => {
const { result } = renderHook(
() => {
const entityFields = useRecoilValue(
recordStoreFamilySelector({ recordId: entityId, fieldName }),
recordStoreFamilySelector({
recordId: entityId,
fieldName: 'company',
}),
);
return {
@ -149,7 +148,7 @@ describe('usePersistField', () => {
);
act(() => {
result.current.persistField({ foo: 'bar' });
result.current.persistField({ id: 'companyId' });
});
await waitFor(() => {

View File

@ -24,13 +24,19 @@ const mocks: MockedResponse[] = [
{
request: {
query: gql`
mutation UpdateOnePerson($idToUpdate: ID!, $input: PersonUpdateInput!) {
updatePerson(id: $idToUpdate, data: $input) {
mutation UpdateOneCompany(
$idToUpdate: ID!
$input: CompanyUpdateInput!
) {
updateCompany(id: $idToUpdate, data: $input) {
id
}
}
`,
variables: { idToUpdate: 'entityId', input: { isActive: true } },
variables: {
idToUpdate: 'entityId',
input: { idealCustomerProfile: true },
},
},
result: jest.fn(() => ({
data: {
@ -45,7 +51,7 @@ const mocks: MockedResponse[] = [
const Wrapper = ({ children }: { children: ReactNode }) => {
const useUpdateOneRecordMutation: RecordUpdateHook = () => {
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: CoreObjectNameSingular.Person,
objectNameSingular: CoreObjectNameSingular.Company,
});
const updateEntity = ({ variables }: RecordUpdateHookParams) => {

View File

@ -82,24 +82,8 @@ export const usePersistField = () => {
const fieldIsSelect =
isFieldSelect(fieldDefinition) && isFieldSelectValue(valueToPersist);
if (fieldIsRelation) {
const fieldName = fieldDefinition.metadata.fieldName;
set(
recordStoreFamilySelector({ recordId: entityId, fieldName }),
valueToPersist,
);
updateRecord?.({
variables: {
where: { id: entityId },
updateOneRecordInput: {
[`${fieldName}Id`]: valueToPersist?.id ?? null,
[fieldName]: valueToPersist ?? null,
},
},
});
} else if (
if (
fieldIsRelation ||
fieldIsText ||
fieldIsBoolean ||
fieldIsEmail ||

View File

@ -1,4 +1,3 @@
import { useEffect } from 'react';
import styled from '@emotion/styled';
import { RelationPicker } from '@/object-record/relation-picker/components/RelationPicker';
@ -33,8 +32,6 @@ export const RelationFieldInput = ({
onSubmit?.(() => persistField(newEntity?.record ?? null));
};
useEffect(() => {}, [initialSearchValue]);
return (
<StyledRelationPickerContainer>
<RelationPicker

View File

@ -11,10 +11,10 @@ import {
import { useSetRecoilState } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { ObjectMetadataItemsProvider } from '@/object-metadata/components/ObjectMetadataItemsProvider';
import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
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 } from '~/testing/mock-data/users';
@ -53,28 +53,25 @@ const RelationFieldInputWithContext = ({
return (
<div>
<ObjectMetadataItemsProvider>
<RelationPickerScope relationPickerScopeId="relation-picker">
<FieldContextProvider
fieldDefinition={{
fieldMetadataId: 'relation',
label: 'Relation',
type: 'RELATION',
iconName: 'IconLink',
metadata: {
fieldName: 'Relation',
relationObjectMetadataNamePlural: 'workspaceMembers',
relationObjectMetadataNameSingular: 'workspaceMember',
},
}}
entityId={entityId}
>
<RelationWorkspaceSetterEffect />
<RelationFieldInput onSubmit={onSubmit} onCancel={onCancel} />
</FieldContextProvider>
</RelationPickerScope>
<div data-testid="data-field-input-click-outside-div" />
</ObjectMetadataItemsProvider>
<FieldContextProvider
fieldDefinition={{
fieldMetadataId: 'relation',
label: 'Relation',
type: 'RELATION',
iconName: 'IconLink',
metadata: {
fieldName: 'Relation',
relationObjectMetadataNamePlural: 'workspaceMembers',
relationObjectMetadataNameSingular:
CoreObjectNameSingular.WorkspaceMember,
},
}}
entityId={entityId}
>
<RelationWorkspaceSetterEffect />
<RelationFieldInput onSubmit={onSubmit} onCancel={onCancel} />
</FieldContextProvider>
<div data-testid="data-field-input-click-outside-div" />
</div>
);
};
@ -102,7 +99,11 @@ const meta: Meta = {
onSubmit: { control: false },
onCancel: { control: false },
},
decorators: [SnackBarDecorator, clearMocksDecorator],
decorators: [
clearMocksDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
],
parameters: {
clearMocks: true,
msw: graphqlMocks,

View File

@ -3,7 +3,6 @@ import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { LightIconButton, MenuItem } from 'tsup.ui.index';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordChip } from '@/object-record/components/RecordChip';
@ -57,20 +56,15 @@ export const RecordRelationFieldCardContent = ({
divider,
relationRecord,
}: RecordRelationFieldCardContentProps) => {
const { fieldDefinition, entityId } = useContext(FieldContext);
const { fieldDefinition } = useContext(FieldContext);
const {
relationFieldMetadataId,
relationObjectMetadataNameSingular,
relationType,
fieldName,
objectMetadataNameSingular,
} = fieldDefinition.metadata as FieldRelationMetadata;
const { modifyRecordFromCache } = useObjectMetadataItem({
objectNameSingular: objectMetadataNameSingular ?? '',
});
const isToOneObject = relationType === 'TO_ONE_OBJECT';
const { objectMetadataItem: relationObjectMetadataItem } =
useObjectMetadataItem({
@ -102,31 +96,9 @@ export const RecordRelationFieldCardContent = ({
updateOneRelationRecord({
idToUpdate: relationRecord.id,
updateOneRecordInput: {
[`${relationFieldMetadataItem.name}Id`]: null,
[relationFieldMetadataItem.name]: null,
},
});
modifyRecordFromCache(entityId, {
[fieldName]: (cachedRelationConnection, { readField }) => {
const edges = readField<CachedObjectRecordEdge[]>(
'edges',
cachedRelationConnection,
);
if (!edges) {
return cachedRelationConnection;
}
return {
...cachedRelationConnection,
edges: edges.filter(({ node }) => {
const id = readField('id', node);
return id !== relationRecord.id;
}),
};
},
});
};
const isOpportunityCompanyRelation =

View File

@ -1,6 +1,5 @@
import { useCallback, useContext } from 'react';
import { Link } from 'react-router-dom';
import { Reference } from '@apollo/client';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import qs from 'qs';
@ -8,7 +7,6 @@ import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { parseFieldRelationType } from '@/object-metadata/utils/parseFieldRelationType';
import { useModifyRecordFromCache } from '@/object-record/hooks/useModifyRecordFromCache';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
@ -21,7 +19,6 @@ import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRela
import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope';
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import { IconForbid, IconPlus } from '@/ui/display/icon';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { Card } from '@/ui/layout/card/components/Card';
@ -89,7 +86,6 @@ export const RecordRelationFieldCardSection = () => {
relationFieldMetadataId,
relationObjectMetadataNameSingular,
relationType,
objectMetadataNameSingular,
} = fieldDefinition.metadata as FieldRelationMetadata;
const record = useRecoilValue(recordStoreFamilyState(entityId));
@ -100,10 +96,6 @@ export const RecordRelationFieldCardSection = () => {
objectNameSingular: relationObjectMetadataNameSingular,
});
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: objectMetadataNameSingular ?? '',
});
const relationFieldMetadataItem = relationObjectMetadataItem.fields.find(
({ id }) => id === relationFieldMetadataId,
);
@ -124,24 +116,8 @@ export const RecordRelationFieldCardSection = () => {
const { closeDropdown, isDropdownOpen } = useDropdown(dropdownId);
const { relationPickerSearchFilter, setRelationPickerSearchFilter } =
useRelationPicker({ relationPickerScopeId: dropdownId });
const { searchQuery } = useRelationPicker();
const entities = useFilteredSearchEntityQuery({
filters: [
{
fieldNames:
searchQuery?.computeFilterFields?.(
relationObjectMetadataNameSingular,
) ?? [],
filter: relationPickerSearchFilter,
},
],
orderByField: 'createdAt',
selectedIds: relationRecordIds,
objectNameSingular: relationObjectMetadataNameSingular,
const { setRelationPickerSearchFilter } = useRelationPicker({
relationPickerScopeId: dropdownId,
});
const handleCloseRelationPickerDropdown = useCallback(() => {
@ -153,46 +129,24 @@ export const RecordRelationFieldCardSection = () => {
objectNameSingular: relationObjectMetadataNameSingular,
});
const modifyRecordFromCache = useModifyRecordFromCache({
objectMetadataItem,
});
const handleRelationPickerEntitySelected = (
selectedRelationEntity?: EntityForSelect,
) => {
closeDropdown();
if (!selectedRelationEntity?.id) return;
if (!selectedRelationEntity?.id || !relationFieldMetadataItem?.name) return;
if (isToOneObject) {
persistField(selectedRelationEntity.record);
return;
}
if (!relationFieldMetadataItem?.name) return;
updateOneRelationRecord({
idToUpdate: selectedRelationEntity.id,
updateOneRecordInput: {
[`${relationFieldMetadataItem.name}Id`]: entityId,
[relationFieldMetadataItem.name]: record,
},
});
modifyRecordFromCache(entityId, {
[fieldName]: (relationRef, { readField }) => {
const edges = readField<{ node: Reference }[]>('edges', relationRef);
if (!edges) {
return relationRef;
}
return {
...relationRef,
edges: [...edges, { node: record }],
};
},
});
};
const filterQueryParams: FilterQueryParams = {
@ -208,55 +162,58 @@ export const RecordRelationFieldCardSection = () => {
return (
<Section>
<RelationPickerScope relationPickerScopeId={dropdownId}>
<StyledHeader isDropdownOpen={isDropdownOpen}>
<StyledTitle>
<StyledTitleLabel>{fieldDefinition.label}</StyledTitleLabel>
{parseFieldRelationType(relationFieldMetadataItem) ===
'TO_ONE_OBJECT' && (
<StyledLink to={filterLinkHref}>
All ({relationRecords.length})
</StyledLink>
)}
</StyledTitle>
<DropdownScope dropdownScopeId={dropdownId}>
<StyledAddDropdown
dropdownId={dropdownId}
dropdownPlacement="right-start"
onClose={handleCloseRelationPickerDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={IconPlus}
accent="tertiary"
/>
}
dropdownComponents={
<StyledHeader isDropdownOpen={isDropdownOpen}>
<StyledTitle>
<StyledTitleLabel>{fieldDefinition.label}</StyledTitleLabel>
{parseFieldRelationType(relationFieldMetadataItem) ===
'TO_ONE_OBJECT' && (
<StyledLink to={filterLinkHref}>
All ({relationRecords.length})
</StyledLink>
)}
</StyledTitle>
<DropdownScope dropdownScopeId={dropdownId}>
<StyledAddDropdown
dropdownId={dropdownId}
dropdownPlacement="right-start"
onClose={handleCloseRelationPickerDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={IconPlus}
accent="tertiary"
/>
}
dropdownComponents={
<RelationPickerScope relationPickerScopeId={dropdownId}>
<SingleEntitySelectMenuItemsWithSearch
EmptyIcon={IconForbid}
entitiesToSelect={entities.entitiesToSelect}
loading={entities.loading}
onEntitySelected={handleRelationPickerEntitySelected}
selectedRelationRecordIds={relationRecordIds}
relationObjectNameSingular={
relationObjectMetadataNameSingular
}
relationPickerScopeId={dropdownId}
/>
}
dropdownHotkeyScope={{
scope: dropdownId,
}}
</RelationPickerScope>
}
dropdownHotkeyScope={{
scope: dropdownId,
}}
/>
</DropdownScope>
</StyledHeader>
{!!relationRecords.length && (
<Card>
{relationRecords.slice(0, 5).map((relationRecord, index) => (
<RecordRelationFieldCardContent
key={`${relationRecord.id}${relationLabelIdentifierFieldMetadata?.id}`}
divider={index < relationRecords.length - 1}
relationRecord={relationRecord}
/>
</DropdownScope>
</StyledHeader>
{!!relationRecords.length && (
<Card>
{relationRecords.slice(0, 5).map((relationRecord, index) => (
<RecordRelationFieldCardContent
key={`${relationRecord.id}${relationLabelIdentifierFieldMetadata?.id}`}
divider={index < relationRecords.length - 1}
relationRecord={relationRecord}
/>
))}
</Card>
)}
</RelationPickerScope>
))}
</Card>
)}
</Section>
);
};

View File

@ -5,7 +5,6 @@ import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldM
import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect';
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import { IconForbid } from '@/ui/display/icon';
export type RelationPickerProps = {
@ -27,33 +26,15 @@ export const RelationPicker = ({
initialSearchFilter,
fieldDefinition,
}: RelationPickerProps) => {
const {
relationPickerSearchFilter,
setRelationPickerSearchFilter,
searchQuery,
} = useRelationPicker({ relationPickerScopeId: 'relation-picker' });
const relationPickerScopeId = 'relation-picker';
const { setRelationPickerSearchFilter } = useRelationPicker({
relationPickerScopeId,
});
useEffect(() => {
setRelationPickerSearchFilter(initialSearchFilter ?? '');
}, [initialSearchFilter, setRelationPickerSearchFilter]);
const entities = useFilteredSearchEntityQuery({
filters: [
{
fieldNames:
searchQuery?.computeFilterFields?.(
fieldDefinition.metadata.relationObjectMetadataNameSingular,
) ?? [],
filter: relationPickerSearchFilter,
},
],
orderByField: 'createdAt',
selectedIds: recordId ? [recordId] : [],
excludeEntityIds: excludeRecordIds,
objectNameSingular:
fieldDefinition.metadata.relationObjectMetadataNameSingular,
});
const handleEntitySelected = (
selectedEntity: EntityForSelect | null | undefined,
) => onSubmit(selectedEntity ?? null);
@ -62,12 +43,15 @@ export const RelationPicker = ({
<SingleEntitySelect
EmptyIcon={IconForbid}
emptyLabel={'No ' + fieldDefinition.label}
entitiesToSelect={entities.entitiesToSelect}
loading={entities.loading}
onCancel={onCancel}
onEntitySelected={handleEntitySelected}
selectedEntity={entities.selectedEntities[0]}
width={width}
relationObjectNameSingular={
fieldDefinition.metadata.relationObjectMetadataNameSingular
}
relationPickerScopeId={relationPickerScopeId}
selectedRelationRecordIds={recordId ? [recordId] : []}
excludedRelationRecordIds={excludeRecordIds}
/>
);
};

View File

@ -13,15 +13,17 @@ export type SingleEntitySelectProps = {
} & SingleEntitySelectMenuItemsWithSearchProps;
export const SingleEntitySelect = ({
EmptyIcon,
disableBackgroundBlur = false,
EmptyIcon,
emptyLabel,
entitiesToSelect,
loading,
excludedRelationRecordIds,
onCancel,
onCreate,
onEntitySelected,
relationObjectNameSingular,
relationPickerScopeId,
selectedEntity,
selectedRelationRecordIds,
width = 200,
}: SingleEntitySelectProps) => {
const containerRef = useRef<HTMLDivElement>(null);
@ -52,12 +54,14 @@ export const SingleEntitySelect = ({
{...{
EmptyIcon,
emptyLabel,
entitiesToSelect,
loading,
excludedRelationRecordIds,
onCancel,
onCreate,
onEntitySelected,
relationObjectNameSingular,
relationPickerScopeId,
selectedEntity,
selectedRelationRecordIds,
}}
/>
</DropdownMenu>

View File

@ -86,58 +86,54 @@ export const SingleEntitySelectMenuItems = ({
}
}}
>
<>
<DropdownMenuItemsContainer hasMaxHeight>
{loading ? (
<DropdownMenuSkeletonItem />
) : entitiesInDropdown.length === 0 && !isAllEntitySelectShown ? (
<MenuItem text="No result" />
) : (
<>
{isAllEntitySelectShown &&
selectAllLabel &&
onAllEntitySelected && (
<MenuItemSelect
key="select-all"
onClick={() => onAllEntitySelected()}
LeftIcon={SelectAllIcon}
text={selectAllLabel}
selected={!!isAllEntitySelected}
/>
)}
{emptyLabel && (
<DropdownMenuItemsContainer hasMaxHeight>
{loading ? (
<DropdownMenuSkeletonItem />
) : entitiesInDropdown.length === 0 && !isAllEntitySelectShown ? (
<MenuItem text="No result" />
) : (
<>
{isAllEntitySelectShown &&
selectAllLabel &&
onAllEntitySelected && (
<MenuItemSelect
key="select-none"
onClick={() => onEntitySelected()}
LeftIcon={EmptyIcon}
text={emptyLabel}
selected={!selectedEntity}
key="select-all"
onClick={() => onAllEntitySelected()}
LeftIcon={SelectAllIcon}
text={selectAllLabel}
selected={!!isAllEntitySelected}
/>
)}
</>
)}
</DropdownMenuItemsContainer>
<DropdownMenuItemsContainer hasMaxHeight>
{entitiesInDropdown?.map((entity) => (
<SelectableMenuItemSelect
key={entity.id}
entity={entity}
onEntitySelected={onEntitySelected}
selectedEntity={selectedEntity}
/>
))}
</DropdownMenuItemsContainer>
</>
{showCreateButton && !loading && (
<DropdownMenuItemsContainer hasMaxHeight>
{entitiesToSelect.length > 0 && <DropdownMenuSeparator />}
<CreateNewButton
onClick={onCreate}
LeftIcon={IconPlus}
text="Add New"
{emptyLabel && (
<MenuItemSelect
key="select-none"
onClick={() => onEntitySelected()}
LeftIcon={EmptyIcon}
text={emptyLabel}
selected={!selectedEntity}
/>
)}
</>
)}
{entitiesInDropdown?.map((entity) => (
<SelectableMenuItemSelect
key={entity.id}
entity={entity}
onEntitySelected={onEntitySelected}
selectedEntity={selectedEntity}
/>
</DropdownMenuItemsContainer>
)}
))}
{showCreateButton && !loading && (
<>
{entitiesToSelect.length > 0 && <DropdownMenuSeparator />}
<CreateNewButton
onClick={onCreate}
LeftIcon={IconPlus}
text="Add New"
/>
</>
)}
</DropdownMenuItemsContainer>
</SelectableList>
</div>
);

View File

@ -1,7 +1,9 @@
import { ObjectMetadataItemsRelationPickerEffect } from '@/object-metadata/components/ObjectMetadataItemsRelationPickerEffect';
import {
SingleEntitySelectMenuItems,
SingleEntitySelectMenuItemsProps,
} from '@/object-record/relation-picker/components/SingleEntitySelectMenuItems';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { isDefined } from '~/utils/isDefined';
@ -9,13 +11,15 @@ import { isDefined } from '~/utils/isDefined';
import { useEntitySelectSearch } from '../hooks/useEntitySelectSearch';
export type SingleEntitySelectMenuItemsWithSearchProps = {
excludedRelationRecordIds?: string[];
onCreate?: () => void;
relationObjectNameSingular: string;
relationPickerScopeId?: string;
selectedRelationRecordIds: string[];
} & Pick<
SingleEntitySelectMenuItemsProps,
| 'EmptyIcon'
| 'emptyLabel'
| 'entitiesToSelect'
| 'loading'
| 'onCancel'
| 'onEntitySelected'
| 'selectedEntity'
@ -24,19 +28,41 @@ export type SingleEntitySelectMenuItemsWithSearchProps = {
export const SingleEntitySelectMenuItemsWithSearch = ({
EmptyIcon,
emptyLabel,
entitiesToSelect,
loading,
excludedRelationRecordIds,
onCancel,
onCreate,
onEntitySelected,
relationObjectNameSingular,
relationPickerScopeId = 'relation-picker',
selectedEntity,
selectedRelationRecordIds,
}: SingleEntitySelectMenuItemsWithSearchProps) => {
const { searchFilter, handleSearchFilterChange } = useEntitySelectSearch();
const { searchFilter, searchQuery, handleSearchFilterChange } =
useEntitySelectSearch({
relationPickerScopeId,
});
const showCreateButton = isDefined(onCreate) && searchFilter !== '';
const entities = useFilteredSearchEntityQuery({
filters: [
{
fieldNames:
searchQuery?.computeFilterFields?.(relationObjectNameSingular) ?? [],
filter: searchFilter,
},
],
orderByField: 'createdAt',
selectedIds: selectedRelationRecordIds,
excludeEntityIds: excludedRelationRecordIds,
objectNameSingular: relationObjectNameSingular,
});
return (
<>
<ObjectMetadataItemsRelationPickerEffect
relationPickerScopeId={relationPickerScopeId}
/>
<DropdownMenuSearchInput
value={searchFilter}
onChange={handleSearchFilterChange}
@ -44,15 +70,15 @@ export const SingleEntitySelectMenuItemsWithSearch = ({
/>
<DropdownMenuSeparator />
<SingleEntitySelectMenuItems
entitiesToSelect={entities.entitiesToSelect}
loading={entities.loading}
selectedEntity={selectedEntity ?? entities.selectedEntities[0]}
{...{
EmptyIcon,
emptyLabel,
entitiesToSelect,
loading,
onCancel,
onCreate,
onEntitySelected,
selectedEntity,
showCreateButton,
}}
/>

View File

@ -1,10 +1,14 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { IconUserCircle } from '@/ui/display/icon';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { RelationPickerDecorator } from '~/testing/decorators/RelationPickerDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedPeopleData } from '~/testing/mock-data/people';
import { sleep } from '~/testing/sleep';
@ -26,7 +30,13 @@ const meta: Meta<typeof SingleEntitySelect> = {
ComponentDecorator,
ComponentWithRecoilScopeDecorator,
RelationPickerDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
],
args: {
relationObjectNameSingular: CoreObjectNameSingular.WorkspaceMember,
selectedRelationRecordIds: [],
},
argTypes: {
selectedEntity: {
options: entities.map(({ name }) => name),
@ -36,37 +46,8 @@ const meta: Meta<typeof SingleEntitySelect> = {
),
},
},
render: ({
EmptyIcon,
disableBackgroundBlur = false,
emptyLabel,
loading,
onCancel,
onCreate,
onEntitySelected,
selectedEntity,
width,
}) => {
const filteredEntities = entities.filter(
(entity) => entity.id !== selectedEntity?.id,
);
return (
<SingleEntitySelect
{...{
EmptyIcon,
disableBackgroundBlur,
emptyLabel,
loading,
onCancel,
onCreate,
onEntitySelected,
selectedEntity,
width,
}}
entitiesToSelect={filteredEntities}
/>
);
parameters: {
msw: graphqlMocks,
},
};
@ -89,7 +70,7 @@ export const WithEmptyOption: Story = {
export const WithSearchFilter: Story = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const searchInput = canvas.getByRole('textbox');
const searchInput = await canvas.findByRole('textbox');
await step('Enter search text', async () => {
await sleep(50);

View File

@ -2,12 +2,17 @@ import debounce from 'lodash.debounce';
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
export const useEntitySelectSearch = () => {
export const useEntitySelectSearch = ({
relationPickerScopeId,
}: {
relationPickerScopeId?: string;
} = {}) => {
const {
setRelationPickerPreselectedId,
relationPickerSearchFilter,
searchQuery,
setRelationPickerPreselectedId,
setRelationPickerSearchFilter,
} = useRelationPicker();
} = useRelationPicker({ relationPickerScopeId });
const debouncedSetSearchFilter = debounce(
setRelationPickerSearchFilter,
@ -26,6 +31,7 @@ export const useEntitySelectSearch = () => {
return {
searchFilter: relationPickerSearchFilter,
searchQuery,
handleSearchFilterChange,
};
};

View File

@ -1,5 +1,7 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isFieldRelationValue } from '@/object-record/record-field/types/guards/isFieldRelationValue';
import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
export const sanitizeRecordInput = ({
objectMetadataItem,
@ -9,12 +11,30 @@ export const sanitizeRecordInput = ({
recordInput: Record<string, unknown>;
}) => {
return Object.fromEntries(
Object.entries(recordInput).filter(([fieldName]) => {
const fieldDefinition = objectMetadataItem.fields.find(
(field) => field.name === fieldName,
);
Object.entries(recordInput)
.map<[string, unknown] | undefined>(([fieldName, fieldValue]) => {
const fieldDefinition = objectMetadataItem.fields.find(
(field) => field.name === fieldName,
);
return fieldDefinition?.type !== FieldMetadataType.Relation;
}),
if (!fieldDefinition) return undefined;
if (
fieldDefinition.type === FieldMetadataType.Relation &&
isFieldRelationValue(fieldValue)
) {
const relationIdFieldName = `${fieldDefinition.name}Id`;
const relationIdFieldDefinition = objectMetadataItem.fields.find(
(field) => field.name === relationIdFieldName,
);
return relationIdFieldDefinition
? [relationIdFieldName, fieldValue?.id ?? null]
: undefined;
}
return [fieldName, fieldValue];
})
.filter(isDefined),
);
};