feat: display label identifier table cell as chip with link to Record… (#3503)

* feat: display label identifier table cell as chip with link to RecordShowPage

Closes #3502

* Fix test

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thaïs
2024-01-17 13:44:36 -03:00
committed by GitHub
parent 4b7e42c38e
commit 2d929c3b91
32 changed files with 162 additions and 459 deletions

View File

@ -21,7 +21,7 @@ export const RecordChip = ({ objectNameSingular, record }: RecordChipProps) => {
entityId={record.id}
name={objectRecordIdentifier.name}
avatarType={objectRecordIdentifier.avatarType}
avatarUrl={objectRecordIdentifier.avatarUrl ?? undefined}
avatarUrl={objectRecordIdentifier.avatarUrl}
linkToEntity={objectRecordIdentifier.linkToShowPage}
/>
);

View File

@ -27,39 +27,33 @@ import { isFieldUuid } from '../types/guards/isFieldUuid';
export const FieldDisplay = () => {
const { fieldDefinition, isLabelIdentifier } = useContext(FieldContext);
if (
isLabelIdentifier &&
(isFieldText(fieldDefinition) || isFieldFullName(fieldDefinition))
) {
return <ChipFieldDisplay />;
}
return (
<>
{isFieldRelation(fieldDefinition) ? (
<RelationFieldDisplay />
) : isFieldText(fieldDefinition) ? (
<TextFieldDisplay />
) : isFieldUuid(fieldDefinition) ? (
<UuidFieldDisplay />
) : isFieldEmail(fieldDefinition) ? (
<EmailFieldDisplay />
) : isFieldDateTime(fieldDefinition) ? (
<DateFieldDisplay />
) : isFieldNumber(fieldDefinition) ? (
<NumberFieldDisplay />
) : isFieldLink(fieldDefinition) ? (
<LinkFieldDisplay />
) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldDisplay />
) : isFieldFullName(fieldDefinition) ? (
<FullNameFieldDisplay />
) : isFieldPhone(fieldDefinition) ? (
<PhoneFieldDisplay />
) : isFieldSelect(fieldDefinition) ? (
<SelectFieldDisplay />
) : (
<></>
)}
</>
);
return isLabelIdentifier &&
(isFieldText(fieldDefinition) ||
isFieldFullName(fieldDefinition) ||
isFieldNumber(fieldDefinition)) ? (
<ChipFieldDisplay />
) : isFieldRelation(fieldDefinition) ? (
<RelationFieldDisplay />
) : isFieldText(fieldDefinition) ? (
<TextFieldDisplay />
) : isFieldUuid(fieldDefinition) ? (
<UuidFieldDisplay />
) : isFieldEmail(fieldDefinition) ? (
<EmailFieldDisplay />
) : isFieldDateTime(fieldDefinition) ? (
<DateFieldDisplay />
) : isFieldNumber(fieldDefinition) ? (
<NumberFieldDisplay />
) : isFieldLink(fieldDefinition) ? (
<LinkFieldDisplay />
) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldDisplay />
) : isFieldFullName(fieldDefinition) ? (
<FullNameFieldDisplay />
) : isFieldPhone(fieldDefinition) ? (
<PhoneFieldDisplay />
) : isFieldSelect(fieldDefinition) ? (
<SelectFieldDisplay />
) : null;
};

View File

@ -1,25 +1,12 @@
import { RecordChip } from '@/object-record/components/RecordChip';
import { useChipField } from '@/object-record/field/meta-types/hooks/useChipField';
import { EntityChip } from '@/ui/display/chip/components/EntityChip';
export const ChipFieldDisplay = () => {
const {
record,
entityId,
identifiersMapper,
objectNameSingular,
basePathToShowPage,
} = useChipField();
const { objectNameSingular, record } = useChipField();
// TODO: remove this and use ObjectRecordChip instead
const identifiers = identifiersMapper?.(record, objectNameSingular ?? '');
if (!record) return null;
return (
<EntityChip
name={identifiers?.name ?? ''}
avatarUrl={identifiers?.avatarUrl}
avatarType={identifiers?.avatarType}
entityId={entityId}
linkToEntity={basePathToShowPage + entityId}
/>
<RecordChip objectNameSingular={objectNameSingular || ''} record={record} />
);
};

View File

