From db46dd44972235bbae55e143ff902b19f8a7d5ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tha=C3=AFs?= Date: Fri, 5 Jan 2024 07:02:02 -0300 Subject: [PATCH] feat: add RecordRelationFieldCardSection (#3176) Closes #3123 Co-authored-by: Lucas Bordeau --- .../components/AddPersonToCompany.tsx | 188 ----------------- .../companies/components/CompanyTeam.tsx | 95 --------- ...rmatFieldMetadataItemAsColumnDefinition.ts | 8 +- .../components/RecordShowPage.tsx | 59 ++++-- .../meta-types/hooks/useRelationField.ts | 5 +- .../field/types/FieldMetadata.ts | 9 +- .../object-record/hooks/useFieldContext.tsx | 23 ++- .../object-record/hooks/useFindOneRecord.ts | 20 +- .../useUpsertRecordFromState.ts} | 5 +- .../RecordRelationFieldCardContent.tsx | 52 +++++ .../RecordRelationFieldCardSection.tsx | 109 ++++++++++ .../record-table/hooks/useRecordTable.ts | 4 +- .../utils/isFieldMetadataItemAvailable.ts | 8 +- .../modules/people/components/PeopleCard.tsx | 195 ------------------ .../components/ShowPageLeftContainer.tsx | 23 +-- .../components/ShowPageSummaryCard.tsx | 3 +- .../extend-object-type-definition.factory.ts | 96 ++++----- 17 files changed, 296 insertions(+), 606 deletions(-) delete mode 100644 packages/twenty-front/src/modules/companies/components/AddPersonToCompany.tsx delete mode 100644 packages/twenty-front/src/modules/companies/components/CompanyTeam.tsx rename packages/twenty-front/src/modules/object-record/{record-table/hooks/internal/useUpsertRecordTableItem.ts => hooks/useUpsertRecordFromState.ts} (88%) create mode 100644 packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardContent.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx delete mode 100644 packages/twenty-front/src/modules/people/components/PeopleCard.tsx diff --git a/packages/twenty-front/src/modules/companies/components/AddPersonToCompany.tsx b/packages/twenty-front/src/modules/companies/components/AddPersonToCompany.tsx deleted file mode 100644 index 1f6cd9499..000000000 --- a/packages/twenty-front/src/modules/companies/components/AddPersonToCompany.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { useState } from 'react'; -import { useMutation } from '@apollo/client'; -import { getOperationName } from '@apollo/client/utilities'; -import styled from '@emotion/styled'; -import { flip, offset, useFloating } from '@floating-ui/react'; -import { v4 } from 'uuid'; - -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { FieldDoubleText } from '@/object-record/field/types/FieldDoubleText'; -import { RelationPicker } from '@/object-record/relation-picker/components/RelationPicker'; -import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; -import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; -import { IconPlus } from '@/ui/display/icon'; -import { DoubleTextInput } from '@/ui/field/input/components/DoubleTextInput'; -import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; -import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; -import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; - -const StyledContainer = styled.div` - position: static; -`; - -const StyledInputContainer = styled.div` - background-color: transparent; - box-shadow: ${({ theme }) => theme.boxShadow.strong}; - display: flex; - gap: ${({ theme }) => theme.spacing(0.5)}; - width: ${({ theme }) => theme.spacing(62.5)}; - & input, - div { - background-color: ${({ theme }) => theme.background.primary}; - width: 100%; - } - div { - border-radius: ${({ theme }) => theme.spacing(1)}; - overflow: hidden; - } - input { - display: flex; - flex-grow: 1; - padding: ${({ theme }) => theme.spacing(2)}; - } -`; - -export const AddPersonToCompany = ({ - companyId, - peopleIds, -}: { - companyId: string; - peopleIds?: string[]; -}) => { - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [isCreationDropdownOpen, setIsCreationDropdownOpen] = useState(false); - const { refs, floatingStyles } = useFloating({ - open: isDropdownOpen, - placement: 'right-start', - middleware: [flip(), offset({ mainAxis: 30, crossAxis: 0 })], - }); - - const handleEscape = () => { - if (isCreationDropdownOpen) setIsCreationDropdownOpen(false); - if (isDropdownOpen) setIsDropdownOpen(false); - }; - - const { - setHotkeyScopeAndMemorizePreviousScope, - goBackToPreviousHotkeyScope, - } = usePreviousHotkeyScope(); - - const { - findManyRecordsQuery, - updateOneRecordMutation, - createOneRecordMutation, - } = useObjectMetadataItem({ - objectNameSingular: CoreObjectNameSingular.Person, - }); - - const [updatePerson] = useMutation(updateOneRecordMutation); - const [createPerson] = useMutation(createOneRecordMutation); - - const handlePersonSelected = - (companyId: string) => async (newPerson: EntityForSelect | null) => { - if (!newPerson) return; - await updatePerson({ - variables: { - idToUpdate: newPerson.id, - input: { - companyId: companyId, - }, - }, - refetchQueries: [getOperationName(findManyRecordsQuery) ?? ''], - }); - - handleClosePicker(); - }; - - const handleClosePicker = () => { - if (isDropdownOpen) { - setIsDropdownOpen(false); - goBackToPreviousHotkeyScope(); - } - }; - - const handleOpenPicker = () => { - if (!isDropdownOpen) { - setIsDropdownOpen(true); - setHotkeyScopeAndMemorizePreviousScope( - RelationPickerHotkeyScope.RelationPicker, - ); - } - }; - - const handleCreatePerson = async ({ - firstValue, - secondValue, - }: FieldDoubleText) => { - if (!firstValue && !secondValue) return; - const newPersonId = v4(); - - await createPerson({ - variables: { - input: { - companyId: companyId, - id: newPersonId, - name: { - firstName: firstValue, - lastName: secondValue, - }, - }, - }, - refetchQueries: [getOperationName(findManyRecordsQuery) ?? ''], - }); - - setIsCreationDropdownOpen(false); - }; - - return ( - - - - - {isDropdownOpen && ( -
- {isCreationDropdownOpen ? ( - - - - ) : ( - - )} -
- )} -
-
- ); -}; diff --git a/packages/twenty-front/src/modules/companies/components/CompanyTeam.tsx b/packages/twenty-front/src/modules/companies/components/CompanyTeam.tsx deleted file mode 100644 index 2816fe2ab..000000000 --- a/packages/twenty-front/src/modules/companies/components/CompanyTeam.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useQuery } from '@apollo/client'; -import styled from '@emotion/styled'; -import { isNonEmptyArray } from '@sniptt/guards'; - -import { Company } from '@/companies/types/Company'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { mapPaginatedRecordsToRecords } from '@/object-record/utils/mapPaginatedRecordsToRecords'; -import { PeopleCard } from '@/people/components/PeopleCard'; - -import { AddPersonToCompany } from './AddPersonToCompany'; - -export type CompanyTeamProps = { - company: Pick; -}; - -const StyledContainer = styled.div` - display: flex; - flex-direction: column; - gap: ${({ theme }) => theme.spacing(2)}; - margin-bottom: ${({ theme }) => theme.spacing(6)}; -`; - -const StyledTitleContainer = styled.div` - align-items: center; - color: ${({ theme }) => theme.font.color.primary}; - display: flex; - justify-content: space-between; - padding-bottom: ${({ theme }) => theme.spacing(0)}; - padding-top: ${({ theme }) => theme.spacing(3)}; -`; - -const StyledListContainer = styled.div` - align-items: flex-start; - border: 1px solid ${({ theme }) => theme.border.color.medium}; - border-radius: ${({ theme }) => theme.spacing(1)}; - box-sizing: border-box; - display: flex; - flex-direction: column; - overflow: auto; - width: 100%; -`; - -const StyledTitle = styled.div` - color: ${({ theme }) => theme.font.color.primary}; - font-weight: ${({ theme }) => theme.font.weight.medium}; - line-height: ${({ theme }) => theme.text.lineHeight.lg}; -`; - -export const CompanyTeam = ({ company }: { company: any }) => { - const { findManyRecordsQuery } = useObjectMetadataItem({ - objectNameSingular: CoreObjectNameSingular.Person, - }); - - const { data } = useQuery(findManyRecordsQuery, { - variables: { - filter: { - companyId: { - eq: company.id, - }, - }, - }, - }); - - const people = mapPaginatedRecordsToRecords({ - objectNamePlural: 'people', - pagedRecords: data ?? [], - }); - - const peopleIds = people.map((person) => person.id); - - const hasPeople = isNonEmptyArray(peopleIds); - - return ( - <> - {hasPeople && ( - - - Team - - - - {people.map((person: any) => ( - - ))} - - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition.ts index 655d35617..7343ef16c 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition.ts @@ -17,7 +17,12 @@ export const formatFieldMetadataItemAsColumnDefinition = ({ objectMetadataItem: ObjectMetadataItem; }): ColumnDefinition => { const relationObjectMetadataItem = - field.toRelationMetadata?.fromObjectMetadata; + field.toRelationMetadata?.fromObjectMetadata || + field.fromRelationMetadata?.toObjectMetadata; + + const relationFieldMetadataId = + field.toRelationMetadata?.fromFieldMetadataId || + field.fromRelationMetadata?.toFieldMetadataId; return { position, @@ -29,6 +34,7 @@ export const formatFieldMetadataItemAsColumnDefinition = ({ fieldName: field.name, placeHolder: field.label, relationType: parseFieldRelationType(field), + relationFieldMetadataId, relationObjectMetadataNameSingular: relationObjectMetadataItem?.nameSingular ?? '', relationObjectMetadataNamePlural: diff --git a/packages/twenty-front/src/modules/object-record/components/RecordShowPage.tsx b/packages/twenty-front/src/modules/object-record/components/RecordShowPage.tsx index 2ac72896e..6f12e35e1 100644 --- a/packages/twenty-front/src/modules/object-record/components/RecordShowPage.tsx +++ b/packages/twenty-front/src/modules/object-record/components/RecordShowPage.tsx @@ -1,7 +1,6 @@ import { useParams } from 'react-router-dom'; import { useRecoilState } from 'recoil'; -import { CompanyTeam } from '@/companies/components/CompanyTeam'; import { useFavorites } from '@/favorites/hooks/useFavorites'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition'; @@ -15,6 +14,7 @@ import { entityFieldsFamilyState } from '@/object-record/field/states/entityFiel 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 { RecordRelationFieldCardSection } from '@/object-record/record-relation-card/components/RecordRelationFieldCardSection'; import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker'; import { isFieldMetadataItemAvailable } from '@/object-record/utils/isFieldMetadataItemAvailable'; import { IconBuildingSkyscraper } from '@/ui/display/icon'; @@ -73,9 +73,7 @@ export const RecordShowPage = () => { }); const [uploadImage] = useUploadImageMutation(); - const { updateOneRecord } = useUpdateOneRecord({ - objectNameSingular, - }); + const { updateOneRecord } = useUpdateOneRecord({ objectNameSingular }); const useUpdateOneObjectRecordMutation: RecordUpdateHook = () => { const updateEntity = ({ variables }: RecordUpdateHookParams) => { @@ -146,16 +144,26 @@ export const RecordShowPage = () => { }); }; - const fieldMetadataItemsToShow = [...objectMetadataItem.fields] - .sort((fieldMetadataItemA, fieldMetadataItemB) => - fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name), - ) - .filter(isFieldMetadataItemAvailable) + const availableFieldMetadataItems = objectMetadataItem.fields .filter( (fieldMetadataItem) => + isFieldMetadataItemAvailable(fieldMetadataItem) && fieldMetadataItem.id !== labelIdentifierFieldMetadata?.id, + ) + .sort((fieldMetadataItemA, fieldMetadataItemB) => + fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name), ); + const inlineFieldMetadataItems = availableFieldMetadataItems.filter( + (fieldMetadataItem) => + fieldMetadataItem.type !== FieldMetadataType.Relation, + ); + + const relationFieldMetadataItems = availableFieldMetadataItems.filter( + (fieldMetadataItem) => + fieldMetadataItem.type === FieldMetadataType.Relation, + ); + return ( @@ -189,7 +197,7 @@ export const RecordShowPage = () => { - {!loading && record ? ( + {!loading && !!record && ( <> { } /> - {fieldMetadataItemsToShow.map( + {inlineFieldMetadataItems.map( (fieldMetadataItem, index) => ( { ), )} - {objectNameSingular === 'company' ? ( - <> - - - ) : ( - <> + {relationFieldMetadataItems.map( + (fieldMetadataItem, index) => ( + + + + ), )} - ) : ( - <> )} { const fieldName = fieldDefinition.metadata.fieldName; const [fieldValue, setFieldValue] = useRecoilState( - entityFieldsFamilySelector({ - entityId: entityId, - fieldName: fieldName, - }), + entityFieldsFamilySelector({ entityId, fieldName }), ); const fieldInitialValue = useFieldInitialValue(); diff --git a/packages/twenty-front/src/modules/object-record/field/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/field/types/FieldMetadata.ts index f752ab825..0ff54de2f 100644 --- a/packages/twenty-front/src/modules/object-record/field/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/field/types/FieldMetadata.ts @@ -75,12 +75,13 @@ export type FieldDefinitionRelationType = | 'TO_ONE_OBJECT'; export type FieldRelationMetadata = { - objectMetadataNameSingular?: string; fieldName: string; - useEditButton?: boolean; - relationType?: FieldDefinitionRelationType; - relationObjectMetadataNameSingular: string; + objectMetadataNameSingular?: string; + relationFieldMetadataId: string; relationObjectMetadataNamePlural: string; + relationObjectMetadataNameSingular: string; + relationType?: FieldDefinitionRelationType; + useEditButton?: boolean; }; export type FieldSelectMetadata = { diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFieldContext.tsx b/packages/twenty-front/src/modules/object-record/hooks/useFieldContext.tsx index 1ec53e0a3..930de356a 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFieldContext.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/useFieldContext.tsx @@ -11,19 +11,21 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope'; export const useFieldContext = ({ - objectNameSingular, - fieldMetadataName, - objectRecordId, - fieldPosition, clearable, + fieldMetadataName, + fieldPosition, + isLabelIdentifier = false, + objectNameSingular, + objectRecordId, }: { - objectNameSingular: string; - objectRecordId: string; + clearable?: boolean; fieldMetadataName: string; fieldPosition: number; - clearable?: boolean; + isLabelIdentifier?: boolean; + objectNameSingular: string; + objectRecordId: string; }) => { - const { objectMetadataItem } = useObjectMetadataItem({ + const { basePathToShowPage, objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); @@ -52,9 +54,12 @@ export const useFieldContext = ({ , >({ objectNameSingular, - objectRecordId, + objectRecordId = '', onCompleted, depth, skip, @@ -18,9 +18,7 @@ export const useFindOneRecord = < depth?: number; }) => { const { objectMetadataItem, findOneRecordQuery } = useObjectMetadataItem( - { - objectNameSingular, - }, + { objectNameSingular }, depth, ); @@ -29,20 +27,12 @@ export const useFindOneRecord = < { objectRecordId: string } >(findOneRecordQuery, { skip: !objectMetadataItem || !objectRecordId || skip, - variables: { - objectRecordId: objectRecordId ?? '', - }, - onCompleted: (data) => { - if (onCompleted) { - onCompleted(data[objectNameSingular]); - } - }, + variables: { objectRecordId }, + onCompleted: (data) => onCompleted?.(data[objectNameSingular]), }); - const record = data ? data[objectNameSingular] : undefined; - return { - record, + record: data?.[objectNameSingular] || undefined, loading, error, }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useUpsertRecordTableItem.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpsertRecordFromState.ts similarity index 88% rename from packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useUpsertRecordTableItem.ts rename to packages/twenty-front/src/modules/object-record/hooks/useUpsertRecordFromState.ts index 3f7e411f5..27ec737ce 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useUpsertRecordTableItem.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpsertRecordFromState.ts @@ -4,8 +4,8 @@ import { entityFieldsFamilyState } from '@/object-record/field/states/entityFiel import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; // TODO: refactor with scoped state later -export const useUpsertRecordTableItem = () => { - return useRecoilCallback( +export const useUpsertRecordFromState = () => + useRecoilCallback( ({ set, snapshot }) => (entity: T) => { const currentEntity = snapshot @@ -18,4 +18,3 @@ export const useUpsertRecordTableItem = () => { }, [], ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardContent.tsx b/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardContent.tsx new file mode 100644 index 000000000..3720a502c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardContent.tsx @@ -0,0 +1,52 @@ +import { useContext } from 'react'; +import styled from '@emotion/styled'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { FieldDisplay } from '@/object-record/field/components/FieldDisplay'; +import { FieldContext } from '@/object-record/field/contexts/FieldContext'; +import { FieldRelationMetadata } from '@/object-record/field/types/FieldMetadata'; +import { useFieldContext } from '@/object-record/hooks/useFieldContext'; +import { CardContent } from '@/ui/layout/card/components/CardContent'; + +const StyledCardContent = styled(CardContent)` + align-items: center; + display: flex; + height: ${({ theme }) => theme.spacing(10)}; + padding: ${({ theme }) => theme.spacing(0, 2, 0, 3)}; +`; + +type RecordRelationFieldCardContentProps = { + divider?: boolean; + relationRecordId: string; +}; + +export const RecordRelationFieldCardContent = ({ + divider, + relationRecordId, +}: RecordRelationFieldCardContentProps) => { + const { fieldDefinition } = useContext(FieldContext); + const { relationObjectMetadataNameSingular } = + fieldDefinition.metadata as FieldRelationMetadata; + const { labelIdentifierFieldMetadata: relationLabelIdentifierFieldMetadata } = + useObjectMetadataItem({ + objectNameSingular: relationObjectMetadataNameSingular, + }); + + const { FieldContextProvider } = useFieldContext({ + fieldMetadataName: relationLabelIdentifierFieldMetadata?.name || '', + fieldPosition: 0, + isLabelIdentifier: true, + objectNameSingular: relationObjectMetadataNameSingular, + objectRecordId: relationRecordId, + }); + + if (!FieldContextProvider) return null; + + return ( + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx b/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx new file mode 100644 index 000000000..481c59ff3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-relation-card/components/RecordRelationFieldCardSection.tsx @@ -0,0 +1,109 @@ +import { useContext, useEffect, useMemo } from 'react'; +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { FieldContext } from '@/object-record/field/contexts/FieldContext'; +import { entityFieldsFamilySelector } from '@/object-record/field/states/selectors/entityFieldsFamilySelector'; +import { FieldRelationMetadata } from '@/object-record/field/types/FieldMetadata'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; +import { useUpsertRecordFromState } from '@/object-record/hooks/useUpsertRecordFromState'; +import { RecordRelationFieldCardContent } from '@/object-record/record-relation-card/components/RecordRelationFieldCardContent'; +import { Card } from '@/ui/layout/card/components/Card'; +import { Section } from '@/ui/layout/section/components/Section'; + +const StyledTitle = styled.div` + font-weight: ${({ theme }) => theme.font.weight.medium}; + margin-bottom: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(0, 1)}; +`; + +export const RecordRelationFieldCardSection = () => { + const { entityId, fieldDefinition } = useContext(FieldContext); + const { + relationFieldMetadataId, + relationObjectMetadataNameSingular, + relationType, + } = fieldDefinition.metadata as FieldRelationMetadata; + + const { + labelIdentifierFieldMetadata: relationLabelIdentifierFieldMetadata, + objectMetadataItem: relationObjectMetadataItem, + } = useObjectMetadataItem({ + objectNameSingular: relationObjectMetadataNameSingular, + }); + + const relationFieldMetadataItem = relationObjectMetadataItem.fields.find( + ({ id }) => id === relationFieldMetadataId, + ); + + const fieldValue = useRecoilValue< + ({ id: string } & Record) | null + >( + entityFieldsFamilySelector({ + entityId, + fieldName: fieldDefinition.metadata.fieldName, + }), + ); + + const isToOneObject = relationType === 'TO_ONE_OBJECT'; + + const { record: recordFromFieldValue } = useFindOneRecord({ + objectNameSingular: relationObjectMetadataNameSingular, + objectRecordId: fieldValue?.id, + skip: !relationLabelIdentifierFieldMetadata || !isToOneObject, + }); + + // ONE_TO_MANY records cannot be retrieved from the field value, + // as the record's field is an empty "Connection" object. + // TODO: maybe the backend could return an array of related records instead? + const { records } = useFindManyRecords({ + objectNameSingular: relationObjectMetadataNameSingular, + limit: 5, + filter: { + // TODO: this won't work for MANY_TO_MANY relations. + [`${relationFieldMetadataItem?.name}Id`]: { + eq: entityId, + }, + }, + skip: + !relationLabelIdentifierFieldMetadata || + !relationFieldMetadataItem?.name || + isToOneObject, + }); + + const relationRecords = useMemo( + () => (recordFromFieldValue ? [recordFromFieldValue] : records), + [recordFromFieldValue, records], + ); + + const upsertRecordFromState = useUpsertRecordFromState(); + + useEffect(() => { + if (!relationRecords.length) return; + + relationRecords.forEach((relationRecord) => + upsertRecordFromState(relationRecord), + ); + }, [relationRecords, upsertRecordFromState]); + + if (!relationLabelIdentifierFieldMetadata) return null; + + return ( +
+ {fieldDefinition.label} + {!!relationRecords.length && ( + + {relationRecords.map((relationRecord, index) => ( + + ))} + + )} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts index e9136c99a..633dff783 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts @@ -12,6 +12,7 @@ import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotV import { FieldMetadata } from '../../field/types/FieldMetadata'; import { onEntityCountChangeScopedState } from '../states/onEntityCountChangeScopedState'; +import { useUpsertRecordFromState } from '../../hooks/useUpsertRecordFromState'; import { ColumnDefinition } from '../types/ColumnDefinition'; import { TableHotkeyScope } from '../types/TableHotkeyScope'; @@ -23,7 +24,6 @@ import { useSelectAllRows } from './internal/useSelectAllRows'; import { useSetRecordTableData } from './internal/useSetRecordTableData'; import { useSetRowSelectedState } from './internal/useSetRowSelectedState'; import { useSetSoftFocusPosition } from './internal/useSetSoftFocusPosition'; -import { useUpsertRecordTableItem } from './internal/useUpsertRecordTableItem'; type useRecordTableProps = { recordTableScopeId?: string; @@ -131,7 +131,7 @@ export const useRecordTable = (props?: useRecordTableProps) => { const resetTableRowSelection = useResetTableRowSelection(scopeId); - const upsertRecordTableItem = useUpsertRecordTableItem(); + const upsertRecordTableItem = useUpsertRecordFromState(); const setSoftFocusPosition = useSetSoftFocusPosition(scopeId); diff --git a/packages/twenty-front/src/modules/object-record/utils/isFieldMetadataItemAvailable.ts b/packages/twenty-front/src/modules/object-record/utils/isFieldMetadataItemAvailable.ts index a6679aa47..dc992cc06 100644 --- a/packages/twenty-front/src/modules/object-record/utils/isFieldMetadataItemAvailable.ts +++ b/packages/twenty-front/src/modules/object-record/utils/isFieldMetadataItemAvailable.ts @@ -1,13 +1,17 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { parseFieldRelationType } from '@/object-metadata/utils/parseFieldRelationType'; +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' && - parseFieldRelationType(fieldMetadataItem) !== 'TO_ONE_OBJECT' + ( + fieldMetadataItem.fromRelationMetadata ?? + fieldMetadataItem.toRelationMetadata + )?.relationType === RelationMetadataType.ManyToMany ) && !fieldMetadataItem.isSystem && !!fieldMetadataItem.isActive; diff --git a/packages/twenty-front/src/modules/people/components/PeopleCard.tsx b/packages/twenty-front/src/modules/people/components/PeopleCard.tsx deleted file mode 100644 index 9373a81cd..000000000 --- a/packages/twenty-front/src/modules/people/components/PeopleCard.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useMutation } from '@apollo/client'; -import { getOperationName } from '@apollo/client/utilities'; -import styled from '@emotion/styled'; -import { autoUpdate, flip, offset, useFloating } from '@floating-ui/react'; - -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { Person } from '@/people/types/Person'; -import { IconDotsVertical, IconLinkOff, IconTrash } from '@/ui/display/icon'; -import { FloatingIconButton } from '@/ui/input/button/components/FloatingIconButton'; -import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; -import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; -import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; -import { Avatar } from '@/users/components/Avatar'; - -export type PeopleCardProps = { - person: Pick; - hasBottomBorder?: boolean; -}; - -const StyledCard = styled.div<{ - isHovered: boolean; - hasBottomBorder?: boolean; -}>` - align-items: center; - align-self: stretch; - background: ${({ theme, isHovered }) => - isHovered ? theme.background.tertiary : 'auto'}; - border-bottom: 1px solid - ${({ theme, hasBottomBorder }) => - hasBottomBorder ? theme.border.color.light : 'transparent'}; - display: flex; - gap: ${({ theme }) => theme.spacing(2)}; - height: ${({ theme }) => theme.spacing(8)}; - padding: ${({ theme }) => theme.spacing(3)}; - &:hover { - background: ${({ theme }) => theme.background.tertiary}; - cursor: pointer; - } -`; - -const StyledCardInfo = styled.div` - align-items: flex-start; - display: flex; - flex: 1 0 0; - flex-direction: column; -`; - -const StyledTitle = styled.div` - color: ${({ theme }) => theme.font.color.primary}; - font-weight: ${({ theme }) => theme.font.weight.medium}; - line-height: ${({ theme }) => theme.text.lineHeight.lg}; -`; - -const StyledJobTitle = styled.div` - border-radius: ${({ theme }) => theme.spacing(1)}; - color: ${({ theme }) => theme.font.color.tertiary}; - padding-bottom: ${({ theme }) => theme.spacing(0.5)}; - padding-left: ${({ theme }) => theme.spacing(0)}; - padding-right: ${({ theme }) => theme.spacing(2)}; - padding-top: ${({ theme }) => theme.spacing(0.5)}; - - &:hover { - background: ${({ theme }) => theme.background.tertiary}; - } -`; - -export const PeopleCard = ({ - person, - hasBottomBorder = true, -}: PeopleCardProps) => { - const navigate = useNavigate(); - const [isHovered, setIsHovered] = useState(false); - const [isOptionsOpen, setIsOptionsOpen] = useState(false); - - const { refs, floatingStyles } = useFloating({ - strategy: 'absolute', - middleware: [offset(10), flip()], - whileElementsMounted: autoUpdate, - placement: 'right-start', - }); - - useListenClickOutside({ - refs: [refs.floating], - callback: () => { - setIsOptionsOpen(false); - if (isOptionsOpen) { - setIsHovered(false); - } - }, - }); - - const handleMouseEnter = () => { - setIsHovered(true); - }; - - const handleMouseLeave = () => { - if (!isOptionsOpen) { - setIsHovered(false); - } - }; - - const handleToggleOptions = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsOptionsOpen(!isOptionsOpen); - }; - - const { - findManyRecordsQuery, - updateOneRecordMutation, - deleteOneRecordMutation, - } = useObjectMetadataItem({ - objectNameSingular: CoreObjectNameSingular.Person, - }); - - const [updatePerson] = useMutation(updateOneRecordMutation); - const [deletePerson] = useMutation(deleteOneRecordMutation); - - const handleDetachPerson = async () => { - await updatePerson({ - variables: { - idToUpdate: person.id, - input: { - companyId: null, - }, - }, - refetchQueries: [getOperationName(findManyRecordsQuery) ?? ''], - }); - }; - - const handleDeletePerson = () => { - deletePerson({ - variables: { - idToDelete: person.id, - }, - refetchQueries: [getOperationName(findManyRecordsQuery) ?? ''], - }); - }; - - return ( - navigate(`/object/person/${person.id}`)} - hasBottomBorder={hasBottomBorder} - > - - - - {person.name.firstName + ' ' + person.name.lastName} - - {person.jobTitle && {person.jobTitle}} - - {isHovered && ( -
- - {isOptionsOpen && ( - - - - - - - )} -
- )} -
- ); -}; diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageLeftContainer.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageLeftContainer.tsx index e3ef38f72..bf58a8786 100644 --- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageLeftContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageLeftContainer.tsx @@ -1,4 +1,4 @@ -import { ReactElement } from 'react'; +import { ReactNode } from 'react'; import styled from '@emotion/styled'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; @@ -7,28 +7,23 @@ import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; const StyledOuterContainer = styled.div` background: ${({ theme }) => theme.background.secondary}; border-bottom-left-radius: 8px; - border-right: ${({ theme }) => { - const isMobile = useIsMobile(); - return !isMobile ? `1px solid ${theme.border.color.medium}` : 'none'; - }}; + border-right: ${({ theme }) => + useIsMobile() ? 'none' : `1px solid ${theme.border.color.medium}`}; border-top-left-radius: 8px; display: flex; flex-direction: column; gap: ${({ theme }) => theme.spacing(3)}; - z-index: 10; `; const StyledInnerContainer = styled.div` display: flex; flex-direction: column; - padding: 0px ${({ theme }) => theme.spacing(2)} 0px - ${({ theme }) => theme.spacing(3)}; - width: ${({ theme }) => { - const isMobile = useIsMobile(); - - return isMobile ? `calc(100% - ${theme.spacing(5)})` : '320px'; - }}; + gap: ${({ theme }) => theme.spacing(6)}; + padding: ${({ theme }) => theme.spacing(3)}; + padding-right: ${({ theme }) => theme.spacing(2)}; + width: ${({ theme }) => + useIsMobile() ? `calc(100% - ${theme.spacing(5)})` : '320px'}; `; const StyledIntermediateContainer = styled.div` @@ -38,7 +33,7 @@ const StyledIntermediateContainer = styled.div` `; export type ShowPageLeftContainerProps = { - children: ReactElement; + children: ReactNode; }; export const ShowPageLeftContainer = ({ diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx index 1d7231c35..0e1a8dbdb 100644 --- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx @@ -25,8 +25,7 @@ const StyledShowPageSummaryCard = styled.div` flex-direction: column; gap: ${({ theme }) => theme.spacing(3)}; justify-content: center; - padding: ${({ theme }) => theme.spacing(6)} ${({ theme }) => theme.spacing(3)} - ${({ theme }) => theme.spacing(3)} ${({ theme }) => theme.spacing(3)}; + padding: ${({ theme }) => theme.spacing(3)}; `; const StyledInfoContainer = styled.div` diff --git a/packages/twenty-server/src/workspace/workspace-schema-builder/factories/extend-object-type-definition.factory.ts b/packages/twenty-server/src/workspace/workspace-schema-builder/factories/extend-object-type-definition.factory.ts index 60b984739..0c1891ef2 100644 --- a/packages/twenty-server/src/workspace/workspace-schema-builder/factories/extend-object-type-definition.factory.ts +++ b/packages/twenty-server/src/workspace/workspace-schema-builder/factories/extend-object-type-definition.factory.ts @@ -9,7 +9,6 @@ import { import { WorkspaceBuildSchemaOptions } from 'src/workspace/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface'; import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/object-metadata.interface'; -import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; import { TypeDefinitionsStorage } from 'src/workspace/workspace-schema-builder/storages/type-definitions.storage'; import { objectContainsRelationField } from 'src/workspace/workspace-schema-builder/utils/object-contains-relation-field'; import { getResolverArgs } from 'src/workspace/workspace-schema-builder/utils/get-resolver-args.util'; @@ -114,61 +113,52 @@ export class ExtendObjectTypeDefinitionFactory { continue; } - switch (fieldMetadata.type) { - case FieldMetadataType.RELATION: { - const relationMetadata = - fieldMetadata.fromRelationMetadata ?? - fieldMetadata.toRelationMetadata; + const relationMetadata = + fieldMetadata.fromRelationMetadata ?? fieldMetadata.toRelationMetadata; - if (!relationMetadata) { - this.logger.error( - `Could not find a relation metadata for ${fieldMetadata.id}`, - { - fieldMetadata, - }, - ); + if (!relationMetadata) { + this.logger.error( + `Could not find a relation metadata for ${fieldMetadata.id}`, + { fieldMetadata }, + ); - throw new Error( - `Could not find a relation metadata for ${fieldMetadata.id}`, - ); - } - - const relationDirection = deduceRelationDirection( - fieldMetadata.objectMetadataId, - relationMetadata, - ); - const relationType = this.relationTypeFactory.create( - fieldMetadata, - relationMetadata, - relationDirection, - ); - let argsType: GraphQLFieldConfigArgumentMap | undefined = undefined; - - // Args are only needed when relation is of kind `oneToMany` and the relation direction is `from` - if ( - relationMetadata.relationType === - RelationMetadataType.ONE_TO_MANY && - relationDirection === RelationDirection.FROM - ) { - const args = getResolverArgs('findMany'); - - argsType = this.argsFactory.create( - { - args, - objectMetadataId: relationMetadata.toObjectMetadataId, - }, - options, - ); - } - - fields[fieldMetadata.name] = { - type: relationType, - args: argsType, - description: fieldMetadata.description, - }; - break; - } + throw new Error( + `Could not find a relation metadata for ${fieldMetadata.id}`, + ); } + + const relationDirection = deduceRelationDirection( + fieldMetadata.objectMetadataId, + relationMetadata, + ); + const relationType = this.relationTypeFactory.create( + fieldMetadata, + relationMetadata, + relationDirection, + ); + let argsType: GraphQLFieldConfigArgumentMap | undefined = undefined; + + // Args are only needed when relation is of kind `oneToMany` and the relation direction is `from` + if ( + relationMetadata.relationType === RelationMetadataType.ONE_TO_MANY && + relationDirection === RelationDirection.FROM + ) { + const args = getResolverArgs('findMany'); + + argsType = this.argsFactory.create( + { + args, + objectMetadataId: relationMetadata.toObjectMetadataId, + }, + options, + ); + } + + fields[fieldMetadata.name] = { + type: relationType, + args: argsType, + description: fieldMetadata.description, + }; } return fields;