feat: expand relation record cards on click in Record Show page (#4570)

Closes #3126
This commit is contained in:
Thaïs
2024-04-02 09:42:57 +02:00
committed by GitHub
parent 746747ba2b
commit dc8ab5d95a
8 changed files with 271 additions and 101 deletions

View File

@ -0,0 +1,41 @@
import { useLazyQuery } from '@apollo/client';
import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
type UseLazyFindOneRecordParams = ObjectMetadataItemIdentifier & {
depth?: number;
};
type FindOneRecordParams<T extends ObjectRecord> = {
objectRecordId: string | undefined;
onCompleted?: (data: T) => void;
};
export const useLazyFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
depth,
}: UseLazyFindOneRecordParams) => {
const { objectMetadataItem } = useObjectMetadataItemOnly({
objectNameSingular,
});
const findOneRecordQuery = useGenerateFindOneRecordQuery();
const [findOneRecord, { loading, error, data, called }] = useLazyQuery(
findOneRecordQuery({ objectMetadataItem, depth }),
);
return {
findOneRecord: ({ objectRecordId, onCompleted }: FindOneRecordParams<T>) =>
findOneRecord({
variables: { objectRecordId },
onCompleted: (data) => onCompleted?.(data[objectNameSingular]),
}),
called,
error,
loading,
record: data?.[objectNameSingular] || undefined,
};
};

View File