@ -1,30 +1,18 @@
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
import { EntityChip } from '@/ui/display/chip/components/EntityChip';
import { RecordChip } from '@/object-record/components/RecordChip';
import { useRelationField } from '../../hooks/useRelationField';
export const RelationFieldDisplay = () => {
const { fieldValue, fieldDefinition } = useRelationField();
const { identifiersMapper } = useRelationPicker({
relationPickerScopeId: 'relation-picker',
});
if (!fieldValue || !fieldDefinition || !identifiersMapper) {
return <></>;
}
const objectIdentifiers = identifiersMapper(
fieldValue,
fieldDefinition.metadata.relationObjectMetadataNameSingular,
);
if (!fieldValue || !fieldDefinition) return null;
return (
<EntityChip
entityId={fieldValue.id}
name={objectIdentifiers?.name ?? ''}
avatarUrl={objectIdentifiers?.avatarUrl}
avatarType={objectIdentifiers?.avatarType}
<RecordChip
objectNameSingular={
fieldDefinition.metadata.relationObjectMetadataNameSingular
}
record={fieldValue}
/>
);
};

View File

@ -3,31 +3,25 @@ import { useRecoilValue } from 'recoil';
import { entityFieldsFamilyState } from '@/object-record/field/states/entityFieldsFamilyState';
import { isFieldFullName } from '@/object-record/field/types/guards/isFieldFullName';
import { isFieldNumber } from '@/object-record/field/types/guards/isFieldNumber';
import { isFieldText } from '@/object-record/field/types/guards/isFieldText';
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
import { FieldContext } from '../../contexts/FieldContext';
export const useChipField = () => {
const { entityId, fieldDefinition, basePathToShowPage } =
useContext(FieldContext);
const { entityId, fieldDefinition } = useContext(FieldContext);
const objectNameSingular =
isFieldText(fieldDefinition) || isFieldFullName(fieldDefinition)
isFieldText(fieldDefinition) ||
isFieldFullName(fieldDefinition) ||
isFieldNumber(fieldDefinition)
? fieldDefinition.metadata.objectMetadataNameSingular
: undefined;
const record = useRecoilValue<any | null>(entityFieldsFamilyState(entityId));
const { identifiersMapper } = useRelationPicker({
relationPickerScopeId: 'relation-picker',
});
const record = useRecoilValue(entityFieldsFamilyState(entityId));
return {
basePathToShowPage,
entityId,
objectNameSingular,
record,
identifiersMapper,
};
};

View File

@ -1,20 +1,28 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { useSetRecoilState } from 'recoil';
import { entityFieldsFamilyState } from '@/object-record/field/states/entityFieldsFamilyState';
import { FieldContextProvider } from '../../../__stories__/FieldContextProvider';
import { useBooleanField } from '../../../hooks/useBooleanField';
import {
BooleanFieldInput,
BooleanFieldInputProps,
} from '../BooleanFieldInput';
const BooleanFieldValueSetterEffect = ({ value }: { value: boolean }) => {
const { setFieldValue } = useBooleanField();
const BooleanFieldValueSetterEffect = ({
value,
entityId,
}: {
value: boolean;
entityId: string;
}) => {
const setField = useSetRecoilState(entityFieldsFamilyState(entityId));
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
setField({ id: entityId, Boolean: value });
}, [entityId, setField, value]);
return <></>;
};
@ -42,7 +50,7 @@ const BooleanFieldInputWithContext = ({
}}
entityId={entityId}
>
<BooleanFieldValueSetterEffect value={value} />
<BooleanFieldValueSetterEffect value={value} entityId={entityId ?? ''} />
<BooleanFieldInput onSubmit={onSubmit} testId="boolean-field-input" />
</FieldContextProvider>
);
@ -53,6 +61,7 @@ const meta: Meta = {
component: BooleanFieldInputWithContext,
args: {
value: true,
entityId: 'id-1',
},
};

View File

