From dc8ab5d95a426b88fc72775daf963cbcf026c173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tha=C3=AFs?= Date: Tue, 2 Apr 2024 09:42:57 +0200 Subject: [PATCH] feat: expand relation record cards on click in Record Show page (#4570) Closes #3126 --- .../hooks/useLazyFindOneRecord.ts | 41 +++++ .../components/RecordShowContainer.tsx | 79 +++------ .../components/RecordDetailRecordsList.tsx | 1 - .../RecordDetailRelationRecordsList.tsx | 31 ++-- .../RecordDetailRelationRecordsListItem.tsx | 162 +++++++++++++++--- .../RecordDetailRelationSection.tsx | 4 +- .../utils/isFieldCellSupported.ts | 37 ++++ .../utils/isFieldMetadataItemAvailable.ts | 17 -- 8 files changed, 271 insertions(+), 101 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/hooks/useLazyFindOneRecord.ts create mode 100644 packages/twenty-front/src/modules/object-record/utils/isFieldCellSupported.ts delete mode 100644 packages/twenty-front/src/modules/object-record/utils/isFieldMetadataItemAvailable.ts diff --git a/packages/twenty-front/src/modules/object-record/hooks/useLazyFindOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useLazyFindOneRecord.ts new file mode 100644 index 000000000..8ef752088 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useLazyFindOneRecord.ts @@ -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 = { + objectRecordId: string | undefined; + onCompleted?: (data: T) => void; +}; + +export const useLazyFindOneRecord = ({ + 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) => + findOneRecord({ + variables: { objectRecordId }, + onCompleted: (data) => onCompleted?.(data[objectNameSingular]), + }), + called, + error, + loading, + record: data?.[objectNameSingular] || undefined, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx index 4b33988e7..98c7d8eb5 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx @@ -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) => ( - - - - ))} + {relationFieldMetadataItems.map((fieldMetadataItem, index) => ( + + + + ))} )} diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRecordsList.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRecordsList.tsx index d18c51bec..0317df930 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRecordsList.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRecordsList.tsx @@ -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 }; diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsList.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsList.tsx index 0c2df8ae8..c8ce45c0b 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsList.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsList.tsx @@ -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) => ( - - {relationRecords.slice(0, 5).map((relationRecord) => ( - - ))} - -); +}: RecordDetailRelationRecordsListProps) => { + const [expandedItem, setExpandedItem] = useState(''); + + const handleItemClick = (recordId: string) => + setExpandedItem(recordId === expandedItem ? '' : recordId); + + return ( + + {relationRecords.slice(0, 5).map((relationRecord) => ( + + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx index b45e77cc5..863111c31 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx @@ -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( + (props) => ( + + ), + [isExpanded], + ); return ( - - - { + <> + + + + !hasFetchedRelationRecord && + findOneRelationRecord({ + objectRecordId: relationRecord.id, + onCompleted: (record) => setRecords([record]), + }) + } + > + + } - dropdownHotkeyScope={{ - scope: dropdownScopeId, - }} + dropdownHotkeyScope={{ scope: dropdownScopeId }} /> - } - + + + + {availableRelationFieldMetadataItems.map( + (fieldMetadataItem, index) => ( + + + + ), + )} + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx index 7a3252e7d..2decc3441 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx @@ -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, }); diff --git a/packages/twenty-front/src/modules/object-record/utils/isFieldCellSupported.ts b/packages/twenty-front/src/modules/object-record/utils/isFieldCellSupported.ts new file mode 100644 index 000000000..c06278e9a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/isFieldCellSupported.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/isFieldMetadataItemAvailable.ts b/packages/twenty-front/src/modules/object-record/utils/isFieldMetadataItemAvailable.ts deleted file mode 100644 index dc992cc06..000000000 --- a/packages/twenty-front/src/modules/object-record/utils/isFieldMetadataItemAvailable.ts +++ /dev/null @@ -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;