@ -1,8 +1,8 @@
import groupBy from 'lodash.groupby';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { isObjectMetadataAvailableForRelation } from '@/object-metadata/utils/isObjectMetadataAvailableForRelation';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import {
FieldContext,
@ -17,7 +17,7 @@ import { RecordDetailRelationSection } from '@/object-record/record-show/record-
import { recordLoadingFamilyState } from '@/object-record/record-store/states/recordLoadingFamilyState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { recordStoreIdentifierFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreIdentifierSelector';
import { isFieldMetadataItemAvailable } from '@/object-record/utils/isFieldMetadataItemAvailable';
import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported';
import { ShowPageContainer } from '@/ui/layout/page/ShowPageContainer';
import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPageLeftContainer';
import { ShowPageRightContainer } from '@/ui/layout/show-page/components/ShowPageRightContainer';
@ -89,13 +89,7 @@ export const RecordShowContainer = ({
const avatarUrl = result?.data?.uploadImage;
if (!avatarUrl) {
return;
}
if (isUndefinedOrNull(updateOneRecord)) {
return;
}
if (!recordFromStore) {
if (!avatarUrl || isUndefinedOrNull(updateOneRecord) || !recordFromStore) {
return;
}
@ -110,21 +104,19 @@ export const RecordShowContainer = ({
const availableFieldMetadataItems = objectMetadataItem.fields
.filter(
(fieldMetadataItem) =>
isFieldMetadataItemAvailable(fieldMetadataItem) &&
isFieldCellSupported(fieldMetadataItem) &&
fieldMetadataItem.id !== labelIdentifierFieldMetadata?.id,
)
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name),
);
const inlineFieldMetadataItems = availableFieldMetadataItems.filter(
const { inlineFieldMetadataItems, relationFieldMetadataItems } = groupBy(
availableFieldMetadataItems,
(fieldMetadataItem) =>
fieldMetadataItem.type !== FieldMetadataType.Relation,
);
const relationFieldMetadataItems = availableFieldMetadataItems.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.Relation,
fieldMetadataItem.type === FieldMetadataType.Relation
? 'relationFieldMetadataItems'
: 'inlineFieldMetadataItems',
);
return (
@ -197,40 +189,25 @@ export const RecordShowContainer = ({
objectRecordId={objectRecordId}
objectNameSingular={objectNameSingular}
/>
{relationFieldMetadataItems
.filter((item) => {
const relationObjectMetadataItem = item.toRelationMetadata
? item.toRelationMetadata.fromObjectMetadata
: item.fromRelationMetadata?.toObjectMetadata;
if (!relationObjectMetadataItem) {
return false;
}
return isObjectMetadataAvailableForRelation(
relationObjectMetadataItem,
);
})
.map((fieldMetadataItem, index) => (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
entityId: objectRecordId,
recoilScopeId: objectRecordId + fieldMetadataItem.id,
isLabelIdentifier: false,
fieldDefinition:
formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
hotkeyScope: InlineCellHotkeyScope.InlineCell,
}}
>
<RecordDetailRelationSection />
</FieldContext.Provider>
))}
{relationFieldMetadataItems.map((fieldMetadataItem, index) => (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
entityId: objectRecordId,
recoilScopeId: objectRecordId + fieldMetadataItem.id,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
hotkeyScope: InlineCellHotkeyScope.InlineCell,
}}
>
<RecordDetailRelationSection />
</FieldContext.Provider>
))}
</>
)}
</ShowPageLeftContainer>

View File

@ -2,7 +2,6 @@ import styled from '@emotion/styled';
const StyledRecordsList = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
overflow: hidden;
`;
export { StyledRecordsList as RecordDetailRecordsList };

View File

@ -1,3 +1,5 @@
import { useState } from 'react';
import { RecordDetailRecordsList } from '@/object-record/record-show/record-detail-section/components/RecordDetailRecordsList';
import { RecordDetailRelationRecordsListItem } from '@/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
@ -8,13 +10,22 @@ type RecordDetailRelationRecordsListProps = {
export const RecordDetailRelationRecordsList = ({
relationRecords,
}: RecordDetailRelationRecordsListProps) => (
<RecordDetailRecordsList>
{relationRecords.slice(0, 5).map((relationRecord) => (
<RecordDetailRelationRecordsListItem
key={relationRecord.id}
relationRecord={relationRecord}
/>
))}
</RecordDetailRecordsList>
);
}: RecordDetailRelationRecordsListProps) => {
const [expandedItem, setExpandedItem] = useState('');
const handleItemClick = (recordId: string) =>
setExpandedItem(recordId === expandedItem ? '' : recordId);
return (
<RecordDetailRecordsList>
{relationRecords.slice(0, 5).map((relationRecord) => (
<RecordDetailRelationRecordsListItem
key={relationRecord.id}
isExpanded={expandedItem === relationRecord.id}
onClick={handleItemClick}
relationRecord={relationRecord}
/>
))}
</RecordDetailRecordsList>
);
};

View File

@ -1,23 +1,42 @@
import { useContext } from 'react';
import { useCallback, useContext } from 'react';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { LightIconButton, MenuItem } from 'tsup.ui.index';
import { IconDotsVertical, IconTrash, IconUnlink } from 'twenty-ui';
import {
IconChevronDown,
IconDotsVertical,
IconTrash,
IconUnlink,
} from 'twenty-ui';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { RecordChip } from '@/object-record/components/RecordChip';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord.ts';
import { useLazyFindOneRecord } from '@/object-record/hooks/useLazyFindOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import {
FieldContext,
RecordUpdateHook,
RecordUpdateHookParams,
} from '@/object-record/record-field/contexts/FieldContext';
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { RecordDetailRecordsListItem } from '@/object-record/record-show/record-detail-section/components/RecordDetailRecordsListItem';
import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { AnimatedEaseInOut } from '@/ui/utilities/animation/components/AnimatedEaseInOut';
const StyledListItem = styled(RecordDetailRecordsListItem)<{
isDropdownOpen?: boolean;
@ -40,11 +59,26 @@ const StyledListItem = styled(RecordDetailRecordsListItem)<{
}
`;
const StyledClickableZone = styled.div`
align-items: center;
cursor: pointer;
display: flex;
flex: 1 0 auto;
height: 100%;
justify-content: flex-end;
`;
const MotionIconChevronDown = motion(IconChevronDown);
type RecordDetailRelationRecordsListItemProps = {
isExpanded: boolean;
onClick: (relationRecordId: string) => void;
relationRecord: ObjectRecord;
};
export const RecordDetailRelationRecordsListItem = ({
isExpanded,
onClick,
relationRecord,
}: RecordDetailRelationRecordsListItemProps) => {
const { fieldDefinition } = useContext(FieldContext);
@ -57,18 +91,39 @@ export const RecordDetailRelationRecordsListItem = ({
const isToOneObject = relationType === 'TO_ONE_OBJECT';
const { objectMetadataItem: relationObjectMetadataItem } =
useObjectMetadataItem({
useObjectMetadataItemOnly({
objectNameSingular: relationObjectMetadataNameSingular,
});
const persistField = usePersistField();
const {
called: hasFetchedRelationRecord,
findOneRecord: findOneRelationRecord,
} = useLazyFindOneRecord({
objectNameSingular: relationObjectMetadataNameSingular,
});
const { updateOneRecord: updateOneRelationRecord } = useUpdateOneRecord({
objectNameSingular: relationObjectMetadataNameSingular,
});
const { deleteOneRecord: deleteOneRelationRecord } = useDeleteOneRecord({
objectNameSingular: relationObjectMetadataNameSingular,
});
const isAccountOwnerRelation =
relationObjectMetadataNameSingular ===
CoreObjectNameSingular.WorkspaceMember;
const availableRelationFieldMetadataItems = relationObjectMetadataItem.fields
.filter(
(fieldMetadataItem) =>
isFieldCellSupported(fieldMetadataItem) &&
fieldMetadataItem.id !==
relationObjectMetadataItem.labelIdentifierFieldMetadataId &&
fieldMetadataItem.id !== relationFieldMetadataId,
)
.sort();
const dropdownScopeId = `record-field-card-menu-${relationRecord.id}`;
const { closeDropdown, isDropdownOpen } = useDropdown(dropdownScopeId);
@ -100,17 +155,58 @@ export const RecordDetailRelationRecordsListItem = ({
await deleteOneRelationRecord(relationRecord.id);
};
const isAccountOwnerRelation =
relationObjectMetadataNameSingular ===
CoreObjectNameSingular.WorkspaceMember;
const useUpdateOneObjectRecordMutation: RecordUpdateHook = () => {
const updateEntity = ({ variables }: RecordUpdateHookParams) => {
updateOneRelationRecord?.({
idToUpdate: variables.where.id as string,
updateOneRecordInput: variables.updateOneRecordInput,
});
};
return [updateEntity, { loading: false }];
};
const { setRecords } = useSetRecordInStore();
const handleClick = () => onClick(relationRecord.id);
const AnimatedIconChevronDown = useCallback<IconComponent>(
(props) => (
<MotionIconChevronDown
className={props.className}
color={props.color}
size={props.size}
stroke={props.stroke}
initial={{ rotate: isExpanded ? 0 : -180 }}
animate={{ rotate: isExpanded ? -180 : 0 }}
/>
),
[isExpanded],
);
return (
<StyledListItem isDropdownOpen={isDropdownOpen}>
<RecordChip
record={relationRecord}
objectNameSingular={relationObjectMetadataItem.nameSingular}
/>
{
<>
<StyledListItem isDropdownOpen={isDropdownOpen}>
<RecordChip
record={relationRecord}
objectNameSingular={relationObjectMetadataItem.nameSingular}
/>
<StyledClickableZone
onClick={handleClick}
onMouseOver={() =>
!hasFetchedRelationRecord &&
findOneRelationRecord({
objectRecordId: relationRecord.id,
onCompleted: (record) => setRecords([record]),
})
}
>
<LightIconButton
className="displayOnHover"
Icon={AnimatedIconChevronDown}
accent="tertiary"
/>
</StyledClickableZone>
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
dropdownId={dropdownScopeId}
@ -139,12 +235,38 @@ export const RecordDetailRelationRecordsListItem = ({
)}
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{
scope: dropdownScopeId,
}}
dropdownHotkeyScope={{ scope: dropdownScopeId }}
/>
</DropdownScope>
}
</StyledListItem>
</StyledListItem>
<AnimatedEaseInOut isOpen={isExpanded}>
<PropertyBox>
{availableRelationFieldMetadataItems.map(
(fieldMetadataItem, index) => (
<FieldContext.Provider
key={fieldMetadataItem.id}
value={{
entityId: relationRecord.id,
maxWidth: 200,
recoilScopeId: `${relationRecord.id}-${fieldMetadataItem.id}`,
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem: relationObjectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
hotkeyScope: InlineCellHotkeyScope.InlineCell,
}}
>
<RecordInlineCell />
</FieldContext.Provider>
),
)}
</PropertyBox>
</AnimatedEaseInOut>
</>
);
};

View File

@ -4,7 +4,7 @@ import qs from 'qs';
import { useRecoilValue } from 'recoil';
import { IconForbid, IconPencil, IconPlus } from 'twenty-ui';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
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';
@ -42,7 +42,7 @@ export const RecordDetailRelationSection = () => {
const record = useRecoilValue(recordStoreFamilyState(entityId));
const { objectMetadataItem: relationObjectMetadataItem } =
useObjectMetadataItem({
useObjectMetadataItemOnly({
objectNameSingular: relationObjectMetadataNameSingular,
});

View File

@ -0,0 +1,37 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isObjectMetadataAvailableForRelation } from '@/object-metadata/utils/isObjectMetadataAvailableForRelation';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
export const isFieldCellSupported = (fieldMetadataItem: FieldMetadataItem) => {
if (
[FieldMetadataType.Uuid, FieldMetadataType.Position].includes(
fieldMetadataItem.type,
)
) {
return false;
}
if (fieldMetadataItem.type === FieldMetadataType.Relation) {
const relationMetadata =
fieldMetadataItem.fromRelationMetadata ??
fieldMetadataItem.toRelationMetadata;
const relationObjectMetadataItem =
fieldMetadataItem.fromRelationMetadata?.toObjectMetadata ??
fieldMetadataItem.toRelationMetadata?.fromObjectMetadata;
if (
!relationMetadata ||
// TODO: Many to many relations are not supported yet.
relationMetadata.relationType === RelationMetadataType.ManyToMany ||
!relationObjectMetadataItem ||
!isObjectMetadataAvailableForRelation(relationObjectMetadataItem)
) {
return false;
}
}
return !fieldMetadataItem.isSystem && !!fieldMetadataItem.isActive;
};

View File

@ -1,17 +0,0 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { RelationMetadataType } from '~/generated-metadata/graphql';
export const isFieldMetadataItemAvailable = (
fieldMetadataItem: FieldMetadataItem,
) =>
fieldMetadataItem.type !== 'UUID' &&
// TODO: Many to many relations are not supported yet.
!(
fieldMetadataItem.type === 'RELATION' &&
(
fieldMetadataItem.fromRelationMetadata ??
fieldMetadataItem.toRelationMetadata
)?.relationType === RelationMetadataType.ManyToMany
) &&
!fieldMetadataItem.isSystem &&
!!fieldMetadataItem.isActive;