Introduce a RelationPicker component with a RelationPickerScope (#2617)

Refactor mainIdentifier into scope componetn
This commit is contained in:
Charles Bochet
2023-11-21 16:09:02 +01:00
committed by GitHub
parent d25f00e04f
commit f97d25d986
25 changed files with 256 additions and 168 deletions

View File

@ -1,9 +1,18 @@
import { ObjectMetadataItemsRelationPickerEffect } from '@/object-metadata/components/ObjectMetadataItemsRelationPickerEffect';
import { useFindManyObjectMetadataItems } from '@/object-metadata/hooks/useFindManyObjectMetadataItems';
import { RelationPickerScope } from '@/ui/input/components/internal/relation-picker/scopes/RelationPickerScope';
export const ObjectMetadataItemsProvider = ({
children,
}: React.PropsWithChildren) => {
const { loading } = useFindManyObjectMetadataItems();
return loading ? <></> : <>{children}</>;
return loading ? (
<></>
) : (
<RelationPickerScope relationPickerScopeId="relation-picker">
<ObjectMetadataItemsRelationPickerEffect />
{children}
</RelationPickerScope>
);
};

View File

@ -0,0 +1,51 @@
import { useEffect } from 'react';
import { useRelationPicker } from '@/ui/input/components/internal/relation-picker/hooks/useRelationPicker';
import { IdentifiersMapper } from '@/ui/input/components/internal/relation-picker/types/IdentifiersMapper';
export const ObjectMetadataItemsRelationPickerEffect = () => {
const { setIdentifiersMapper } = useRelationPicker();
const identifierMapper: IdentifiersMapper = (
record: any,
objectMetadataItemSingularName: string,
) => {
if (!record) {
return;
}
if (objectMetadataItemSingularName === 'company') {
return {
id: record.id,
name: record.name,
avatarUrl: record.avatarUrl,
avatarType: 'squared',
record: record,
};
}
if (objectMetadataItemSingularName === 'workspaceMember') {
return {
id: record.id,
name: record.name.firstName + ' ' + record.name.lastName,
avatarUrl: record.avatarUrl,
avatarType: 'rounded',
record: record,
};
}
return {
id: record.id,
name: record.name,
avatarUrl: record.avatarUrl,
avatarType: 'rounded',
record,
};
};
useEffect(() => {
setIdentifiersMapper(() => identifierMapper);
}, [setIdentifiersMapper]);
return <></>;
};

View File

@ -1,5 +1,4 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { AvatarType } from '@/users/components/Avatar';
import { Nullable } from '~/types/Nullable';
export const useObjectMainIdentifier = (
@ -9,39 +8,14 @@ export const useObjectMainIdentifier = (
return {};
}
const labelIdentifierFieldPaths = ['person', 'workspaceMember'].includes(
objectMetadataItem.nameSingular,
)
? ['name.firstName', 'name.lastName']
: ['name'];
const imageIdentifierFormat: AvatarType = ['company'].includes(
objectMetadataItem.nameSingular,
)
? 'squared'
: 'rounded';
const imageIdentifierUrlPrefix = ['company'].includes(
objectMetadataItem.nameSingular,
)
? 'https://favicon.twenty.com/'
: '';
const imageIdentifierUrlField = ['company'].includes(
objectMetadataItem.nameSingular,
)
? 'domainName'
: 'avatarUrl';
const mainIdentifierFieldMetadataId = objectMetadataItem.fields.find(
const labelIdentifierFieldMetadataId = objectMetadataItem.fields.find(
({ name }) => name === 'name',
)?.id;
const basePathToShowPage = `/object/${objectMetadataItem.nameSingular}/`;
return {
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
mainIdentifierFieldMetadataId,
labelIdentifierFieldMetadataId,
basePathToShowPage,
};
};

View File

@ -1,7 +1,6 @@
import { parseFieldRelationType } from '@/object-metadata/utils/parseFieldRelationType';
import { FieldMetadata } from '@/ui/object/field/types/FieldMetadata';
import { ColumnDefinition } from '@/ui/object/record-table/types/ColumnDefinition';
import { AvatarType } from '@/users/components/Avatar';
import { FieldMetadataItem } from '../types/FieldMetadataItem';
@ -17,27 +16,6 @@ export const formatFieldMetadataItemAsColumnDefinition = ({
const relationObjectMetadataItem =
field.toRelationMetadata?.fromObjectMetadata;
const labelIdentifierFieldPaths = ['person', 'workspaceMember'].includes(
relationObjectMetadataItem?.nameSingular ?? '',
)
? ['name.firstName', 'name.lastName']
: ['name'];
const imageIdentifierFormat: AvatarType = ['company'].includes(
relationObjectMetadataItem?.nameSingular ?? '',
)
? 'squared'
: 'rounded';
const imageIdentifierUrlPrefix = ['company'].includes(
relationObjectMetadataItem?.nameSingular ?? '',
)
? 'https://favicon.twenty.com/'
: '';
const imageIdentifierUrlField = ['company'].includes(
relationObjectMetadataItem?.nameSingular ?? '',
)
? 'domainName'
: 'avatarUrl';
return {
position,
fieldMetadataId: field.id,
@ -47,15 +25,10 @@ export const formatFieldMetadataItemAsColumnDefinition = ({
metadata: {
fieldName: field.name,
placeHolder: field.label,
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
relationType: parseFieldRelationType(field),
searchFields: ['name'],
objectMetadataNamePlural: relationObjectMetadataItem?.namePlural ?? '',
objectMetadataNameSingular:
relationObjectMetadataItem?.nameSingular ?? '',
objectMetadataNamePlural: relationObjectMetadataItem?.namePlural ?? '',
},
iconName: field.icon ?? 'Icon123',
isVisible: true,

View File

@ -21,14 +21,8 @@ export const RecordTableEffect = () => {
objectNamePlural,
});
const {
basePathToShowPage,
mainIdentifierFieldMetadataId,
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
} = useObjectMainIdentifier(objectMetadataItem);
const { basePathToShowPage, labelIdentifierFieldMetadataId } =
useObjectMainIdentifier(objectMetadataItem);
const { columnDefinitions, filterDefinitions, sortDefinitions } =
useComputeDefinitionsFromFieldMetadata(objectMetadataItem);
@ -43,25 +37,17 @@ export const RecordTableEffect = () => {
} = useView();
useEffect(() => {
if (basePathToShowPage && mainIdentifierFieldMetadataId) {
if (basePathToShowPage && labelIdentifierFieldMetadataId) {
setObjectMetadataConfig?.({
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
basePathToShowPage,
mainIdentifierFieldMetadataId,
labelIdentifierFieldMetadataId,
});
}
}, [
basePathToShowPage,
objectMetadataItem,
mainIdentifierFieldMetadataId,
labelIdentifierFieldMetadataId,
setObjectMetadataConfig,
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
]);
useEffect(() => {

View File

@ -4,6 +4,7 @@ import { isNonEmptyString } from '@sniptt/guards';
import { mapPaginatedObjectsToObjects } from '@/object-record/utils/mapPaginatedObjectsToObjects';
import { EntitiesForMultipleEntitySelect } from '@/ui/input/relation-picker/components/MultipleEntitySelect';
import { EntityForSelect } from '@/ui/input/relation-picker/types/EntityForSelect';
import { assertNotNull } from '~/utils/assert';
import { isDefined } from '~/utils/isDefined';
type SearchFilter = { fieldNames: string[]; filter: string | number };
@ -36,7 +37,7 @@ export const useFilteredSearchEntityQuery = ({
filters: SearchFilter[];
sortOrder?: OrderBy;
selectedIds: string[];
mappingFunction: (entity: any) => EntityForSelect;
mappingFunction: (entity: any) => EntityForSelect | undefined;
limit?: number;
excludeEntityIds?: string[];
objectNamePlural: string;
@ -139,15 +140,21 @@ export const useFilteredSearchEntityQuery = ({
selectedEntities: mapPaginatedObjectsToObjects({
objectNamePlural: objectNamePlural,
pagedObjects: selectedEntitiesData,
}).map(mappingFunction),
})
.map(mappingFunction)
.filter(assertNotNull),
filteredSelectedEntities: mapPaginatedObjectsToObjects({
objectNamePlural: objectNamePlural,
pagedObjects: filteredSelectedEntitiesData,
}).map(mappingFunction),
})
.map(mappingFunction)
.filter(assertNotNull),
entitiesToSelect: mapPaginatedObjectsToObjects({
objectNamePlural: objectNamePlural,
pagedObjects: entitiesToSelectData,
}).map(mappingFunction),
})
.map(mappingFunction)
.filter(assertNotNull),
loading:
entitiesToSelectLoading ||
filteredSelectedEntitiesLoading ||

View File

@ -90,13 +90,7 @@ export const SettingsObjectFieldPreview = ({
objectMetadataId,
});
const {
defaultValue: relationDefaultValue,
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
} = useRelationFieldPreview({
const { defaultValue: relationDefaultValue } = useRelationFieldPreview({
relationObjectMetadataId,
skipDefaultValue:
fieldMetadata.type !== FieldMetadataType.Relation || hasValue,
@ -107,15 +101,6 @@ export const SettingsObjectFieldPreview = ({
? relationDefaultValue
: dataTypes[fieldMetadata.type].defaultValue;
if (
!labelIdentifierFieldPaths ||
!imageIdentifierUrlField ||
!imageIdentifierUrlPrefix ||
!imageIdentifierFormat
) {
return <></>;
}
return (
<StyledContainer className={className}>
<StyledObjectSummary>
@ -160,10 +145,6 @@ export const SettingsObjectFieldPreview = ({
label: fieldMetadata.label,
metadata: {
fieldName,
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
},
},
hotkeyScope: 'field-preview',

View File

@ -1,4 +1,3 @@
import { useObjectMainIdentifier } from '@/object-metadata/hooks/useObjectMainIdentifier';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFindManyObjectRecords } from '@/object-record/hooks/useFindManyObjectRecords';
@ -20,18 +19,7 @@ export const useRelationFieldPreview = ({
skip: skipDefaultValue || !relationObjectMetadataItem,
});
const {
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
} = useObjectMainIdentifier(relationObjectMetadataItem);
return {
defaultValue: relationObjects?.[0],
labelIdentifierFieldPaths,
imageIdentifierUrlField,
imageIdentifierUrlPrefix,
imageIdentifierFormat,
};
};

View File

@ -42,18 +42,25 @@ export const RelationPicker = ({
const useFindManyQuery = (options: any) => useQuery(findManyQuery, options);
const { mapToObjectIdentifiers } = useRelationField();
const { identifiersMapper, searchQuery } = useRelationField();
const workspaceMembers = useFilteredSearchEntityQuery({
queryHook: useFindManyQuery,
filters: [
{
fieldNames: fieldDefinition.metadata.searchFields,
fieldNames:
searchQuery?.filterFields?.(
fieldDefinition.metadata.objectMetadataNameSingular,
) ?? [],
filter: relationPickerSearchFilter,
},
],
orderByField: 'createdAt',
mappingFunction: mapToObjectIdentifiers,
mappingFunction: (record: any) =>
identifiersMapper?.(
record,
fieldDefinition.metadata.objectMetadataNameSingular,
),
selectedIds: recordId ? [recordId] : [],
objectNamePlural: fieldDefinition.metadata.objectMetadataNamePlural,
});

View File

@ -0,0 +1,28 @@
import { getRelationPickerScopedStates } from '@/ui/input/components/internal/relation-picker/utils/getRelationPickerScopedStates';
import { RecordTableScopeInternalContext } from '@/ui/object/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
export const useRelationPickerScopedStates = (args?: {
relationPickerScopedId?: string;
}) => {
const { relationPickerScopedId } = args ?? {};
const scopeId = useAvailableScopeIdOrThrow(
RecordTableScopeInternalContext,
relationPickerScopedId,
);
const { identifiersMapperState } = getRelationPickerScopedStates({
relationPickerScopeId: scopeId,
});
const { searchQueryState } = getRelationPickerScopedStates({
relationPickerScopeId: scopeId,
});
return {
scopeId,
identifiersMapperState,
searchQueryState,
};
};

View File

@ -0,0 +1,35 @@
import { useRecoilState } from 'recoil';
import { useRelationPickerScopedStates } from '@/ui/input/components/internal/relation-picker/hooks/internal/useRelationPickerScopedStates';
import { RelationPickerScopeInternalContext } from '@/ui/input/components/internal/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
type useRelationPickeProps = {
relationPickerScopeId?: string;
};
export const useRelationPicker = (props?: useRelationPickeProps) => {
const scopeId = useAvailableScopeIdOrThrow(
RelationPickerScopeInternalContext,
props?.relationPickerScopeId,
);
const { identifiersMapperState, searchQueryState } =
useRelationPickerScopedStates({
relationPickerScopedId: scopeId,
});
const [identifiersMapper, setIdentifiersMapper] = useRecoilState(
identifiersMapperState,
);
const [searchQuery, setSearchQuery] = useRecoilState(searchQueryState);
return {
scopeId,
identifiersMapper,
setIdentifiersMapper,
searchQuery,
setSearchQuery,
};
};

View File

@ -0,0 +1,21 @@
import { ReactNode } from 'react';
import { RelationPickerScopeInternalContext } from '@/ui/input/components/internal/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext';
type RelationPickerScopeProps = {
children: ReactNode;
relationPickerScopeId: string;
};
export const RelationPickerScope = ({
children,
relationPickerScopeId,
}: RelationPickerScopeProps) => {
return (
<RelationPickerScopeInternalContext.Provider
value={{ scopeId: relationPickerScopeId }}
>
{children}
</RelationPickerScopeInternalContext.Provider>
);
};

View File

@ -0,0 +1,7 @@
import { ScopedStateKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopedStateKey';
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
type RelationPickerScopeInternalContextProps = ScopedStateKey;
export const RelationPickerScopeInternalContext =
createScopeInternalContext<RelationPickerScopeInternalContextProps>();

View File

@ -0,0 +1,8 @@
import { IdentifiersMapper } from '@/ui/input/components/internal/relation-picker/types/IdentifiersMapper';
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const identifiersMapperScopedState =
createScopedState<IdentifiersMapper | null>({
key: 'identifiersMapperScopedState',
defaultValue: null,
});

View File

@ -0,0 +1,7 @@
import { SearchQuery } from '@/ui/input/components/internal/relation-picker/types/SearchQuery';
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
export const searchQueryScopedState = createScopedState<SearchQuery | null>({
key: 'searchQueryScopedState',
defaultValue: null,
});

View File

@ -0,0 +1,14 @@
import { AvatarType } from '@/users/components/Avatar';
type RecordMappedToIdentifiers = {
id: string;
name: string;
avatarUrl?: string;
avatarType: AvatarType;
record: any;
};
export type IdentifiersMapper = (
record: any,
relationPickerType: string,
) => RecordMappedToIdentifiers | undefined;

View File

@ -0,0 +1,3 @@
export type SearchQuery = {
filterFields: (relationPickerType: string) => string[];
};

View File

@ -0,0 +1,24 @@
import { identifiersMapperScopedState } from '@/ui/input/components/internal/relation-picker/states/identifiersMapperScopedState';
import { searchQueryScopedState } from '@/ui/input/components/internal/relation-picker/states/searchQueryScopedState';
import { getScopedState } from '@/ui/utilities/recoil-scope/utils/getScopedState';
export const getRelationPickerScopedStates = ({
relationPickerScopeId,
}: {
relationPickerScopeId: string;
}) => {
const identifiersMapperState = getScopedState(
identifiersMapperScopedState,
relationPickerScopeId,
);
const searchQueryState = getScopedState(
searchQueryScopedState,
relationPickerScopeId,
);
return {
identifiersMapperState,
searchQueryState,
};
};

View File

@ -1,9 +1,7 @@
import { ScopedStateKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopedStateKey';
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
type DropdownScopeInternalContextProps = ScopedStateKey & {
test?: string;
};
type DropdownScopeInternalContextProps = ScopedStateKey;
export const DropdownScopeInternalContext =
createScopeInternalContext<DropdownScopeInternalContextProps>();

View File

@ -5,20 +5,23 @@ import { useRelationField } from '../../hooks/useRelationField';
export const RelationFieldDisplay = () => {
const { fieldValue, fieldDefinition } = useRelationField();
const { mapToObjectIdentifiers } = useRelationField();
const { identifiersMapper } = useRelationField();
if (!fieldValue || !fieldDefinition) {
if (!fieldValue || !fieldDefinition || !identifiersMapper) {
return <></>;
}
const objectIdentifiers = mapToObjectIdentifiers(fieldValue);
const objectIdentifiers = identifiersMapper(
fieldValue,
fieldDefinition.metadata.objectMetadataNameSingular,
);
return (
<EntityChip
entityId={fieldValue.id}
name={objectIdentifiers.name}
avatarUrl={objectIdentifiers.avatarUrl}
avatarType={objectIdentifiers.avatarType}
name={objectIdentifiers?.name ?? ''}
avatarUrl={objectIdentifiers?.avatarUrl}
avatarType={objectIdentifiers?.avatarType}
/>
);
};

View File

@ -1,6 +1,8 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { useRelationPicker } from '@/ui/input/components/internal/relation-picker/hooks/useRelationPicker';
import { FieldContext } from '../../contexts/FieldContext';
import { useFieldInitialValue } from '../../hooks/useFieldInitialValue';
import { entityFieldsFamilySelector } from '../../states/selectors/entityFieldsFamilySelector';
@ -30,35 +32,7 @@ export const useRelationField = () => {
const initialValue = fieldInitialValue?.isEmpty ? null : fieldValue;
const mapToObjectIdentifiers = (record: any) => {
let name = '';
for (const fieldPath of fieldDefinition.metadata
.labelIdentifierFieldPaths) {
const fieldPathParts = fieldPath.split('.');
if (fieldPathParts.length === 1) {
name += record[fieldPathParts[0]];
} else if (fieldPathParts.length === 2) {
name += record[fieldPathParts[0]][fieldPathParts[1]] + ' ';
} else {
throw new Error(
`Invalid field path ${fieldPath}. Relation picker only supports field paths with 1 or 2 parts.`,
);
}
}
const avatarUrl = record[fieldDefinition.metadata.imageIdentifierUrlField];
return {
id: record.id,
name: name.trimEnd(),
avatarUrl: avatarUrl
? fieldDefinition.metadata.imageIdentifierUrlPrefix +
record[fieldDefinition.metadata.imageIdentifierUrlField]
: '',
avatarType: fieldDefinition.metadata.imageIdentifierFormat,
record: record,
};
};
const { identifiersMapper, searchQuery } = useRelationPicker();
return {
fieldDefinition,
@ -66,6 +40,7 @@ export const useRelationField = () => {
initialValue,
initialSearchValue,
setFieldValue,
mapToObjectIdentifiers,
searchQuery,
identifiersMapper,
};
};

View File

@ -1,8 +1,8 @@
import { useEffect } from 'react';
import styled from '@emotion/styled';
import { RelationPicker } from '@/ui/input/components/internal/relation-picker/RelationPicker';
import { EntityForSelect } from '@/ui/input/relation-picker/types/EntityForSelect';
import { RelationPicker } from '@/ui/object/field/meta-types/input/components/internal/RelationPicker';
import { usePersistField } from '../../../hooks/usePersistField';
import { useRelationField } from '../../hooks/useRelationField';

View File

@ -64,11 +64,6 @@ export type FieldRelationMetadata = {
fieldName: string;
useEditButton?: boolean;
relationType?: FieldDefinitionRelationType;
labelIdentifierFieldPaths: string[];
imageIdentifierUrlField: string;
imageIdentifierUrlPrefix: string;
imageIdentifierFormat: 'squared' | 'rounded';
searchFields: string[];
objectMetadataNameSingular: string;
objectMetadataNamePlural: string;
};

View File

@ -62,7 +62,7 @@ export const RecordTableCell = ({ cellIndex }: { cellIndex: number }) => {
hotkeyScope: customHotkeyScope,
isMainIdentifier:
columnDefinition.fieldMetadataId ===
objectMetadataConfig?.mainIdentifierFieldMetadataId,
objectMetadataConfig?.labelIdentifierFieldMetadataId,
}}
>
<TableCell customHotkeyScope={{ scope: customHotkeyScope }} />

View File

@ -1,10 +1,4 @@
import { AvatarType } from '@/users/components/Avatar';
export type ObjectMetadataConfig = {
mainIdentifierFieldMetadataId: string;
labelIdentifierFieldPaths: string[];
imageIdentifierUrlField: string;
imageIdentifierUrlPrefix: string;
imageIdentifierFormat: AvatarType;
labelIdentifierFieldMetadataId: string;
basePathToShowPage: string;
};