@ -1,9 +1,8 @@
import { atomFamily } from 'recoil';
export const entityFieldsFamilyState = atomFamily<
Record<string, unknown> | null,
string
>({
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const entityFieldsFamilyState = atomFamily<ObjectRecord | null, string>({
key: 'entityFieldsFamilyState',
default: null,
});

View File

@ -11,8 +11,7 @@ export const entityFieldsFamilySelector = selectorFamily({
set:
<T>({ fieldName, entityId }: { fieldName: string; entityId: string }) =>
({ set }, newValue: T) =>
set(entityFieldsFamilyState(entityId), (prevState) => ({
...prevState,
[fieldName]: newValue,
})),
set(entityFieldsFamilyState(entityId), (prevState) =>
prevState ? { ...prevState, [fieldName]: newValue } : null,
),
});

View File

@ -125,7 +125,7 @@ export const RecordRelationFieldCardSection = () => {
const { relationPickerSearchFilter, setRelationPickerSearchFilter } =
useRelationPicker({ relationPickerScopeId: dropdownId });
const { identifiersMapper, searchQuery } = useRelationPicker();
const { searchQuery } = useRelationPicker();
const entities = useFilteredSearchEntityQuery({
filters: [
@ -138,8 +138,6 @@ export const RecordRelationFieldCardSection = () => {
},
],
orderByField: 'createdAt',
mappingFunction: (recordToMap) =>
identifiersMapper?.(recordToMap, relationObjectMetadataNameSingular),
selectedIds: relationRecordIds,
excludeEntityIds: relationRecordIds,
objectNameSingular: relationObjectMetadataNameSingular,

View File

@ -117,17 +117,13 @@ export const RecordTable = ({
recordTableScopeId={scopeId}
onColumnsChange={onColumnsChange}
>
<>
{objectNamePlural ? (
<StyledTable ref={recordTableRef} className="entity-table-cell">
<RecordTableHeader createRecord={createRecord} />
<RecordTableBodyEffect objectNamePlural={objectNamePlural} />
<RecordTableBody objectNamePlural={objectNamePlural} />
</StyledTable>
) : (
<></>
)}
</>
{!!objectNamePlural && (
<StyledTable ref={recordTableRef} className="entity-table-cell">
<RecordTableHeader createRecord={createRecord} />
<RecordTableBodyEffect objectNamePlural={objectNamePlural} />
<RecordTableBody objectNamePlural={objectNamePlural} />
</StyledTable>
)}
</RecordTableScope>
);
};

View File

@ -1,8 +1,8 @@
import { useContext } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useSetRecoilState } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState';
import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState';
@ -26,9 +26,6 @@ export const RecordTableCellContainer = ({
const setContextMenuPosition = useSetRecoilState(contextMenuPositionState);
const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState);
const currentRowId = useContext(RowIdContext);
const { getObjectMetadataConfigState } = useRecordTableStates();
const objectMetadataConfig = useRecoilValue(getObjectMetadataConfigState());
const { setCurrentRowSelected } = useCurrentRowSelected();
@ -44,6 +41,11 @@ export const RecordTableCellContainer = ({
const columnDefinition = useContext(ColumnContext);
const { basePathToShowPage, objectMetadataItem } = useObjectMetadataItem({
objectNameSingular:
columnDefinition?.metadata.objectMetadataNameSingular || '',
});
const updateRecord = useContext(RecordUpdateContext);
if (!columnDefinition || !currentRowId) {
@ -65,16 +67,13 @@ export const RecordTableCellContainer = ({
fieldDefinition: columnDefinition,
useUpdateRecord: () => [updateRecord, {}],
hotkeyScope: customHotkeyScope,
basePathToShowPage: objectMetadataConfig?.basePathToShowPage,
basePathToShowPage,
isLabelIdentifier: isLabelIdentifierField({
fieldMetadataItem: {
id: columnDefinition.fieldMetadataId,
name: columnDefinition.metadata.fieldName,
},
objectMetadataItem: {
labelIdentifierFieldMetadataId:
objectMetadataConfig?.labelIdentifierFieldMetadataId,
},
objectMetadataItem,
}),
}}
>

View File

@ -96,6 +96,6 @@ export const RecordTableCell = ({
/>
}
nonEditModeContent={<FieldDisplay />}
></TableCellContainer>
/>
);
};

View File

@ -1,6 +1,5 @@
import { useEffect } from 'react';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { FieldDefinition } from '@/object-record/field/types/FieldDefinition';
import { FieldRelationMetadata } from '@/object-record/field/types/FieldMetadata';
import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect';
@ -31,7 +30,6 @@ export const RelationPicker = ({
const {
relationPickerSearchFilter,
setRelationPickerSearchFilter,
identifiersMapper,
searchQuery,
} = useRelationPicker({ relationPickerScopeId: 'relation-picker' });
@ -39,12 +37,6 @@ export const RelationPicker = ({
setRelationPickerSearchFilter(initialSearchFilter ?? '');
}, [initialSearchFilter, setRelationPickerSearchFilter]);
const { objectNameSingular: relationObjectNameSingular } =
useObjectNameSingularFromPlural({
objectNamePlural:
fieldDefinition.metadata.relationObjectMetadataNamePlural,
});
const entities = useFilteredSearchEntityQuery({
filters: [
{
@ -56,18 +48,15 @@ export const RelationPicker = ({
},
],
orderByField: 'createdAt',
mappingFunction: (record: any) =>
identifiersMapper?.(
record,
fieldDefinition.metadata.relationObjectMetadataNameSingular,
),
selectedIds: recordId ? [recordId] : [],
excludeEntityIds: excludeRecordIds,
objectNameSingular: relationObjectNameSingular,
objectNameSingular:
fieldDefinition.metadata.relationObjectMetadataNameSingular,
});
const handleEntitySelected = (selectedEntity: any | null | undefined) =>
onSubmit(selectedEntity ?? null);
const handleEntitySelected = (
selectedEntity: EntityForSelect | null | undefined,
) => onSubmit(selectedEntity ?? null);
return (
<SingleEntitySelect

View File

@ -14,6 +14,8 @@ import { SingleEntitySelect } from '../SingleEntitySelect';
const entities = mockedPeopleData.map<EntityForSelect>((person) => ({
id: person.id,
name: person.name.firstName + ' ' + person.name.lastName,
avatarUrl: person.avatarUrl,
avatarType: 'rounded',
record: person,
}));

View File

@ -13,7 +13,6 @@ export const useRelationPickerScopedStates = (args?: {
);
const {
identifiersMapperState,
relationPickerSearchFilterState,
relationPickerPreselectedIdState,
searchQueryState,
@ -23,7 +22,6 @@ export const useRelationPickerScopedStates = (args?: {
return {
scopeId,
identifiersMapperState,
relationPickerSearchFilterState,
relationPickerPreselectedIdState,
searchQueryState,

View File

@ -15,7 +15,6 @@ export const useRelationPicker = (props?: useRelationPickeProps) => {
);
const {
identifiersMapperState,
searchQueryState,
relationPickerSearchFilterState,
relationPickerPreselectedIdState,
@ -23,10 +22,6 @@ export const useRelationPicker = (props?: useRelationPickeProps) => {
relationPickerScopedId: scopeId,
});
const [identifiersMapper, setIdentifiersMapper] = useRecoilState(
identifiersMapperState,
);
const [searchQuery, setSearchQuery] = useRecoilState(searchQueryState);
const [relationPickerSearchFilter, setRelationPickerSearchFilter] =
@ -37,8 +32,6 @@ export const useRelationPicker = (props?: useRelationPickeProps) => {
return {
scopeId,
identifiersMapper,
setIdentifiersMapper,
searchQuery,
setSearchQuery,
relationPickerSearchFilter,

View File

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

View File

@ -1,9 +1,4 @@
import { AvatarType } from '@/users/components/Avatar';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
export type EntityForSelect = {
id: string;
name: string;
avatarUrl?: string;
avatarType?: AvatarType;
record: any;
};
export type EntityForSelect = ObjectRecordIdentifier & { record: ObjectRecord };

View File

@ -1,14 +0,0 @@
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

@ -1,4 +1,3 @@
import { identifiersMapperScopedState } from '@/object-record/relation-picker/states/identifiersMapperScopedState';
import { relationPickerPreselectedIdScopedState } from '@/object-record/relation-picker/states/relationPickerPreselectedIdScopedState';
import { relationPickerSearchFilterScopedState } from '@/object-record/relation-picker/states/relationPickerSearchFilterScopedState';
import { searchQueryScopedState } from '@/object-record/relation-picker/states/searchQueryScopedState';
@ -9,11 +8,6 @@ export const getRelationPickerScopedStates = ({
}: {
relationPickerScopeId: string;
}) => {
const identifiersMapperState = getScopedStateDeprecated(
identifiersMapperScopedState,
relationPickerScopeId,
);
const searchQueryState = getScopedStateDeprecated(
searchQueryScopedState,
relationPickerScopeId,
@ -30,7 +24,6 @@ export const getRelationPickerScopedStates = ({
);
return {
identifiersMapperState,
relationPickerSearchFilterState,
relationPickerPreselectedIdState,
searchQueryState,

View File

@ -3,7 +3,7 @@ import { AvatarType } from '@/users/components/Avatar';
export type ObjectRecordIdentifier = {
id: string;
name: string;
avatarUrl?: string | null;
avatarUrl: string;
avatarType?: AvatarType | null;
linkToShowPage?: string;
};