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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 970 additions and 1109 deletions

View File

@ -9,9 +9,9 @@
"start:clean": "yarn start --force",
"build": "tsc && vite build && yarn build:inject-runtime-env",
"build:inject-runtime-env": "sh ./scripts/inject-runtime-env.sh",
"tsc:spec": "tsc --project tsconfig.spec.json --noEmit",
"tsc": "tsc --project tsconfig.app.json --watch",
"tsc:ci": "tsc --project tsconfig.app.json --noEmit && tsc --project tsconfig.spec.json --noEmit && tsc --project tsconfig.node.json --noEmit",
"tsc:ci": "tsc",
"tsc:spec": "tsc --project tsconfig.spec.json",
"preview": "vite preview",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0 --config .eslintrc.cjs",
"lint:ci": "yarn lint --config .eslintrc-ci.cjs",
@ -60,4 +60,4 @@
"msw": {
"workerDirectory": "public"
}
}
}

View File

@ -0,0 +1,76 @@
import { useRecoilCallback } from 'recoil';
import { TriggerUpdateRelationFieldOptimisticEffectParams } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const useGetRelationFieldsToOptimisticallyUpdate = () =>
useRecoilCallback(
({ snapshot }) =>
<UpdatedObjectRecord extends ObjectRecord = ObjectRecord>({
cachedRecord,
objectMetadataItem,
updateRecordInput,
}: {
cachedRecord: UpdatedObjectRecord & { __typename: string };
objectMetadataItem: ObjectMetadataItem;
updateRecordInput: Partial<Omit<UpdatedObjectRecord, 'id'>>;
}) =>
Object.entries(updateRecordInput).reduce<
Pick<
TriggerUpdateRelationFieldOptimisticEffectParams,
| 'relationObjectMetadataNameSingular'
| 'relationFieldName'
| 'previousRelationRecord'
| 'nextRelationRecord'
>[]
>((result, [fieldName, nextRelationRecord]) => {
const fieldDefinition = objectMetadataItem.fields.find(
(fieldMetadataItem) => fieldMetadataItem.name === fieldName,
);
if (fieldDefinition?.type !== FieldMetadataType.Relation)
return result;
const relationObjectMetadataNameSingular = (
fieldDefinition.toRelationMetadata?.fromObjectMetadata ||
fieldDefinition.fromRelationMetadata?.toObjectMetadata
)?.nameSingular;
const relationFieldMetadataId =
fieldDefinition.toRelationMetadata?.fromFieldMetadataId ||
fieldDefinition.fromRelationMetadata?.toFieldMetadataId;
if (!relationObjectMetadataNameSingular || !relationFieldMetadataId)
return result;
const relationObjectMetadataItem = snapshot
.getLoadable(
objectMetadataItemFamilySelector({
objectName: relationObjectMetadataNameSingular,
objectNameType: 'singular',
}),
)
.valueOrThrow();
if (!relationObjectMetadataItem) return result;
const relationFieldName = relationObjectMetadataItem.fields.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === relationFieldMetadataId,
)?.name;
if (!relationFieldName) return result;
return [
...result,
{
relationObjectMetadataNameSingular,
relationFieldName,
previousRelationRecord: cachedRecord[fieldName],
nextRelationRecord,
},
];
}, []),
);

View File

@ -0,0 +1,86 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { capitalize } from '~/utils/string/capitalize';
export type TriggerUpdateRelationFieldOptimisticEffectParams = {
cache: ApolloCache<unknown>;
objectNameSingular: string;
record: ObjectRecord;
relationObjectMetadataNameSingular: string;
relationFieldName: string;
previousRelationRecord: ObjectRecord | null;
nextRelationRecord: ObjectRecord | null;
};
export const triggerUpdateRelationFieldOptimisticEffect = ({
cache,
objectNameSingular,
record,
relationObjectMetadataNameSingular,
relationFieldName,
previousRelationRecord,
nextRelationRecord,
}: TriggerUpdateRelationFieldOptimisticEffectParams) => {
const recordTypeName = capitalize(objectNameSingular);
const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular);
if (previousRelationRecord) {
cache.modify<StoreObject>({
id: cache.identify({
...previousRelationRecord,
__typename: relationRecordTypeName,
}),
fields: {
[relationFieldName]: (cachedFieldValue, { isReference, readField }) => {
// To many objects => remove record from previous relation field list
if (isCachedObjectConnection(objectNameSingular, cachedFieldValue)) {
const nextEdges = cachedFieldValue.edges.filter(
({ node }) => readField('id', node) !== record.id,
);
return { ...cachedFieldValue, edges: nextEdges };
}
// To one object => detach previous relation record
if (isReference(cachedFieldValue)) {
return null;
}
},
},
});
}
if (nextRelationRecord) {
cache.modify<StoreObject>({
id: cache.identify({
...nextRelationRecord,
__typename: relationRecordTypeName,
}),
fields: {
[relationFieldName]: (cachedFieldValue, { toReference }) => {
const nodeReference = toReference(record);
if (!nodeReference) return cachedFieldValue;
if (isCachedObjectConnection(objectNameSingular, cachedFieldValue)) {
// To many objects => add record to next relation field list
const nextEdges: CachedObjectRecordEdge[] = [
...cachedFieldValue.edges,
{
__typename: `${recordTypeName}Edge`,
node: nodeReference,
cursor: '',
},
];
return { ...cachedFieldValue, edges: nextEdges };
}
// To one object => attach next relation record
return nodeReference;
},
},
});
}
};

