mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-23 20:13:21 +03:00
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:
parent
d66d8c9907
commit
a58b4cf437
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
},
|
||||
];
|
||||
}, []),
|
||||
);
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
@ -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: {
|
||||
|
@ -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} />
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
},
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -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') {
|
||||
|
@ -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<
|
||||
|
@ -4,7 +4,8 @@ import { FieldMetadataItem } from './FieldMetadataItem';
|
||||
|
||||
export type ObjectMetadataItem = Omit<
|
||||
GeneratedObject,
|
||||
'fields' | 'dataSourceId'
|
||||
'__typename' | 'fields' | 'dataSourceId'
|
||||
> & {
|
||||
__typename?: string;
|
||||
fields: FieldMetadataItem[];
|
||||
};
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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',
|
||||
};
|
||||
};
|
@ -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,
|
||||
|
@ -37,7 +37,7 @@ export const useGetRecordFromCache = ({
|
||||
id: recordId,
|
||||
});
|
||||
|
||||
return cache.readFragment<CachedObjectRecord>({
|
||||
return cache.readFragment<CachedObjectRecord & { __typename: string }>({
|
||||
id: cachedRecordId,
|
||||
fragment: cacheReadFragment,
|
||||
});
|
||||
|
@ -12,7 +12,7 @@ export const useModifyRecordFromCache = ({
|
||||
}) => {
|
||||
const { cache } = useApolloClient();
|
||||
|
||||
return <CachedObjectRecord extends ObjectRecord>(
|
||||
return <CachedObjectRecord extends ObjectRecord = ObjectRecord>(
|
||||
recordId: string,
|
||||
fieldModifiers: Modifiers<CachedObjectRecord>,
|
||||
) => {
|
||||
|
@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -46,7 +46,6 @@ const mocks = [
|
||||
variables: {
|
||||
input: {
|
||||
id: mockedUuid,
|
||||
name: 'Opportunity',
|
||||
pipelineStepId: 'pipelineStepId',
|
||||
companyId: 'New Opportunity',
|
||||
},
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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(() => {
|
||||
|
@ -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) => {
|
||||
|
@ -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 ||
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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 =
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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),
|
||||
);
|
||||
};
|
||||
|
@ -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(
|
||||
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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
@ -35,9 +35,6 @@
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
|
@ -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
@ -5,6 +5,7 @@
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"jest.config.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
|
Loading…
Reference in New Issue
Block a user