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

@ -9,9 +9,9 @@
"start:clean": "yarn start --force", "start:clean": "yarn start --force",
"build": "tsc && vite build && yarn build:inject-runtime-env", "build": "tsc && vite build && yarn build:inject-runtime-env",
"build:inject-runtime-env": "sh ./scripts/inject-runtime-env.sh", "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": "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", "preview": "vite preview",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0 --config .eslintrc.cjs", "lint": "eslint . --report-unused-disable-directives --max-warnings 0 --config .eslintrc.cjs",
"lint:ci": "yarn lint --config .eslintrc-ci.cjs", "lint:ci": "yarn lint --config .eslintrc-ci.cjs",

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 { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test'; import { expect, userEvent, within } from '@storybook/test';
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandType } from '@/command-menu/types/Command'; import { CommandType } from '@/command-menu/types/Command';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { IconCheckbox, IconNotes } from '@/ui/display/icon'; import { IconCheckbox, IconNotes } from '@/ui/display/icon';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator'; import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks'; import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockDefaultWorkspace } from '~/testing/mock-data/users'; import { mockDefaultWorkspace } from '~/testing/mock-data/users';
import { sleep } from '~/testing/sleep'; import { sleep } from '~/testing/sleep';
@ -27,7 +26,6 @@ const meta: Meta<typeof CommandMenu> = {
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const { addToCommandMenu, setToIntitialCommandMenu, openCommandMenu } = const { addToCommandMenu, setToIntitialCommandMenu, openCommandMenu } =
useCommandMenu(); useCommandMenu();
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
setCurrentWorkspace(mockDefaultWorkspace); setCurrentWorkspace(mockDefaultWorkspace);
@ -54,16 +52,10 @@ const meta: Meta<typeof CommandMenu> = {
openCommandMenu(); openCommandMenu();
}, [addToCommandMenu, setToIntitialCommandMenu, openCommandMenu]); }, [addToCommandMenu, setToIntitialCommandMenu, openCommandMenu]);
return objectMetadataItems.length ? <Story /> : <></>; return <Story />;
}, },
ObjectMetadataItemsDecorator, ObjectMetadataItemsDecorator,
(Story) => ( SnackBarDecorator,
<RecoilRoot>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<Story />
</SnackBarProviderScope>
</RecoilRoot>
),
ComponentWithRouterDecorator, ComponentWithRouterDecorator,
], ],
parameters: { 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 { BoardColumnContext } from '@/object-record/record-board-deprecated/contexts/BoardColumnContext';
import { useCreateOpportunity } from '@/object-record/record-board-deprecated/hooks/internal/useCreateOpportunity'; import { useCreateOpportunity } from '@/object-record/record-board-deprecated/hooks/internal/useCreateOpportunity';
import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect'; 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 { 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 { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
@ -52,30 +50,16 @@ export const NewOpportunityButton = () => {
setIsCreatingCard(false); setIsCreatingCard(false);
}; };
const { relationPickerSearchFilter, searchQuery } = useRelationPicker();
const filteredSearchEntityResults = useFilteredSearchEntityQuery({
filters: [
{
fieldNames: searchQuery?.computeFilterFields?.('company') ?? [],
filter: relationPickerSearchFilter,
},
],
orderByField: 'createdAt',
selectedIds: [],
objectNameSingular: CoreObjectNameSingular.Company,
});
return ( return (
<> <>
{isCreatingCard ? ( {isCreatingCard ? (
<SingleEntitySelect <SingleEntitySelect
disableBackgroundBlur disableBackgroundBlur
entitiesToSelect={filteredSearchEntityResults.entitiesToSelect}
loading={filteredSearchEntityResults.loading}
onCancel={handleCancel} onCancel={handleCancel}
onEntitySelected={handleEntitySelect} onEntitySelected={handleEntitySelect}
selectedEntity={filteredSearchEntityResults.selectedEntities[0]} relationObjectNameSingular={CoreObjectNameSingular.Company}
relationPickerScopeId="relation-picker"
selectedRelationRecordIds={[]}
/> />
) : ( ) : (
<NewButton onClick={handleNewClick} /> <NewButton onClick={handleNewClick} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 createOneRecord = async (input: Partial<CreatedObjectRecord>) => {
const optimisticallyCreatedRecord =
generateCachedObjectRecord<CreatedObjectRecord>(input);
const sanitizedCreateOneRecordInput = sanitizeRecordInput({ const sanitizedCreateOneRecordInput = sanitizeRecordInput({
objectMetadataItem, objectMetadataItem,
recordInput: { ...input, id: optimisticallyCreatedRecord.id }, recordInput: input,
}); });
const optimisticallyCreatedRecord =
generateCachedObjectRecord<CreatedObjectRecord>({
...input,
...sanitizedCreateOneRecordInput,
});
const mutationResponseField = const mutationResponseField =
getCreateOneRecordMutationResponseField(objectNameSingular); getCreateOneRecordMutationResponseField(objectNameSingular);
const createdObject = await apolloClient.mutate({ const createdObject = await apolloClient.mutate({
mutation: createOneRecordMutation, mutation: createOneRecordMutation,
variables: { variables: {
input: sanitizedCreateOneRecordInput, input: {
...sanitizedCreateOneRecordInput,
id: optimisticallyCreatedRecord.id,
},
}, },
optimisticResponse: { optimisticResponse: {
[mutationResponseField]: optimisticallyCreatedRecord, [mutationResponseField]: optimisticallyCreatedRecord,

View File

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

View File

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

View File

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

View File

@ -46,7 +46,6 @@ const mocks = [
variables: { variables: {
input: { input: {
id: mockedUuid, id: mockedUuid,
name: 'Opportunity',
pipelineStepId: 'pipelineStepId', pipelineStepId: 'pipelineStepId',
companyId: 'New Opportunity', 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 { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { import {
FieldBooleanMetadata,
FieldFullNameMetadata, FieldFullNameMetadata,
FieldLinkMetadata, FieldLinkMetadata,
FieldPhoneMetadata,
FieldRatingMetadata, FieldRatingMetadata,
FieldRelationMetadata,
FieldSelectMetadata, FieldSelectMetadata,
FieldTextMetadata, FieldTextMetadata,
} from '@/object-record/record-field/types/FieldMetadata'; } from '@/object-record/record-field/types/FieldMetadata';
import {
mockedCompaniesMetadata,
mockedPeopleMetadata,
} from '~/testing/mock-data/metadata';
export const fieldMetadataId = 'fieldMetadataId'; 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> = { export const textfieldDefinition: FieldDefinition<FieldTextMetadata> = {
fieldMetadataId, fieldMetadataId,
label: 'User Name', label: 'User Name',
@ -20,29 +31,15 @@ export const textfieldDefinition: FieldDefinition<FieldTextMetadata> = {
metadata: { placeHolder: 'John Doe', fieldName: 'userName' }, metadata: { placeHolder: 'John Doe', fieldName: 'userName' },
}; };
export const booleanFieldDefinition: FieldDefinition<FieldBooleanMetadata> = { const relationFieldMetadataItem = mockedPersonObjectMetadataItem.fields.find(
fieldMetadataId, ({ name }) => name === 'company',
label: 'Is Active?', );
iconName: 'iconName', export const relationFieldDefinition = formatFieldMetadataItemAsFieldDefinition(
type: 'BOOLEAN', {
metadata: { field: relationFieldMetadataItem!,
objectMetadataNameSingular: 'person', objectMetadataItem: mockedPersonObjectMetadataItem,
fieldName: 'isActive',
}, },
}; );
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> = { export const selectFieldDefinition: FieldDefinition<FieldSelectMetadata> = {
fieldMetadataId, fieldMetadataId,
@ -77,17 +74,13 @@ export const linkFieldDefinition: FieldDefinition<FieldLinkMetadata> = {
}, },
}; };
export const phoneFieldDefinition: FieldDefinition<FieldPhoneMetadata> = { const phoneFieldMetadataItem = mockedPersonObjectMetadataItem.fields.find(
fieldMetadataId, ({ name }) => name === 'phone',
label: 'Contact', );
iconName: 'Phone', export const phoneFieldDefinition = formatFieldMetadataItemAsFieldDefinition({
type: 'TEXT', field: phoneFieldMetadataItem!,
metadata: { objectMetadataItem: mockedPersonObjectMetadataItem,
objectMetadataNameSingular: 'person', });
placeHolder: '(+256)-712-345-6789',
fieldName: 'phone',
},
};
export const ratingfieldDefinition: FieldDefinition<FieldRatingMetadata> = { export const ratingfieldDefinition: FieldDefinition<FieldRatingMetadata> = {
fieldMetadataId, fieldMetadataId,
@ -98,3 +91,11 @@ export const ratingfieldDefinition: FieldDefinition<FieldRatingMetadata> = {
fieldName: 'rating', 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` const query = gql`
mutation UpdateOneWorkspaceMember( mutation UpdateOnePerson($idToUpdate: ID!, $input: PersonUpdateInput!) {
$idToUpdate: ID! updatePerson(id: $idToUpdate, data: $input) {
$input: WorkspaceMemberUpdateInput!
) {
updateWorkspaceMember(id: $idToUpdate, data: $input) {
id id
} }
} }
@ -43,7 +40,7 @@ const mocks: MockedResponse[] = [
}, },
result: jest.fn(() => ({ result: jest.fn(() => ({
data: { data: {
updateWorkspaceMember: { updatePerson: {
id: 'entityId', id: 'entityId',
}, },
}, },
@ -54,12 +51,12 @@ const mocks: MockedResponse[] = [
query, query,
variables: { variables: {
idToUpdate: 'entityId', idToUpdate: 'entityId',
input: { contactId: null, contact: { foo: 'bar' } }, input: { companyId: 'companyId' },
}, },
}, },
result: jest.fn(() => ({ result: jest.fn(() => ({
data: { data: {
updateWorkspaceMember: { updatePerson: {
id: 'entityId', id: 'entityId',
}, },
}, },
@ -68,14 +65,13 @@ const mocks: MockedResponse[] = [
]; ];
const entityId = 'entityId'; const entityId = 'entityId';
const fieldName = 'phone';
const getWrapper = const getWrapper =
(fieldDefinition: FieldDefinition<FieldMetadata>) => (fieldDefinition: FieldDefinition<FieldMetadata>) =>
({ children }: { children: ReactNode }) => { ({ children }: { children: ReactNode }) => {
const useUpdateOneRecordMutation: RecordUpdateHook = () => { const useUpdateOneRecordMutation: RecordUpdateHook = () => {
const { updateOneRecord } = useUpdateOneRecord({ const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember, objectNameSingular: CoreObjectNameSingular.Person,
}); });
const updateEntity = ({ variables }: RecordUpdateHookParams) => { const updateEntity = ({ variables }: RecordUpdateHookParams) => {
@ -113,7 +109,7 @@ describe('usePersistField', () => {
const { result } = renderHook( const { result } = renderHook(
() => { () => {
const entityFields = useRecoilValue( const entityFields = useRecoilValue(
recordStoreFamilySelector({ recordId: entityId, fieldName }), recordStoreFamilySelector({ recordId: entityId, fieldName: 'phone' }),
); );
return { return {
@ -137,7 +133,10 @@ describe('usePersistField', () => {
const { result } = renderHook( const { result } = renderHook(
() => { () => {
const entityFields = useRecoilValue( const entityFields = useRecoilValue(
recordStoreFamilySelector({ recordId: entityId, fieldName }), recordStoreFamilySelector({
recordId: entityId,
fieldName: 'company',
}),
); );
return { return {
@ -149,7 +148,7 @@ describe('usePersistField', () => {
); );
act(() => { act(() => {
result.current.persistField({ foo: 'bar' }); result.current.persistField({ id: 'companyId' });
}); });
await waitFor(() => { await waitFor(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isFieldRelationValue } from '@/object-record/record-field/types/guards/isFieldRelationValue';
import { FieldMetadataType } from '~/generated/graphql'; import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
export const sanitizeRecordInput = ({ export const sanitizeRecordInput = ({
objectMetadataItem, objectMetadataItem,
@ -9,12 +11,30 @@ export const sanitizeRecordInput = ({
recordInput: Record<string, unknown>; recordInput: Record<string, unknown>;
}) => { }) => {
return Object.fromEntries( return Object.fromEntries(
Object.entries(recordInput).filter(([fieldName]) => { Object.entries(recordInput)
const fieldDefinition = objectMetadataItem.fields.find( .map<[string, unknown] | undefined>(([fieldName, fieldValue]) => {
(field) => field.name === fieldName, 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 { MemoryRouter } from 'react-router-dom';
import { Meta, StoryObj } from '@storybook/react'; 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 { Field, FieldMetadataType } from '~/generated-metadata/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks'; import { graphqlMocks } from '~/testing/graphqlMocks';
import { import {
mockedCompaniesMetadata, mockedCompaniesMetadata,
@ -20,22 +17,9 @@ const meta: Meta<typeof SettingsObjectFieldPreview> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldPreview', title: 'Modules/Settings/DataModel/SettingsObjectFieldPreview',
component: SettingsObjectFieldPreview, component: SettingsObjectFieldPreview,
decorators: [ decorators: [
(Story) => {
// wait for metadata
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
return objectMetadataItems.length ? <Story /> : <></>;
},
ComponentDecorator, ComponentDecorator,
ObjectMetadataItemsDecorator, ObjectMetadataItemsDecorator,
(Story) => ( SnackBarDecorator,
<RecoilRoot>
<RelationPickerScope relationPickerScopeId="relation-picker">
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<Story />
</SnackBarProviderScope>
</RelationPickerScope>
</RecoilRoot>
),
], ],
args: { args: {
fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find( fieldMetadata: mockedCompaniesMetadata.node.fields.edges.find(

View File

@ -1,17 +1,15 @@
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/test'; 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 { import {
FieldMetadataType, FieldMetadataType,
RelationMetadataType, RelationMetadataType,
} from '~/generated-metadata/graphql'; } from '~/generated-metadata/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks'; import { graphqlMocks } from '~/testing/graphqlMocks';
import { import {
mockedCompaniesMetadata, mockedCompaniesMetadata,
@ -33,24 +31,14 @@ const meta: Meta<typeof SettingsObjectFieldTypeSelectSection> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldTypeSelectSection', title: 'Modules/Settings/DataModel/SettingsObjectFieldTypeSelectSection',
component: SettingsObjectFieldTypeSelectSection, component: SettingsObjectFieldTypeSelectSection,
decorators: [ decorators: [
(Story) => {
// wait for metadata
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
return objectMetadataItems.length ? <Story /> : <></>;
},
ComponentDecorator, ComponentDecorator,
ObjectMetadataItemsDecorator, ObjectMetadataItemsDecorator,
(Story) => ( SnackBarDecorator,
<RelationPickerScope relationPickerScopeId="relation-picker">
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<Story />
</SnackBarProviderScope>
</RelationPickerScope>
),
], ],
args: { args: {
fieldMetadata: fieldMetadataWithoutId, fieldMetadata: fieldMetadataWithoutId,
objectMetadataId: mockedCompaniesMetadata.node.id, objectMetadataId: mockedCompaniesMetadata.node.id,
onChange: fn(),
values: fieldMetadataFormDefaultValues, values: fieldMetadataFormDefaultValues,
}, },
parameters: { parameters: {
@ -82,10 +70,6 @@ export const WithOpenSelect: Story = {
await userEvent.click(input); await userEvent.click(input);
await userEvent.click(inputField); 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 { 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 { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const ObjectMetadataItemsDecorator: Decorator = (Story) => { export const ObjectMetadataItemsDecorator: Decorator = (Story) => {
const { objectMetadataItems: newObjectMetadataItems } = const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
useFindManyObjectMetadataItems();
const [objectMetadataItems, setObjectMetadataItems] = useRecoilState( return (
objectMetadataItemsState, <>
<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.app.json"
}, },
{
"path": "./tsconfig.node.json"
},
{ {
"path": "./tsconfig.spec.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"] "types": ["jest", "node"]
}, },
"include": [ "include": [
"vite.config.ts",
"jest.config.ts", "jest.config.ts",
"**/*.test.ts", "**/*.test.ts",
"**/*.test.tsx", "**/*.test.tsx",