View File

@ -1,16 +1,15 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
import { useSetRecoilState } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandType } from '@/command-menu/types/Command';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { IconCheckbox, IconNotes } from '@/ui/display/icon';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockDefaultWorkspace } from '~/testing/mock-data/users';
import { sleep } from '~/testing/sleep';
@ -27,7 +26,6 @@ const meta: Meta<typeof CommandMenu> = {
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const { addToCommandMenu, setToIntitialCommandMenu, openCommandMenu } =
useCommandMenu();
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
setCurrentWorkspace(mockDefaultWorkspace);
@ -54,16 +52,10 @@ const meta: Meta<typeof CommandMenu> = {
openCommandMenu();
}, [addToCommandMenu, setToIntitialCommandMenu, openCommandMenu]);
return objectMetadataItems.length ? <Story /> : <></>;
return <Story />;
},
ObjectMetadataItemsDecorator,
(Story) => (
<RecoilRoot>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<Story />
</SnackBarProviderScope>
</RecoilRoot>
),
SnackBarDecorator,
ComponentWithRouterDecorator,
],
parameters: {

View File

@ -5,9 +5,7 @@ import { NewButton } from '@/object-record/record-board-deprecated/components/Ne
import { BoardColumnContext } from '@/object-record/record-board-deprecated/contexts/BoardColumnContext';
import { useCreateOpportunity } from '@/object-record/record-board-deprecated/hooks/internal/useCreateOpportunity';
import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect';
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
@ -52,30 +50,16 @@ export const NewOpportunityButton = () => {
setIsCreatingCard(false);
};
const { relationPickerSearchFilter, searchQuery } = useRelationPicker();
const filteredSearchEntityResults = useFilteredSearchEntityQuery({
filters: [
{
fieldNames: searchQuery?.computeFilterFields?.('company') ?? [],
filter: relationPickerSearchFilter,
},
],
orderByField: 'createdAt',
selectedIds: [],
objectNameSingular: CoreObjectNameSingular.Company,
});
return (
<>
{isCreatingCard ? (
<SingleEntitySelect
disableBackgroundBlur
entitiesToSelect={filteredSearchEntityResults.entitiesToSelect}
loading={filteredSearchEntityResults.loading}
onCancel={handleCancel}
onEntitySelected={handleEntitySelect}
selectedEntity={filteredSearchEntityResults.selectedEntities[0]}
relationObjectNameSingular={CoreObjectNameSingular.Company}
relationPickerScopeId="relation-picker"
selectedRelationRecordIds={[]}
/>
) : (
<NewButton onClick={handleNewClick} />

View File

@ -2,20 +2,15 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { SingleEntitySelectMenuItems } from '@/object-record/relation-picker/components/SingleEntitySelectMenuItems';
import { useEntitySelectSearch } from '@/object-record/relation-picker/hooks/useEntitySelectSearch';
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
import { SingleEntitySelectMenuItemsWithSearch } from '@/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch';
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
import { currentPipelineStepsState } from '@/pipeline/states/currentPipelineStepsState';
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import { IconChevronDown } from '@/ui/display/icon';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
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 { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
export type OpportunityPickerProps = {
companyId: string | null;
@ -32,22 +27,6 @@ export const OpportunityPicker = ({
}: OpportunityPickerProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const { searchFilter, handleSearchFilterChange } = useEntitySelectSearch();
const { searchQuery } = useRelationPicker();
const filteredSearchEntityResults = useFilteredSearchEntityQuery({
filters: [
{
fieldNames: searchQuery?.computeFilterFields?.('company') ?? [],
filter: searchFilter,
},
],
orderByField: 'createdAt',
selectedIds: [],
objectNameSingular: CoreObjectNameSingular.Company,
});
const [isProgressSelectionUnfolded, setIsProgressSelectionUnfolded] =
useState(false);
@ -110,21 +89,12 @@ export const OpportunityPicker = ({
{selectedPipelineStep?.name}
</DropdownMenuHeader>
<DropdownMenuSeparator />
<DropdownMenuSearchInput
value={searchFilter}
onChange={handleSearchFilterChange}
autoFocus
<SingleEntitySelectMenuItemsWithSearch
onCancel={onCancel}
onEntitySelected={handleEntitySelected}
relationObjectNameSingular={CoreObjectNameSingular.Company}
selectedRelationRecordIds={[]}
/>
<DropdownMenuSeparator />
<RecoilScope>
<SingleEntitySelectMenuItems
entitiesToSelect={filteredSearchEntityResults.entitiesToSelect}
loading={filteredSearchEntityResults.loading}
onCancel={onCancel}
onEntitySelected={handleEntitySelected}
selectedEntity={filteredSearchEntityResults.selectedEntities[0]}
/>
</RecoilScope>
</>
)}
</DropdownMenu>

View File

@ -5,7 +5,10 @@ import { ColorScheme } from '@/workspace-member/types/WorkspaceMember';
export const mockId = '8f3b2121-f194-4ba4-9fbf-2d5a37126806';
export const favoriteId = 'f088c8c9-05d2-4276-b065-b863cc7d0b33';
export const mockRecord = { id: 'f088c8c9-05d2-4276-b065-b863cc7d0b33' };
const favoriteTargetObjectId = 'f2d8b9e9-7932-4065-bc09-baf12388b75d';
export const favoriteTargetObjectRecord = {
id: favoriteTargetObjectId,
};
export const initialFavorites = [
{
@ -88,8 +91,7 @@ export const mocks = [
variables: {
input: {
id: mockId,
favoritesId: favoriteId,
favorites: { id: favoriteId },
personId: favoriteTargetObjectId,
position: 4,
workspaceMemberId: '1',
},

View File

@ -7,14 +7,15 @@ import { RecoilRoot, useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import {
favoriteId,
favoriteTargetObjectRecord,
initialFavorites,
mockId,
mockRecord,
mocks,
mockWorkspaceMember,
sortedFavorites,
@ -84,7 +85,10 @@ describe('useFavorites', () => {
},
);
result.current.createFavorite(mockRecord, 'favorites');
result.current.createFavorite(
favoriteTargetObjectRecord,
CoreObjectNameSingular.Person,
);
await waitFor(() => {
expect(mocks[0].result).toHaveBeenCalled();

View File

@ -95,8 +95,7 @@ export const useFavorites = () => {
targetObjectNameSingular: string,
) => {
createOneFavorite({
[`${targetObjectNameSingular}Id`]: targetRecord.id,
[`${targetObjectNameSingular}`]: targetRecord,
[targetObjectNameSingular]: targetRecord,
position: favorites.length + 1,
workspaceMemberId: currentWorkspaceMember?.id,
});

View File

@ -2,31 +2,22 @@ import { useRecoilValue } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { ObjectMetadataItemsLoadEffect } from '@/object-metadata/components/ObjectMetadataItemsLoadEffect';
import { ObjectMetadataItemsRelationPickerEffect } from '@/object-metadata/components/ObjectMetadataItemsRelationPickerEffect';
import { useFindManyObjectMetadataItems } from '@/object-metadata/hooks/useFindManyObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope';
export const ObjectMetadataItemsProvider = ({
children,
}: React.PropsWithChildren) => {
useFindManyObjectMetadataItems();
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
return (
<>
<ObjectMetadataItemsLoadEffect />
{objectMetadataItems.length < 1 && currentWorkspace ? (
<></>
) : (
<>
<ObjectMetadataItemsRelationPickerEffect />
<RelationPickerScope relationPickerScopeId="relation-picker">
{children}
</RelationPickerScope>
</>
{(!currentWorkspace || !!objectMetadataItems.length) && (
<RelationPickerScope relationPickerScopeId="relation-picker">
{children}
</RelationPickerScope>
)}
</>
);

View File

@ -2,10 +2,12 @@ import { useEffect } from 'react';
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
export const ObjectMetadataItemsRelationPickerEffect = () => {
const { setSearchQuery } = useRelationPicker({
relationPickerScopeId: 'relation-picker',
});
export const ObjectMetadataItemsRelationPickerEffect = ({
relationPickerScopeId,
}: {
relationPickerScopeId?: string;
} = {}) => {
const { setSearchQuery } = useRelationPicker({ relationPickerScopeId });
const computeFilterFields = (relationPickerType: string) => {
if (relationPickerType === 'company') {

View File

@ -3,8 +3,13 @@ import { Field, Relation } from '~/generated-metadata/graphql';
export type FieldMetadataItem = Omit<
Field,
'fromRelationMetadata' | 'toRelationMetadata' | 'defaultValue' | 'options'
| '__typename'
| 'fromRelationMetadata'
| 'toRelationMetadata'
| 'defaultValue'
| 'options'
> & {
__typename?: string;
fromRelationMetadata?:
| (Pick<Relation, 'id' | 'toFieldMetadataId' | 'relationType'> & {
toObjectMetadata: Pick<

View File

@ -4,7 +4,8 @@ import { FieldMetadataItem } from './FieldMetadataItem';
export type ObjectMetadataItem = Omit<
GeneratedObject,
'fields' | 'dataSourceId'
'__typename' | 'fields' | 'dataSourceId'
> & {
__typename?: string;
fields: FieldMetadataItem[];
};

View File

@ -1,19 +1,13 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { parseFieldRelationType } from '@/object-metadata/utils/parseFieldRelationType';
import {
FieldMetadataItemAsFieldDefinitionProps,
formatFieldMetadataItemAsFieldDefinition,
} from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { FieldMetadataItem } from '../types/FieldMetadataItem';
import { parseFieldType } from './parseFieldType';
type FieldMetadataItemAsColumnDefinitionProps = {
position: number;
field: FieldMetadataItem;
objectMetadataItem: ObjectMetadataItem;
showLabel?: boolean;
labelWidth?: number;
};
} & FieldMetadataItemAsFieldDefinitionProps;
export const formatFieldMetadataItemAsColumnDefinition = ({
position,
@ -21,36 +15,14 @@ export const formatFieldMetadataItemAsColumnDefinition = ({
objectMetadataItem,
showLabel,
labelWidth,
}: FieldMetadataItemAsColumnDefinitionProps): ColumnDefinition<FieldMetadata> => {
const relationObjectMetadataItem =
field.toRelationMetadata?.fromObjectMetadata ||
field.fromRelationMetadata?.toObjectMetadata;
const relationFieldMetadataId =
field.toRelationMetadata?.fromFieldMetadataId ||
field.fromRelationMetadata?.toFieldMetadataId;
return {
position,
fieldMetadataId: field.id,
label: field.label,
}: FieldMetadataItemAsColumnDefinitionProps): ColumnDefinition<FieldMetadata> => ({
...formatFieldMetadataItemAsFieldDefinition({
field,
objectMetadataItem,
showLabel,
labelWidth,
size: 100,
type: parseFieldType(field.type),
metadata: {
fieldName: field.name,
placeHolder: field.label,
relationType: parseFieldRelationType(field),
relationFieldMetadataId,
relationObjectMetadataNameSingular:
relationObjectMetadataItem?.nameSingular ?? '',
relationObjectMetadataNamePlural:
relationObjectMetadataItem?.namePlural ?? '',
objectMetadataNameSingular: objectMetadataItem.nameSingular ?? '',
options: field.options,
},
iconName: field.icon ?? 'Icon123',
isVisible: true,
};
};
}),
position,
size: 100,
isVisible: true,
});

View File

@ -0,0 +1,49 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { parseFieldRelationType } from '@/object-metadata/utils/parseFieldRelationType';
import { FieldMetadataItem } from '../types/FieldMetadataItem';
import { parseFieldType } from './parseFieldType';
export type FieldMetadataItemAsFieldDefinitionProps = {
field: FieldMetadataItem;
objectMetadataItem: ObjectMetadataItem;
showLabel?: boolean;
labelWidth?: number;
};
export const formatFieldMetadataItemAsFieldDefinition = ({
field,
objectMetadataItem,
showLabel,
labelWidth,
}: FieldMetadataItemAsFieldDefinitionProps) => {
const relationObjectMetadataItem =
field.toRelationMetadata?.fromObjectMetadata ||
field.fromRelationMetadata?.toObjectMetadata;
const relationFieldMetadataId =
field.toRelationMetadata?.fromFieldMetadataId ||
field.fromRelationMetadata?.toFieldMetadataId;
return {
fieldMetadataId: field.id,
label: field.label,
showLabel,
labelWidth,
type: parseFieldType(field.type),
metadata: {
fieldName: field.name,
placeHolder: field.label,
relationType: parseFieldRelationType(field),
relationFieldMetadataId,
relationObjectMetadataNameSingular:
relationObjectMetadataItem?.nameSingular ?? '',
relationObjectMetadataNamePlural:
relationObjectMetadataItem?.namePlural ?? '',
objectMetadataNameSingular: objectMetadataItem.nameSingular ?? '',
options: field.options,
},
iconName: field.icon ?? 'Icon123',
};
};

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),
);
};

View File

@ -1,13 +1,10 @@
import { MemoryRouter } from 'react-router-dom';
import { Meta, StoryObj } from '@storybook/react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { Field, FieldMetadataType } from '~/generated-metadata/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import {
mockedCompaniesMetadata,
@ -20,22 +17,9 @@ const meta: Meta<typeof SettingsObjectFieldPreview> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldPreview',
component: SettingsObjectFieldPreview,
decorators: [
(Story) => {
// wait for metadata
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
return objectMetadataItems.length ? <Story /> : <></>;
},
ComponentDecorator,
ObjectMetadataItemsDecorator,
(Story) => (
<RecoilRoot>
<RelationPickerScope relationPickerScopeId="relation-picker">
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<Story />
</SnackBarProviderScope>
</RelationPickerScope>
</RecoilRoot>
),
SnackBarDecorator,
],
args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(

View File

@ -1,17 +1,15 @@
import { MemoryRouter } from 'react-router-dom';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/test';
import { useRecoilValue } from 'recoil';
import { fn } from '@storybook/test';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import {
mockedCompaniesMetadata,
@ -33,24 +31,14 @@ const meta: Meta<typeof SettingsObjectFieldTypeSelectSection> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldTypeSelectSection',
component: SettingsObjectFieldTypeSelectSection,
decorators: [
(Story) => {
// wait for metadata
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
return objectMetadataItems.length ? <Story /> : <></>;
},
ComponentDecorator,
ObjectMetadataItemsDecorator,
(Story) => (
<RelationPickerScope relationPickerScopeId="relation-picker">
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<Story />
</SnackBarProviderScope>
</RelationPickerScope>
),
SnackBarDecorator,
],
args: {
fieldMetadata: fieldMetadataWithoutId,
objectMetadataId: mockedCompaniesMetadata.node.id,
onChange: fn(),
values: fieldMetadataFormDefaultValues,
},
parameters: {
@ -82,10 +70,6 @@ export const WithOpenSelect: Story = {
await userEvent.click(input);
await userEvent.click(inputField);
const selectLabel = canvas.getByText('Number');
await userEvent.click(selectLabel);
},
};

View File

@ -1,24 +1,16 @@
import { useEffect } from 'react';
import { Decorator } from '@storybook/react';
import { useRecoilState } from 'recoil';
import { useRecoilValue } from 'recoil';
import { useFindManyObjectMetadataItems } from '@/object-metadata/hooks/useFindManyObjectMetadataItems';
import { ObjectMetadataItemsLoadEffect } from '@/object-metadata/components/ObjectMetadataItemsLoadEffect';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const ObjectMetadataItemsDecorator: Decorator = (Story) => {
const { objectMetadataItems: newObjectMetadataItems } =
useFindManyObjectMetadataItems();
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const [objectMetadataItems, setObjectMetadataItems] = useRecoilState(
objectMetadataItemsState,
return (
<>
<ObjectMetadataItemsLoadEffect />
{!!objectMetadataItems.length && <Story />}
</>
);
useEffect(() => {
if (!isDeeplyEqual(objectMetadataItems, newObjectMetadataItems)) {
setObjectMetadataItems(newObjectMetadataItems);
}
}, [newObjectMetadataItems, objectMetadataItems, setObjectMetadataItems]);
return <Story />;
};

File diff suppressed because it is too large Load Diff

View File

@ -35,9 +35,6 @@
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.spec.json"
}

View File

@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,7 @@
"types": ["jest", "node"]
},
"include": [
"vite.config.ts",
"jest.config.ts",
"**/*.test.ts",
"**/*.test.tsx",