diff --git a/packages/twenty-front/src/hooks/useScrollToPosition.ts b/packages/twenty-front/src/hooks/useScrollToPosition.ts new file mode 100644 index 000000000000..34994c1f3b5a --- /dev/null +++ b/packages/twenty-front/src/hooks/useScrollToPosition.ts @@ -0,0 +1,20 @@ +import { overlayScrollbarsState } from '@/ui/utilities/scroll/states/overlayScrollbarsState'; +import { useRecoilCallback } from 'recoil'; + +export const useScrollToPosition = () => { + const scrollToPosition = useRecoilCallback( + ({ snapshot }) => + (scrollPositionInPx: number) => { + const overlayScrollbars = snapshot + .getLoadable(overlayScrollbarsState) + .getValue(); + + const scrollWrapper = overlayScrollbars?.elements().viewport; + + scrollWrapper?.scrollTo({ top: scrollPositionInPx }); + }, + [], + ); + + return { scrollToPosition }; +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx index 2a658225bb0f..e5ad988d3153 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx @@ -1,7 +1,7 @@ -import { useContext } from 'react'; import { css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { format } from 'date-fns'; +import { useContext } from 'react'; import { useRecoilValue } from 'recoil'; import { Avatar, AvatarGroup, IconArrowRight, IconLock } from 'twenty-ui'; @@ -14,8 +14,8 @@ import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEv import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { Card } from '@/ui/layout/card/components/Card'; import { CardContent } from '@/ui/layout/card/components/CardContent'; -import { CalendarChannelVisibility } from '~/generated/graphql'; import { TimelineCalendarEvent } from '~/generated-metadata/graphql'; +import { CalendarChannelVisibility } from '~/generated/graphql'; import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64'; import { isDefined } from '~/utils/isDefined'; @@ -169,7 +169,9 @@ export const CalendarEventRow = ({ ? `${participant.firstName} ${participant.lastName}` : participant.displayName } - entityId={participant.workspaceMemberId ?? participant.personId} + placeholderColorSeed={ + participant.workspaceMemberId ?? participant.personId + } type="rounded" /> ))} diff --git a/packages/twenty-front/src/modules/activities/comment/CommentHeader.tsx b/packages/twenty-front/src/modules/activities/comment/CommentHeader.tsx index 9f87a81ebd39..d48f114e9d7f 100644 --- a/packages/twenty-front/src/modules/activities/comment/CommentHeader.tsx +++ b/packages/twenty-front/src/modules/activities/comment/CommentHeader.tsx @@ -62,7 +62,7 @@ export const CommentHeader = ({ comment, actionBar }: CommentHeaderProps) => { {authorName} diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx index 8b23973c5bb3..cb941c4e1861 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx @@ -1,6 +1,6 @@ -import { useMemo, useRef } from 'react'; import styled from '@emotion/styled'; import { isNonEmptyString } from '@sniptt/guards'; +import { useMemo, useRef } from 'react'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { Key } from 'ts-key-enum'; import { Avatar, IconNotes, IconSparkles } from 'twenty-ui'; @@ -377,7 +377,7 @@ export const CommandMenu = () => { { to={`object/company/${company.id}`} Icon={() => ( { label={labelIdentifier} Icon={() => ( 0; - const chipGeneratorPerObjectPerField = useMemo(() => { - return getRecordChipGeneratorPerObjectPerField(objectMetadataItems); - }, [objectMetadataItems]); - return ( <> {shouldDisplayChildren ? ( - + {children} - + ) : ( )} diff --git a/packages/twenty-front/src/modules/object-metadata/components/PreComputedChipGeneratorsProvider.tsx b/packages/twenty-front/src/modules/object-metadata/components/PreComputedChipGeneratorsProvider.tsx new file mode 100644 index 000000000000..dcf9bc5cc73d --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/components/PreComputedChipGeneratorsProvider.tsx @@ -0,0 +1,30 @@ +import React, { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { getRecordChipGenerators } from '@/object-record/utils/getRecordChipGenerators'; + +export const PreComputedChipGeneratorsProvider = ({ + children, +}: React.PropsWithChildren) => { + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const { chipGeneratorPerObjectPerField, identifierChipGeneratorPerObject } = + useMemo(() => { + return getRecordChipGenerators(objectMetadataItems); + }, [objectMetadataItems]); + + return ( + <> + + {children} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-metadata/context/PreComputedChipGeneratorsContext.ts b/packages/twenty-front/src/modules/object-metadata/context/PreComputedChipGeneratorsContext.ts index fcb5b7d46b68..ed7b734bcc98 100644 --- a/packages/twenty-front/src/modules/object-metadata/context/PreComputedChipGeneratorsContext.ts +++ b/packages/twenty-front/src/modules/object-metadata/context/PreComputedChipGeneratorsContext.ts @@ -3,13 +3,19 @@ import { createContext } from 'react'; import { RecordChipData } from '@/object-record/record-field/types/RecordChipData'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -export type ChipGeneratorPerObjectPerField = Record< +export type ChipGeneratorPerObjectNameSingularPerFieldName = Record< string, Record RecordChipData> >; +export type IdentifierChipGeneratorPerObject = Record< + string, + (record: ObjectRecord) => RecordChipData +>; + export type PreComputedChipGeneratorsContextProps = { - chipGeneratorPerObjectPerField: ChipGeneratorPerObjectPerField; + chipGeneratorPerObjectPerField: ChipGeneratorPerObjectNameSingularPerFieldName; + identifierChipGeneratorPerObject: IdentifierChipGeneratorPerObject; }; export const PreComputedChipGeneratorsContext = diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldValue.ts b/packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldValue.ts index 7960047804ba..001cf4ecb06b 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldValue.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldValue.ts @@ -8,7 +8,7 @@ export const getLabelIdentifierFieldValue = ( record: ObjectRecord, labelIdentifierFieldMetadataItem: FieldMetadataItem | undefined, objectNameSingular: string, -) => { +): string => { if ( objectNameSingular === CoreObjectNameSingular.WorkspaceMember || labelIdentifierFieldMetadataItem?.type === FieldMetadataType.FullName @@ -17,7 +17,7 @@ export const getLabelIdentifierFieldValue = ( } if (isDefined(labelIdentifierFieldMetadataItem?.name)) { - return record[labelIdentifierFieldMetadataItem.name] as string | number; + return String(record[labelIdentifierFieldMetadataItem.name]); } return ''; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getLinkToShowPage.ts b/packages/twenty-front/src/modules/object-metadata/utils/getLinkToShowPage.ts index c5b747a1ebbb..4a6510dea2c1 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getLinkToShowPage.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getLinkToShowPage.ts @@ -4,7 +4,7 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord'; export const getLinkToShowPage = ( objectNameSingular: string, - record: ObjectRecord, + record: Pick, ) => { const basePathToShowPage = getBasePathToShowPage({ objectNameSingular, diff --git a/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx b/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx index ea27165a15dd..94674aa57b21 100644 --- a/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx +++ b/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx @@ -1,13 +1,17 @@ -import { EntityChip, EntityChipVariant } from 'twenty-ui'; +import { AvatarChip, AvatarChipVariant } from 'twenty-ui'; -import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapToObjectRecordIdentifier'; +import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage'; +import { useRecordChipData } from '@/object-record/hooks/useRecordChipData'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isNonEmptyString } from '@sniptt/guards'; +import { MouseEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; export type RecordChipProps = { objectNameSingular: string; record: ObjectRecord; className?: string; - variant?: EntityChipVariant; + variant?: AvatarChipVariant; }; export const RecordChip = ({ @@ -16,19 +20,29 @@ export const RecordChip = ({ className, variant, }: RecordChipProps) => { - const { mapToObjectRecordIdentifier } = useMapToObjectRecordIdentifier({ + const navigate = useNavigate(); + + const { recordChipData } = useRecordChipData({ objectNameSingular, + record, }); - const objectRecordIdentifier = mapToObjectRecordIdentifier(record); + const handleAvatarChipClick = (event: MouseEvent) => { + const linkToShowPage = getLinkToShowPage(objectNameSingular, record); + + if (isNonEmptyString(linkToShowPage)) { + event.stopPropagation(); + navigate(linkToShowPage); + } + }; return ( - diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationVariables.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationVariables.ts index 9dc0fed4f010..3abc3358a654 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationVariables.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationVariables.ts @@ -1,8 +1,14 @@ import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter'; import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy'; +import { QueryCursorDirection } from '@/object-record/utils/generateFindManyRecordsQuery'; export type RecordGqlOperationVariables = { filter?: RecordGqlOperationFilter; orderBy?: RecordGqlOperationOrderBy; limit?: number; + cursorFilter?: { + cursor: string; + cursorDirection: QueryCursorDirection; + limit: number; + }; }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFetchAllRecordIds.ts b/packages/twenty-front/src/modules/object-record/hooks/useFetchAllRecordIds.ts index 937548814f12..715cdc5af15d 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFetchAllRecordIds.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFetchAllRecordIds.ts @@ -38,7 +38,7 @@ export const useFetchAllRecordIds = ({ const firstQueryResult = findManyRecordsDataResult?.data?.[objectMetadataItem.namePlural]; - const totalCount = firstQueryResult?.totalCount ?? 1; + const totalCount = firstQueryResult?.totalCount ?? 0; const recordsCount = firstQueryResult?.edges.length ?? 0; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts index 00629c91a6b6..da5dc238af89 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts @@ -34,6 +34,7 @@ export const useFindManyRecords = ({ fetchPolicy, onError, onCompleted, + cursorFilter, }: UseFindManyRecordsParams) => { const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const { objectMetadataItem } = useObjectMetadataItem({ @@ -42,6 +43,7 @@ export const useFindManyRecords = ({ const { findManyRecordsQuery } = useFindManyRecordsQuery({ objectNameSingular, recordGqlFields, + cursorDirection: cursorFilter?.cursorDirection, }); const { handleFindManyRecordsError } = useHandleFindManyRecordsError({ @@ -67,15 +69,16 @@ export const useFindManyRecords = ({ skip: skip || !objectMetadataItem || !currentWorkspaceMember, variables: { filter, - limit, orderBy, + lastCursor: cursorFilter?.cursor ?? undefined, + limit: cursorFilter?.limit ?? limit, }, fetchPolicy: fetchPolicy, onCompleted: handleFindManyRecordsCompleted, onError: handleFindManyRecordsError, }); - const { fetchMoreRecords, totalCount, records, hasNextPage } = + const { fetchMoreRecords, records, hasNextPage } = useFetchMoreRecordsWithPagination({ objectNameSingular, filter, @@ -87,6 +90,9 @@ export const useFindManyRecords = ({ objectMetadataItem, }); + const pageInfo = data?.[objectMetadataItem.namePlural].pageInfo; + const totalCount = data?.[objectMetadataItem.namePlural].totalCount; + return { objectMetadataItem, records, @@ -96,5 +102,6 @@ export const useFindManyRecords = ({ fetchMoreRecords, queryStateIdentifier: queryIdentifier, hasNextPage, + pageInfo, }; }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecordsQuery.ts index 55a6f270dcef..0ede4f9f009c 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecordsQuery.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecordsQuery.ts @@ -3,16 +3,21 @@ import { useRecoilValue } from 'recoil'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; -import { generateFindManyRecordsQuery } from '@/object-record/utils/generateFindManyRecordsQuery'; +import { + generateFindManyRecordsQuery, + QueryCursorDirection, +} from '@/object-record/utils/generateFindManyRecordsQuery'; export const useFindManyRecordsQuery = ({ objectNameSingular, recordGqlFields, computeReferences, + cursorDirection = 'after', }: { objectNameSingular: string; recordGqlFields?: RecordGqlOperationGqlRecordFields; computeReferences?: boolean; + cursorDirection?: QueryCursorDirection; }) => { const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, @@ -25,6 +30,7 @@ export const useFindManyRecordsQuery = ({ objectMetadataItems, recordGqlFields, computeReferences, + cursorDirection, }); return { diff --git a/packages/twenty-front/src/modules/object-record/hooks/useRecordChipData.ts b/packages/twenty-front/src/modules/object-record/hooks/useRecordChipData.ts new file mode 100644 index 000000000000..1958a09eb535 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useRecordChipData.ts @@ -0,0 +1,24 @@ +import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext'; +import { generateDefaultRecordChipData } from '@/object-metadata/utils/generateDefaultRecordChipData'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { useContext } from 'react'; + +export const useRecordChipData = ({ + objectNameSingular, + record, +}: { + objectNameSingular: string; + record: ObjectRecord; +}) => { + const { identifierChipGeneratorPerObject } = useContext( + PreComputedChipGeneratorsContext, + ); + + const generateRecordChipData = + identifierChipGeneratorPerObject[objectNameSingular] ?? + generateDefaultRecordChipData; + + const recordChipData = generateRecordChipData(record); + + return { recordChipData }; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/GenericEntityFilterChip.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/GenericEntityFilterChip.tsx index e11ae14c966d..fae9454c187a 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/GenericEntityFilterChip.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/GenericEntityFilterChip.tsx @@ -1,4 +1,4 @@ -import { EntityChip, IconComponent } from 'twenty-ui'; +import { AvatarChip, IconComponent } from 'twenty-ui'; import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64'; @@ -13,8 +13,8 @@ export const GenericEntityFilterChip = ({ filter, Icon, }: GenericEntityFilterChipProps) => ( - { }} > - {isCompactModeActive && ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx index c852eafa4831..f3448846d214 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx @@ -4,14 +4,13 @@ import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/dis import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay'; import { RatingFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RatingFieldDisplay'; import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay'; +import { isFieldChipDisplay } from '@/object-record/record-field/meta-types/display/utils/isFieldChipDisplay'; import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean'; import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating'; import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects'; import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject'; -import { isFieldChipDisplay } from '@/object-record/utils/getRecordChipGeneratorPerObjectPerField'; - import { FieldContext } from '../contexts/FieldContext'; import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay'; import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ChipFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ChipFieldDisplay.tsx index 12d9b3ec786f..4a8682f3859e 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ChipFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ChipFieldDisplay.tsx @@ -1,23 +1,21 @@ -import { EntityChip } from 'twenty-ui'; - +import { RecordChip } from '@/object-record/components/RecordChip'; import { useChipFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useChipFieldDisplay'; +import { RecordIndexRecordChip } from '@/object-record/record-index/components/RecordIndexRecordChip'; export const ChipFieldDisplay = () => { - const { recordValue, generateRecordChipData } = useChipFieldDisplay(); + const { recordValue, objectNameSingular, isLabelIdentifier } = + useChipFieldDisplay(); if (!recordValue) { return null; } - const recordChipData = generateRecordChipData(recordValue); - - return ( - + ) : ( + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx index 44b05125dbac..adfaea988958 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx @@ -1,37 +1,27 @@ -import { EntityChip } from 'twenty-ui'; - +import { RecordChip } from '@/object-record/components/RecordChip'; import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus'; import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay'; import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; -import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64'; export const RelationFromManyFieldDisplay = () => { - const { fieldValue, fieldDefinition, generateRecordChipData } = - useRelationFromManyFieldDisplay(); + const { fieldValue, fieldDefinition } = useRelationFromManyFieldDisplay(); const { isFocused } = useFieldFocus(); - if ( - !fieldValue || - !fieldDefinition?.metadata.relationObjectMetadataNameSingular - ) { + const relationObjectNameSingular = + fieldDefinition?.metadata.relationObjectMetadataNameSingular; + + if (!fieldValue || !relationObjectNameSingular) { return null; } - const recordChipsData = fieldValue.map((fieldValueItem) => - generateRecordChipData(fieldValueItem), - ); - return ( - {recordChipsData.map((record) => { + {fieldValue.map((record) => { return ( - ); })} diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationToOneFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationToOneFieldDisplay.tsx index b0af7bdee97c..dede0f879534 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationToOneFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationToOneFieldDisplay.tsx @@ -1,7 +1,5 @@ -import { EntityChip } from 'twenty-ui'; - +import { RecordChip } from '@/object-record/components/RecordChip'; import { useRelationToOneFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay'; -import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64'; export const RelationToOneFieldDisplay = () => { const { fieldValue, fieldDefinition, generateRecordChipData } = @@ -17,12 +15,10 @@ export const RelationToOneFieldDisplay = () => { const recordChipData = generateRecordChipData(fieldValue); return ( - ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/utils/isFieldChipDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/utils/isFieldChipDisplay.ts new file mode 100644 index 000000000000..cb45decfe7c3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/utils/isFieldChipDisplay.ts @@ -0,0 +1,11 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; +import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; +import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText'; + +export const isFieldChipDisplay = ( + field: Pick, + isLabelIdentifier: boolean, +) => + isLabelIdentifier && + (isFieldText(field) || isFieldFullName(field) || isFieldNumber(field)); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useChipFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useChipFieldDisplay.ts index 29cf1d3dc651..c0840bf7c06a 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useChipFieldDisplay.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useChipFieldDisplay.ts @@ -1,8 +1,7 @@ -import { useContext } from 'react'; import { isNonEmptyString } from '@sniptt/guards'; +import { useContext } from 'react'; import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext'; -import { generateDefaultRecordChipData } from '@/object-metadata/utils/generateDefaultRecordChipData'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText'; @@ -12,7 +11,8 @@ import { isDefined } from '~/utils/isDefined'; import { FieldContext } from '../../contexts/FieldContext'; export const useChipFieldDisplay = () => { - const { entityId, fieldDefinition } = useContext(FieldContext); + const { entityId, fieldDefinition, isLabelIdentifier } = + useContext(FieldContext); const { chipGeneratorPerObjectPerField } = useContext( PreComputedChipGeneratorsContext, @@ -31,18 +31,13 @@ export const useChipFieldDisplay = () => { const recordValue = useRecordValue(entityId); - if (!isNonEmptyString(fieldDefinition.metadata.objectMetadataNameSingular)) { + if (!isNonEmptyString(objectNameSingular)) { throw new Error('Object metadata name singular is not a non-empty string'); } - const generateRecordChipData = - chipGeneratorPerObjectPerField[ - fieldDefinition.metadata.objectMetadataNameSingular - ]?.[fieldDefinition.metadata.fieldName] ?? generateDefaultRecordChipData; - return { objectNameSingular, recordValue, - generateRecordChipData, + isLabelIdentifier, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/states/lastShowPageRecordId.ts b/packages/twenty-front/src/modules/object-record/record-field/states/lastShowPageRecordId.ts new file mode 100644 index 000000000000..dce923b37bda --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/states/lastShowPageRecordId.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const lastShowPageRecordIdState = createState({ + key: 'lastShowPageRecordIdState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/states/recordPositionInternalState.ts b/packages/twenty-front/src/modules/object-record/record-field/states/recordPositionInternalState.ts new file mode 100644 index 000000000000..9b83e0dcd8d7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/states/recordPositionInternalState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const recordPositionInternalState = createState({ + key: 'recordPositionInternalState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/RecordChipData.ts b/packages/twenty-front/src/modules/object-record/record-field/types/RecordChipData.ts index 13eb99f84cd0..d6ef0c2f6948 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/RecordChipData.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/RecordChipData.ts @@ -2,8 +2,9 @@ import { AvatarType } from 'twenty-ui'; export type RecordChipData = { recordId: string; - name: string | number; + name: string; avatarType: AvatarType; avatarUrl: string; - linkToShowPage: string; + isLabelIdentifier: boolean; + objectNameSingular: string; }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index 28b6aa9a6a9d..c7098481c06d 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -1,15 +1,24 @@ import styled from '@emotion/styled'; -import { useRecoilCallback, useRecoilState, useSetRecoilState } from 'recoil'; +import { + useRecoilCallback, + useRecoilState, + useRecoilValue, + useSetRecoilState, +} from 'recoil'; import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; +import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; +import { lastShowPageRecordIdState } from '@/object-record/record-field/states/lastShowPageRecordId'; +import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter'; import { RecordIndexBoardContainer } from '@/object-record/record-index/components/RecordIndexBoardContainer'; import { RecordIndexBoardDataLoader } from '@/object-record/record-index/components/RecordIndexBoardDataLoader'; import { RecordIndexBoardDataLoaderEffect } from '@/object-record/record-index/components/RecordIndexBoardDataLoaderEffect'; import { RecordIndexTableContainer } from '@/object-record/record-index/components/RecordIndexTableContainer'; import { RecordIndexTableContainerEffect } from '@/object-record/record-index/components/RecordIndexTableContainerEffect'; import { RecordIndexViewBarEffect } from '@/object-record/record-index/components/RecordIndexViewBarEffect'; +import { RecordIndexEventContext } from '@/object-record/record-index/contexts/RecordIndexEventContext'; import { RecordIndexOptionsDropdown } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdown'; import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; @@ -17,15 +26,22 @@ import { recordIndexIsCompactModeActiveState } from '@/object-record/record-inde import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState'; +import { useFindRecordCursorFromFindManyCacheRootQuery } from '@/object-record/record-show/hooks/useFindRecordCursorFromFindManyCacheRootQuery'; +import { findView } from '@/object-record/record-show/hooks/useRecordShowPagePagination'; import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; +import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; +import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider'; import { ViewBar } from '@/views/components/ViewBar'; +import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState'; +import { View } from '@/views/types/View'; import { ViewField } from '@/views/types/ViewField'; import { ViewType } from '@/views/types/ViewType'; import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions'; import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts'; +import { useNavigate } from 'react-router-dom'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; const StyledContainer = styled.div` @@ -108,6 +124,63 @@ export const RecordIndexContainer = ({ [columnDefinitions, setTableColumns], ); + const navigate = useNavigate(); + + const { records: views } = usePrefetchedData(PrefetchKey.AllViews); + + const currentViewId = useRecoilValue( + currentViewIdComponentState({ + scopeId: recordIndexId, + }), + ); + + const view = findView({ + objectMetadataItemId: objectMetadataItem?.id ?? '', + viewId: currentViewId ?? null, + views, + }); + + const filter = turnObjectDropdownFilterIntoQueryFilter( + mapViewFiltersToFilters(view?.viewFilters ?? [], filterDefinitions), + objectMetadataItem?.fields ?? [], + ); + + const orderBy = turnSortsIntoOrderBy( + objectMetadataItem, + mapViewSortsToSorts(view?.viewSorts ?? [], sortDefinitions), + ); + + const { findCursorInCache } = useFindRecordCursorFromFindManyCacheRootQuery({ + fieldVariables: { + filter, + orderBy, + }, + objectNamePlural: objectNamePlural, + }); + + const handleIndexIdentifierClick = (recordId: string) => { + const cursor = findCursorInCache(recordId); + + // TODO: use URL builder + navigate( + `/object/${objectNameSingular}/${recordId}?view=${currentViewId}`, + { + state: { + cursor, + }, + }, + ); + }; + + const handleIndexRecordsLoaded = useRecoilCallback( + ({ set }) => + () => { + // TODO: find a better way to reset this state ? + set(lastShowPageRecordIdState, null); + }, + [], + ); + return ( @@ -153,41 +226,46 @@ export const RecordIndexContainer = ({ /> - - {recordIndexViewType === ViewType.Table && ( - <> - - - - )} - - {recordIndexViewType === ViewType.Kanban && ( - - - - - - )} + + {recordIndexViewType === ViewType.Table && ( + <> + + + + )} + {recordIndexViewType === ViewType.Kanban && ( + + + + + + )} + ); diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexRecordChip.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexRecordChip.tsx new file mode 100644 index 000000000000..1a064570ca56 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexRecordChip.tsx @@ -0,0 +1,40 @@ +import { AvatarChip, AvatarChipVariant } from 'twenty-ui'; + +import { useRecordChipData } from '@/object-record/hooks/useRecordChipData'; +import { RecordIndexEventContext } from '@/object-record/record-index/contexts/RecordIndexEventContext'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { useContext } from 'react'; + +export type RecordIndexRecordChipProps = { + objectNameSingular: string; + record: ObjectRecord; + variant?: AvatarChipVariant; +}; + +export const RecordIndexRecordChip = ({ + objectNameSingular, + record, + variant, +}: RecordIndexRecordChipProps) => { + const { onIndexIdentifierClick } = useContext(RecordIndexEventContext); + + const { recordChipData } = useRecordChipData({ + objectNameSingular, + record, + }); + + const handleAvatarChipClick = () => { + onIndexIdentifierClick(record.id); + }; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/contexts/RecordIndexEventContext.tsx b/packages/twenty-front/src/modules/object-record/record-index/contexts/RecordIndexEventContext.tsx new file mode 100644 index 000000000000..7de19221dc79 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/contexts/RecordIndexEventContext.tsx @@ -0,0 +1,9 @@ +import { createEventContext } from '~/utils/createEventContext'; + +export type RecordIndexEventContextProps = { + onIndexIdentifierClick: (recordId: string) => void; + onIndexRecordsLoaded: () => void; +}; + +export const RecordIndexEventContext = + createEventContext(); diff --git a/packages/twenty-front/src/modules/object-record/record-show/hooks/useFindRecordCursorFromFindManyCacheRootQuery.ts b/packages/twenty-front/src/modules/object-record/record-show/hooks/useFindRecordCursorFromFindManyCacheRootQuery.ts new file mode 100644 index 000000000000..878309217a11 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/hooks/useFindRecordCursorFromFindManyCacheRootQuery.ts @@ -0,0 +1,34 @@ +import { RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge'; +import { useApolloClient } from '@apollo/client'; +import { createApolloStoreFieldName } from '~/utils/createApolloStoreFieldName'; + +export const useFindRecordCursorFromFindManyCacheRootQuery = ({ + objectNamePlural, + fieldVariables, +}: { + objectNamePlural: string; + fieldVariables: { + filter: any; + orderBy: any; + }; +}) => { + const apollo = useApolloClient(); + + const testsFieldNameOnRootQuery = createApolloStoreFieldName({ + fieldName: objectNamePlural, + fieldVariables: fieldVariables, + }); + + const findCursorInCache = (recordId: string) => { + const extractedCache = apollo.cache.extract() as any; + + const edgesInCache = + extractedCache?.['ROOT_QUERY']?.[testsFieldNameOnRootQuery]?.edges ?? []; + + return edgesInCache.find( + (edge: RecordGqlRefEdge) => edge.node?.__ref.split(':')[1] === recordId, + )?.cursor; + }; + + return { findCursorInCache }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordIdsFromFindManyCacheRootQuery.ts b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordIdsFromFindManyCacheRootQuery.ts new file mode 100644 index 000000000000..522c8112f219 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordIdsFromFindManyCacheRootQuery.ts @@ -0,0 +1,30 @@ +import { RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge'; +import { useApolloClient } from '@apollo/client'; +import { createApolloStoreFieldName } from '~/utils/createApolloStoreFieldName'; + +export const useRecordIdsFromFindManyCacheRootQuery = ({ + objectNamePlural, + fieldVariables, +}: { + objectNamePlural: string; + fieldVariables: { + filter: any; + orderBy: any; + }; +}) => { + const apollo = useApolloClient(); + + const testsFieldNameOnRootQuery = createApolloStoreFieldName({ + fieldName: objectNamePlural, + fieldVariables: fieldVariables, + }); + + const extractedCache = apollo.cache.extract() as any; + + const recordIdsInCache: string[] = + extractedCache?.['ROOT_QUERY']?.[testsFieldNameOnRootQuery]?.edges?.map( + (edge: RecordGqlRefEdge) => edge.node?.__ref.split(':')[1], + ) ?? []; + + return { recordIdsInCache }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowPagePagination.ts b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowPagePagination.ts new file mode 100644 index 000000000000..15092daba476 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowPagePagination.ts @@ -0,0 +1,253 @@ +/* eslint-disable @nx/workspace-no-navigate-prefer-link */ +import { useMemo, useState } from 'react'; +import { + useLocation, + useNavigate, + useParams, + useSearchParams, +} from 'react-router-dom'; +import { useSetRecoilState } from 'recoil'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { formatFieldMetadataItemsAsFilterDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions'; +import { formatFieldMetadataItemsAsSortDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions'; +import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; +import { lastShowPageRecordIdState } from '@/object-record/record-field/states/lastShowPageRecordId'; +import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter'; +import { useRecordIdsFromFindManyCacheRootQuery } from '@/object-record/record-show/hooks/useRecordIdsFromFindManyCacheRootQuery'; +import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; +import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; +import { View } from '@/views/types/View'; +import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; +import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts'; +import { isNonEmptyString } from '@sniptt/guards'; +import { capitalize } from '~/utils/string/capitalize'; + +export const findView = ({ + viewId, + objectMetadataItemId, + views, +}: { + viewId: string | null; + objectMetadataItemId: string; + views: View[]; +}) => { + if (!viewId) { + return views.find( + (view: any) => + view.key === 'INDEX' && view?.objectMetadataId === objectMetadataItemId, + ) as View; + } else { + return views.find( + (view: any) => + view?.id === viewId && view?.objectMetadataId === objectMetadataItemId, + ) as View; + } +}; + +export const useRecordShowPagePagination = ( + propsObjectNameSingular: string, + propsObjectRecordId: string, +) => { + const { + objectNameSingular: paramObjectNameSingular, + objectRecordId: paramObjectRecordId, + } = useParams(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const viewIdQueryParam = searchParams.get('view'); + + const setLastShowPageRecordId = useSetRecoilState(lastShowPageRecordIdState); + + const [isLoadedRecords, setIsLoadedRecords] = useState(false); + + const objectNameSingular = propsObjectNameSingular || paramObjectNameSingular; + const objectRecordId = propsObjectRecordId || paramObjectRecordId; + + if (!objectNameSingular || !objectRecordId) { + throw new Error('Object name or Record id is not defined'); + } + + const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular }); + const { records: views } = usePrefetchedData(PrefetchKey.AllViews); + + const view = useMemo(() => { + return findView({ + objectMetadataItemId: objectMetadataItem?.id ?? '', + viewId: viewIdQueryParam, + views, + }); + }, [viewIdQueryParam, objectMetadataItem, views]); + + const activeFieldMetadataItems = useMemo( + () => + objectMetadataItem + ? objectMetadataItem.fields.filter( + ({ isActive, isSystem }) => isActive && !isSystem, + ) + : [], + [objectMetadataItem], + ); + + const filterDefinitions = formatFieldMetadataItemsAsFilterDefinitions({ + fields: activeFieldMetadataItems, + }); + + const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({ + fields: activeFieldMetadataItems, + }); + + const filter = turnObjectDropdownFilterIntoQueryFilter( + mapViewFiltersToFilters(view?.viewFilters ?? [], filterDefinitions), + objectMetadataItem?.fields ?? [], + ); + + const orderBy = turnSortsIntoOrderBy( + objectMetadataItem, + mapViewSortsToSorts(view?.viewSorts ?? [], sortDefinitions), + ); + + const recordGqlFields = generateDepthOneRecordGqlFields({ + objectMetadataItem, + }); + + const { state } = useLocation(); + + const cursorFromIndexPage = state.cursor; + + const { loading: loadingCurrentRecord, pageInfo: currentRecordsPageInfo } = + useFindManyRecords({ + filter: { + id: { eq: objectRecordId }, + }, + orderBy, + skip: isLoadedRecords, + limit: 1, + objectNameSingular, + recordGqlFields, + }); + + const currentRecordCursor = currentRecordsPageInfo?.endCursor; + + const cursor = cursorFromIndexPage ?? currentRecordCursor; + + const { + loading: loadingRecordBefore, + records: recordsBefore, + pageInfo: pageInfoBefore, + totalCount: totalCountBefore, + } = useFindManyRecords({ + filter, + orderBy, + skip: isLoadedRecords, + cursorFilter: isNonEmptyString(cursor) + ? { + cursorDirection: 'before', + cursor: cursor, + limit: 1, + } + : undefined, + objectNameSingular, + recordGqlFields, + }); + + const { + loading: loadingRecordAfter, + records: recordsAfter, + pageInfo: pageInfoAfter, + totalCount: totalCountAfter, + } = useFindManyRecords({ + filter, + orderBy, + skip: isLoadedRecords, + cursorFilter: cursor + ? { + cursorDirection: 'after', + cursor: cursor, + limit: 1, + } + : undefined, + objectNameSingular, + recordGqlFields, + }); + + const totalCount = Math.max(totalCountBefore ?? 0, totalCountAfter ?? 0); + + const loading = + loadingRecordAfter || loadingRecordBefore || loadingCurrentRecord; + + const isThereARecordBefore = recordsBefore.length > 0; + const isThereARecordAfter = recordsAfter.length > 0; + + const recordBefore = recordsBefore[0]; + const recordAfter = recordsAfter[0]; + + const recordBeforeCursor = pageInfoBefore?.endCursor; + const recordAfterCursor = pageInfoAfter?.endCursor; + + const navigateToPreviousRecord = () => { + navigate( + `/object/${objectNameSingular}/${recordBefore.id}${ + viewIdQueryParam ? `?view=${viewIdQueryParam}` : '' + }`, + { + state: { + cursor: recordBeforeCursor, + }, + }, + ); + }; + + const navigateToNextRecord = () => { + navigate( + `/object/${objectNameSingular}/${recordAfter.id}${ + viewIdQueryParam ? `?view=${viewIdQueryParam}` : '' + }`, + { + state: { + cursor: recordAfterCursor, + }, + }, + ); + }; + + const navigateToIndexView = () => { + const indexPath = `/objects/${objectMetadataItem.namePlural}${ + viewIdQueryParam ? `?view=${viewIdQueryParam}` : '' + }`; + + setLastShowPageRecordId(objectRecordId); + + navigate(indexPath); + }; + + const { recordIdsInCache } = useRecordIdsFromFindManyCacheRootQuery({ + objectNamePlural: objectMetadataItem.namePlural, + fieldVariables: { + filter, + orderBy, + }, + }); + + const rankInView = recordIdsInCache.findIndex((id) => id === objectRecordId); + + const rankFoundInFiew = rankInView > -1; + + const objectLabel = capitalize(objectMetadataItem.namePlural); + + const viewNameWithCount = rankFoundInFiew + ? `${rankInView + 1} of ${totalCount} in ${objectLabel}` + : `${objectLabel} (${totalCount})`; + + return { + viewName: viewNameWithCount, + hasPreviousRecord: isThereARecordBefore, + isLoadingPagination: loading, + hasNextRecord: isThereARecordAfter, + navigateToPreviousRecord, + navigateToNextRecord, + navigateToIndexView, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx index 8e5db342d972..5e3b8c23af3e 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx @@ -21,6 +21,7 @@ const StyledTable = styled.table` `; type RecordTableProps = { + viewBarId: string; recordTableId: string; objectNameSingular: string; onColumnsChange: (columns: any) => void; @@ -28,6 +29,7 @@ type RecordTableProps = { }; export const RecordTable = ({ + viewBarId, recordTableId, objectNameSingular, onColumnsChange, @@ -68,6 +70,7 @@ export const RecordTable = ({ {!isRecordTableInitialLoading && diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContextProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContextProvider.tsx index 47fa787d25b6..7fe7343c9298 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContextProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContextProvider.tsx @@ -18,10 +18,12 @@ import { MoveFocusDirection } from '@/object-record/record-table/types/MoveFocus import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition'; export const RecordTableContextProvider = ({ + viewBarId, recordTableId, objectNameSingular, children, }: { + viewBarId: string; recordTableId: string; objectNameSingular: string; children: ReactNode; @@ -90,6 +92,7 @@ export const RecordTableContextProvider = ({ return ( { leaveTableFocus(); }, - mode: ClickOutsideMode.comparePixels, + mode: ClickOutsideMode.compareHTMLRef, }); useScopedHotkeys( diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx index 7a6cabbdddf1..85e860436e93 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx @@ -76,6 +76,7 @@ export const RecordTableWithWrappers = ({ {}, onOpenTableCell: () => {}, diff --git a/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts b/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts index 9bab734d9037..960e544620a2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts @@ -9,6 +9,7 @@ import { MoveFocusDirection } from '@/object-record/record-table/types/MoveFocus import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition'; export type RecordTableContextProps = { + viewBarId: string; objectMetadataItem: ObjectMetadataItem; onUpsertRecord: ({ persistField, diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyEffect.tsx index 2ba836121002..33816ad4d3ec 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyEffect.tsx @@ -1,7 +1,8 @@ -import { useContext, useEffect } from 'react'; -import { useRecoilValue } from 'recoil'; +import { useContext, useEffect, useState } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; import { useDebouncedCallback } from 'use-debounce'; +import { lastShowPageRecordIdState } from '@/object-record/record-field/states/lastShowPageRecordId'; import { useLoadRecordIndexTable } from '@/object-record/record-index/hooks/useLoadRecordIndexTable'; import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; @@ -11,11 +12,17 @@ import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetch import { scrollLeftState } from '@/ui/utilities/scroll/states/scrollLeftState'; import { scrollTopState } from '@/ui/utilities/scroll/states/scrollTopState'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { isNonEmptyString } from '@sniptt/guards'; import { useScrollRestoration } from '~/hooks/useScrollRestoration'; +import { useScrollToPosition } from '~/hooks/useScrollToPosition'; + +export const ROW_HEIGHT = 32; export const RecordTableBodyEffect = () => { const { objectNameSingular } = useContext(RecordTableContext); + const [hasInitializedScroll, setHasInitiazedScroll] = useState(false); + const { fetchMoreRecords: fetchMoreObjects, records, @@ -76,9 +83,44 @@ export const RecordTableBodyEffect = () => { } }, [scrollLeft, setIsRecordTableScrolledLeft]); - const rowHeight = 32; + const rowHeight = ROW_HEIGHT; const viewportHeight = records.length * rowHeight; + const [lastShowPageRecordId, setLastShowPageRecordId] = useRecoilState( + lastShowPageRecordIdState, + ); + + const { scrollToPosition } = useScrollToPosition(); + + useEffect(() => { + if (isNonEmptyString(lastShowPageRecordId) && !hasInitializedScroll) { + const isRecordAlreadyFetched = records.some( + (record) => record.id === lastShowPageRecordId, + ); + + if (isRecordAlreadyFetched) { + const recordPosition = records.findIndex( + (record) => record.id === lastShowPageRecordId, + ); + + const positionInPx = recordPosition * ROW_HEIGHT; + + scrollToPosition(positionInPx); + + setHasInitiazedScroll(true); + } + } + }, [ + loading, + isFetchingMoreObjects, + lastShowPageRecordId, + fetchMoreObjects, + records, + scrollToPosition, + hasInitializedScroll, + setLastShowPageRecordId, + ]); + useScrollRestoration(viewportHeight); useEffect(() => { diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2.ts index b9e279b0b77a..2a379840276f 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2.ts @@ -1,4 +1,3 @@ -import { useNavigate } from 'react-router-dom'; import { useRecoilCallback, useSetRecoilState } from 'recoil'; import { useInitDraftValueV2 } from '@/object-record/record-field/hooks/useInitDraftValueV2'; @@ -21,6 +20,8 @@ import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useC import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; import { isDefined } from '~/utils/isDefined'; +import { RecordIndexEventContext } from '@/object-record/record-index/contexts/RecordIndexEventContext'; +import { useContext } from 'react'; import { TableHotkeyScope } from '../../types/TableHotkeyScope'; export const DEFAULT_CELL_SCOPE: HotkeyScope = { @@ -40,13 +41,13 @@ export type OpenTableCellArgs = { }; export const useOpenRecordTableCellV2 = (tableScopeId: string) => { + const { onIndexIdentifierClick } = useContext(RecordIndexEventContext); const moveEditModeToTableCellPosition = useMoveEditModeToTableCellPosition(tableScopeId); const setHotkeyScope = useSetHotkeyScope(); const { setDragSelectionStartEnabled } = useDragSelect(); - const navigate = useNavigate(); const leaveTableFocus = useLeaveTableFocus(tableScopeId); const { toggleClickOutsideListener } = useClickOutsideListener( SOFT_FOCUS_CLICK_OUTSIDE_LISTENER_ID, @@ -66,7 +67,6 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => { initialValue, cellPosition, isReadOnly, - pathToShowPage, objectNameSingular, customCellHotkeyScope, fieldDefinition, @@ -94,7 +94,8 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => { if (isFirstColumnCell && !isEmpty && !isActionButtonClick) { leaveTableFocus(); - navigate(pathToShowPage); + + onIndexIdentifierClick(entityId); return; } @@ -142,7 +143,7 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => { openRightDrawer, setViewableRecordId, setViewableRecordNameSingular, - navigate, + onIndexIdentifierClick, ], ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowWrapper.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowWrapper.tsx index d64f316e12bd..193c852d9e88 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowWrapper.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRowWrapper.tsx @@ -1,10 +1,11 @@ -import { ReactNode, useContext } from 'react'; -import { useInView } from 'react-intersection-observer'; import { useTheme } from '@emotion/react'; import { Draggable } from '@hello-pangea/dnd'; +import { ReactNode, useContext, useEffect } from 'react'; +import { useInView } from 'react-intersection-observer'; import { useRecoilValue } from 'recoil'; import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage'; +import { RecordIndexEventContext } from '@/object-record/record-index/contexts/RecordIndexEventContext'; import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext'; import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; @@ -23,6 +24,7 @@ export const RecordTableRowWrapper = ({ children: ReactNode; }) => { const { objectMetadataItem } = useContext(RecordTableContext); + const { onIndexRecordsLoaded } = useContext(RecordIndexEventContext); const theme = useTheme(); @@ -38,6 +40,13 @@ export const RecordTableRowWrapper = ({ rootMargin: '1000px', }); + // TODO: find a better way to emit this event + useEffect(() => { + if (inView) { + onIndexRecordsLoaded?.(); + } + }, [inView, onIndexRecordsLoaded]); + return ( {(draggableProvided, draggableSnapshot) => ( diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx index e46f7d0362da..af92fedd19f0 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx @@ -66,7 +66,7 @@ export const MultipleObjectRecordSelectItem = ({ avatar={ gql` query FindMany${capitalize( objectMetadataItem.namePlural, @@ -24,9 +27,11 @@ query FindMany${capitalize( )}FilterInput, $orderBy: [${capitalize( objectMetadataItem.nameSingular, )}OrderByInput], $lastCursor: String, $limit: Int) { - ${ - objectMetadataItem.namePlural - }(filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor){ + ${objectMetadataItem.namePlural}(filter: $filter, orderBy: $orderBy, ${ + cursorDirection === 'before' + ? 'last: $limit, before: $lastCursor' + : 'first: $limit, after: $lastCursor' + } ){ edges { node ${mapObjectMetadataToGraphQLQuery({ objectMetadataItems, @@ -37,11 +42,12 @@ query FindMany${capitalize( cursor } pageInfo { - ${isAggregationEnabled(objectMetadataItem) ? 'hasNextPage' : ''} + hasNextPage + hasPreviousPage startCursor endCursor } - ${isAggregationEnabled(objectMetadataItem) ? 'totalCount' : ''} + totalCount } } `; diff --git a/packages/twenty-front/src/modules/object-record/utils/getQueryIdentifier.ts b/packages/twenty-front/src/modules/object-record/utils/getQueryIdentifier.ts index 20fc7d79f921..c43f7807a451 100644 --- a/packages/twenty-front/src/modules/object-record/utils/getQueryIdentifier.ts +++ b/packages/twenty-front/src/modules/object-record/utils/getQueryIdentifier.ts @@ -5,7 +5,12 @@ export const getQueryIdentifier = ({ filter, orderBy, limit, + cursorFilter, }: RecordGqlOperationVariables & { objectNameSingular: string; }) => - objectNameSingular + JSON.stringify(filter) + JSON.stringify(orderBy) + limit; + objectNameSingular + + JSON.stringify(filter) + + JSON.stringify(orderBy) + + limit + + (cursorFilter ? JSON.stringify(cursorFilter) : undefined); diff --git a/packages/twenty-front/src/modules/object-record/utils/getRecordChipGeneratorPerObjectPerField.ts b/packages/twenty-front/src/modules/object-record/utils/getRecordChipGeneratorPerObjectPerField.ts deleted file mode 100644 index f127e275ec39..000000000000 --- a/packages/twenty-front/src/modules/object-record/utils/getRecordChipGeneratorPerObjectPerField.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { ChipGeneratorPerObjectPerField } from '@/object-metadata/context/PreComputedChipGeneratorsContext'; -import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { getAvatarType } from '@/object-metadata/utils/getAvatarType'; -import { getAvatarUrl } from '@/object-metadata/utils/getAvatarUrl'; -import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; -import { getLabelIdentifierFieldValue } from '@/object-metadata/utils/getLabelIdentifierFieldValue'; -import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage'; -import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; -import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; -import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; -import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; -import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText'; -import { RecordChipData } from '@/object-record/record-field/types/RecordChipData'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { isDefined } from '~/utils/isDefined'; - -export const isFieldChipDisplay = ( - field: Pick, - isLabelIdentifier: boolean, -) => - isLabelIdentifier && - (isFieldText(field) || isFieldFullName(field) || isFieldNumber(field)); - -export const getRecordChipGeneratorPerObjectPerField = ( - objectMetadataItems: ObjectMetadataItem[], -) => { - const recordChipGeneratorPerObjectPerField: ChipGeneratorPerObjectPerField = - {}; - - for (const objectMetadataItem of objectMetadataItems) { - const generatorPerField = Object.fromEntries< - (record: ObjectRecord) => RecordChipData - >( - objectMetadataItem.fields - .filter( - (fieldMetadataItem) => - isLabelIdentifierField({ - fieldMetadataItem: fieldMetadataItem, - objectMetadataItem, - }) || - fieldMetadataItem.type === FieldMetadataType.Relation || - isFieldChipDisplay( - fieldMetadataItem, - isLabelIdentifierField({ - fieldMetadataItem: fieldMetadataItem, - objectMetadataItem, - }), - ), - ) - .map((fieldMetadataItem) => { - const objectNameSingularToFind = isLabelIdentifierField({ - fieldMetadataItem: fieldMetadataItem, - objectMetadataItem: objectMetadataItem, - }) - ? objectMetadataItem.nameSingular - : isFieldRelation(fieldMetadataItem) - ? fieldMetadataItem.relationDefinition?.targetObjectMetadata - .nameSingular ?? undefined - : undefined; - - const objectMetadataItemToUse = objectMetadataItems.find( - (objectMetadataItem) => - objectMetadataItem.nameSingular === objectNameSingularToFind, - ); - - if ( - !isDefined(objectMetadataItemToUse) || - !isDefined(objectNameSingularToFind) - ) { - return ['', () => ({}) as any]; - } - - const labelIdentifierFieldMetadataItem = - getLabelIdentifierFieldMetadataItem(objectMetadataItemToUse); - - const imageIdentifierFieldMetadata = - objectMetadataItemToUse.fields.find( - (field) => - field.id === - objectMetadataItemToUse.imageIdentifierFieldMetadataId, - ); - - const avatarType = getAvatarType(objectNameSingularToFind); - - return [ - fieldMetadataItem.name, - (record: ObjectRecord) => ({ - recordId: record.id, - name: getLabelIdentifierFieldValue( - record, - labelIdentifierFieldMetadataItem, - objectMetadataItemToUse.nameSingular, - ), - avatarUrl: getAvatarUrl( - objectMetadataItemToUse.nameSingular, - record, - imageIdentifierFieldMetadata, - ), - avatarType, - linkToShowPage: getLinkToShowPage( - objectMetadataItemToUse.nameSingular, - record, - ), - }), - ]; - }), - ); - - recordChipGeneratorPerObjectPerField[objectMetadataItem.nameSingular] = - generatorPerField; - } - - return recordChipGeneratorPerObjectPerField; -}; diff --git a/packages/twenty-front/src/modules/object-record/utils/getRecordChipGenerators.ts b/packages/twenty-front/src/modules/object-record/utils/getRecordChipGenerators.ts new file mode 100644 index 000000000000..c80fe1b76312 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/getRecordChipGenerators.ts @@ -0,0 +1,120 @@ +import { + ChipGeneratorPerObjectNameSingularPerFieldName, + IdentifierChipGeneratorPerObject, +} from '@/object-metadata/context/PreComputedChipGeneratorsContext'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getAvatarType } from '@/object-metadata/utils/getAvatarType'; +import { getAvatarUrl } from '@/object-metadata/utils/getAvatarUrl'; +import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; +import { getLabelIdentifierFieldValue } from '@/object-metadata/utils/getLabelIdentifierFieldValue'; +import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; +import { isFieldChipDisplay } from '@/object-record/record-field/meta-types/display/utils/isFieldChipDisplay'; +import { RecordChipData } from '@/object-record/record-field/types/RecordChipData'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined'; + +export const getRecordChipGenerators = ( + objectMetadataItems: ObjectMetadataItem[], +) => { + const chipGeneratorPerObjectPerField: ChipGeneratorPerObjectNameSingularPerFieldName = + {}; + + const identifierChipGeneratorPerObject: IdentifierChipGeneratorPerObject = {}; + + for (const objectMetadataItem of objectMetadataItems) { + const labelIdentifierFieldMetadataItem = + getLabelIdentifierFieldMetadataItem(objectMetadataItem); + + const generatorPerField = Object.fromEntries< + (record: ObjectRecord) => RecordChipData + >( + objectMetadataItem.fields + .filter( + (fieldMetadataItem) => + labelIdentifierFieldMetadataItem?.id === fieldMetadataItem.id || + fieldMetadataItem.type === FieldMetadataType.Relation || + isFieldChipDisplay( + fieldMetadataItem, + isLabelIdentifierField({ + fieldMetadataItem: fieldMetadataItem, + objectMetadataItem, + }), + ), + ) + .map((fieldMetadataItem) => { + const isLabelIdentifier = + labelIdentifierFieldMetadataItem?.id === fieldMetadataItem.id; + + const currentObjectNameSingular = objectMetadataItem.nameSingular; + const fieldRelationObjectNameSingular = + fieldMetadataItem.relationDefinition?.targetObjectMetadata + .nameSingular ?? undefined; + + const objectNameSingularToFind = isLabelIdentifier + ? currentObjectNameSingular + : fieldRelationObjectNameSingular; + + const objectMetadataItemToUse = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.nameSingular === objectNameSingularToFind, + ); + + if ( + !isDefined(objectMetadataItemToUse) || + !isDefined(objectNameSingularToFind) + ) { + return ['', () => ({}) as any]; + } + + const labelIdentifierFieldMetadataItemToUse = + getLabelIdentifierFieldMetadataItem(objectMetadataItemToUse); + + const imageIdentifierFieldMetadataToUse = + objectMetadataItemToUse.fields.find( + (field) => + field.id === + objectMetadataItemToUse.imageIdentifierFieldMetadataId, + ); + + const avatarType = getAvatarType(objectNameSingularToFind); + + return [ + fieldMetadataItem.name, + (record: ObjectRecord) => + ({ + recordId: record.id, + name: getLabelIdentifierFieldValue( + record, + labelIdentifierFieldMetadataItemToUse, + objectMetadataItemToUse.nameSingular, + ), + avatarUrl: getAvatarUrl( + objectMetadataItemToUse.nameSingular, + record, + imageIdentifierFieldMetadataToUse, + ), + avatarType, + isLabelIdentifier, + objectNameSingular: objectNameSingularToFind, + }) satisfies RecordChipData, + ]; + }), + ); + + chipGeneratorPerObjectPerField[objectMetadataItem.nameSingular] = + generatorPerField; + + if (isDefined(labelIdentifierFieldMetadataItem)) { + identifierChipGeneratorPerObject[objectMetadataItem.nameSingular] = + chipGeneratorPerObjectPerField[objectMetadataItem.nameSingular]?.[ + labelIdentifierFieldMetadataItem.name + ]; + } + } + + return { + chipGeneratorPerObjectPerField, + identifierChipGeneratorPerObject, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/page/PageHeader.tsx b/packages/twenty-front/src/modules/ui/layout/page/PageHeader.tsx index ae92a178319c..49e7ba382dcb 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/PageHeader.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/PageHeader.tsx @@ -4,14 +4,15 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; import { - IconChevronLeft, + IconChevronDown, + IconChevronUp, IconComponent, + IconX, MOBILE_VIEWPORT, OverflowingTextWithTooltip, } from 'twenty-ui'; import { IconButton } from '@/ui/input/button/components/IconButton'; -import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink'; import { NavigationDrawerCollapseButton } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerCollapseButton'; import { isNavigationDrawerOpenState } from '@/ui/navigation/states/isNavigationDrawerOpenState'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; @@ -53,6 +54,7 @@ const StyledLeftContainer = styled.div` const StyledTitleContainer = styled.div` display: flex; font-size: ${({ theme }) => theme.font.size.md}; + font-weight: ${({ theme }) => theme.font.weight.medium}; margin-left: ${({ theme }) => theme.spacing(1)}; max-width: 50%; `; @@ -61,6 +63,7 @@ const StyledTopBarIconStyledTitleContainer = styled.div` align-items: center; display: flex; flex: 1 0 auto; + gap: ${({ theme }) => theme.spacing(1)}; flex-direction: row; `; @@ -89,7 +92,13 @@ const StyledSkeletonLoader = () => { type PageHeaderProps = ComponentProps<'div'> & { title: string; - hasBackButton?: boolean; + hasClosePageButton?: boolean; + onClosePage?: () => void; + hasPaginationButtons?: boolean; + hasPreviousRecord?: boolean; + hasNextRecord?: boolean; + navigateToPreviousRecord?: () => void; + navigateToNextRecord?: () => void; Icon: IconComponent; children?: ReactNode; loading?: boolean; @@ -97,7 +106,13 @@ type PageHeaderProps = ComponentProps<'div'> & { export const PageHeader = ({ title, - hasBackButton, + hasClosePageButton, + onClosePage, + hasPaginationButtons, + hasPreviousRecord, + hasNextRecord, + navigateToPreviousRecord, + navigateToNextRecord, Icon, children, loading, @@ -114,19 +129,36 @@ export const PageHeader = ({ )} - {hasBackButton && ( - - - + {hasClosePageButton && ( + onClosePage?.()} + /> )} {loading ? ( ) : ( + {hasPaginationButtons && ( + <> + navigateToPreviousRecord?.()} + /> + navigateToNextRecord?.()} + /> + + )} {Icon && } 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 4ca592e1e1f2..6a6b26cc591c 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 @@ -1,7 +1,7 @@ -import { ChangeEvent, ReactNode, useRef } from 'react'; -import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { ChangeEvent, ReactNode, useRef } from 'react'; +import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; import { AppTooltip, Avatar, AvatarType } from 'twenty-ui'; import { v4 as uuidV4 } from 'uuid'; @@ -124,7 +124,7 @@ export const ShowPageSummaryCard = ({ avatarUrl={logoOrAvatar} onClick={onUploadPicture ? handleAvatarClick : undefined} size="xl" - entityId={id} + placeholderColorSeed={id} placeholder={avatarPlaceholder} type={avatarType} /> diff --git a/packages/twenty-front/src/modules/users/components/UserChip.tsx b/packages/twenty-front/src/modules/users/components/UserChip.tsx index 82beb9da53be..5f4fbc94ebf3 100644 --- a/packages/twenty-front/src/modules/users/components/UserChip.tsx +++ b/packages/twenty-front/src/modules/users/components/UserChip.tsx @@ -1,4 +1,4 @@ -import { EntityChip } from 'twenty-ui'; +import { AvatarChip } from 'twenty-ui'; import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64'; @@ -9,8 +9,8 @@ export type UserChipProps = { }; export const UserChip = ({ id, name, avatarUrl }: UserChipProps) => ( - {workspaceMember.userEmail} - {accessory} ); diff --git a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx index e8d5bd90247c..1037467db9ff 100644 --- a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx +++ b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx @@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom'; import { TimelineActivityContext } from '@/activities/timelineActivities/contexts/TimelineActivityContext'; import { RecordShowContainer } from '@/object-record/record-show/components/RecordShowContainer'; import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage'; +import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/useRecordShowPagePagination'; import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect'; import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; import { PageBody } from '@/ui/layout/page/PageBody'; @@ -35,16 +36,35 @@ export const RecordShowPage = () => { parameters.objectRecordId ?? '', ); + const { + viewName, + hasPreviousRecord, + hasNextRecord, + navigateToPreviousRecord, + navigateToNextRecord, + navigateToIndexView, + isLoadingPagination, + } = useRecordShowPagePagination( + parameters.objectNameSingular ?? '', + parameters.objectRecordId ?? '', + ); + return ( <> = { routePath: '/object/:objectNameSingular/:objectRecordId', routeParams: { ':objectNameSingular': 'person', - ':objectRecordId': '1234', + ':objectRecordId': peopleMock[0].id, }, }, parameters: { msw: { handlers: [ + graphql.query('FindManyPeople', () => { + return HttpResponse.json({ + data: peopleQueryResult, + }); + }), graphql.query('FindOnePerson', () => { return HttpResponse.json({ data: { @@ -64,8 +69,8 @@ const meta: Meta = { edges: [], pageInfo: { hasNextPage: false, - startCursor: '1234', - endCursor: '1234', + startCursor: peopleMock[0].id, + endCursor: peopleMock[0].id, }, totalCount: 0, }, diff --git a/packages/twenty-front/src/testing/decorators/ChipGeneratorsDecorator.tsx b/packages/twenty-front/src/testing/decorators/ChipGeneratorsDecorator.tsx index 6f9ac5d305bd..283e7046e0ab 100644 --- a/packages/twenty-front/src/testing/decorators/ChipGeneratorsDecorator.tsx +++ b/packages/twenty-front/src/testing/decorators/ChipGeneratorsDecorator.tsx @@ -1,21 +1,21 @@ -import { useMemo } from 'react'; import { Decorator } from '@storybook/react'; +import { useMemo } from 'react'; import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext'; -import { getRecordChipGeneratorPerObjectPerField } from '@/object-record/utils/getRecordChipGeneratorPerObjectPerField'; +import { getRecordChipGenerators } from '@/object-record/utils/getRecordChipGenerators'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems'; export const ChipGeneratorsDecorator: Decorator = (Story) => { - const chipGeneratorPerObjectPerField = useMemo(() => { - return getRecordChipGeneratorPerObjectPerField( - generatedMockObjectMetadataItems, - ); - }, []); + const { chipGeneratorPerObjectPerField, identifierChipGeneratorPerObject } = + useMemo(() => { + return getRecordChipGenerators(generatedMockObjectMetadataItems); + }, []); return ( diff --git a/packages/twenty-front/src/testing/decorators/ObjectMetadataItemsDecorator.tsx b/packages/twenty-front/src/testing/decorators/ObjectMetadataItemsDecorator.tsx index 1bf08a190ffa..c1582edbae07 100644 --- a/packages/twenty-front/src/testing/decorators/ObjectMetadataItemsDecorator.tsx +++ b/packages/twenty-front/src/testing/decorators/ObjectMetadataItemsDecorator.tsx @@ -1,13 +1,12 @@ -import { useEffect, useMemo } from 'react'; import { Decorator } from '@storybook/react'; +import { useEffect } from 'react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { currentUserState } from '@/auth/states/currentUserState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { ObjectMetadataItemsLoadEffect } from '@/object-metadata/components/ObjectMetadataItemsLoadEffect'; -import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext'; +import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { getRecordChipGeneratorPerObjectPerField } from '@/object-record/utils/getRecordChipGeneratorPerObjectPerField'; import { mockedUserData } from '~/testing/mock-data/users'; import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members'; @@ -23,20 +22,12 @@ export const ObjectMetadataItemsDecorator: Decorator = (Story) => { setCurrentUser(mockedUserData); }, [setCurrentUser, setCurrentWorkspaceMember]); - const chipGeneratorPerObjectPerField = useMemo(() => { - return getRecordChipGeneratorPerObjectPerField(objectMetadataItems); - }, [objectMetadataItems]); - return ( <> - + {!!objectMetadataItems.length && } - + ); }; diff --git a/packages/twenty-front/src/utils/createApolloStoreFieldName.ts b/packages/twenty-front/src/utils/createApolloStoreFieldName.ts new file mode 100644 index 000000000000..7959a7f2a938 --- /dev/null +++ b/packages/twenty-front/src/utils/createApolloStoreFieldName.ts @@ -0,0 +1,9 @@ +export const createApolloStoreFieldName = ({ + fieldName, + fieldVariables, +}: { + fieldName: string; + fieldVariables: Record; +}) => { + return `${fieldName}(${JSON.stringify(fieldVariables)})`; +}; diff --git a/packages/twenty-front/src/utils/createEventContext.ts b/packages/twenty-front/src/utils/createEventContext.ts new file mode 100644 index 000000000000..c69425003def --- /dev/null +++ b/packages/twenty-front/src/utils/createEventContext.ts @@ -0,0 +1,12 @@ +import { Context, createContext } from 'react'; + +type ObjectOfFunctions = { + [key: string]: (...args: any[]) => void; +}; + +export type EventContext = + T extends ObjectOfFunctions ? T : never; + +export const createEventContext = (): Context< + EventContext +> => createContext>({} as EventContext); diff --git a/packages/twenty-ui/src/display/avatar/components/Avatar.tsx b/packages/twenty-ui/src/display/avatar/components/Avatar.tsx index a191549d4956..b0373b8cb8e5 100644 --- a/packages/twenty-ui/src/display/avatar/components/Avatar.tsx +++ b/packages/twenty-ui/src/display/avatar/components/Avatar.tsx @@ -1,6 +1,6 @@ -import { useContext } from 'react'; import { styled } from '@linaria/react'; import { isNonEmptyString, isUndefined } from '@sniptt/guards'; +import { useContext } from 'react'; import { useRecoilState } from 'recoil'; import { invalidAvatarUrlsState } from '@ui/display/avatar/components/states/isInvalidAvatarUrlState'; @@ -50,7 +50,7 @@ export type AvatarProps = { className?: string; size?: AvatarSize; placeholder: string | undefined; - entityId?: string; + placeholderColorSeed?: string; type?: Nullable; color?: string; backgroundColor?: string; @@ -62,7 +62,7 @@ export const Avatar = ({ avatarUrl, size = 'md', placeholder, - entityId = placeholder, + placeholderColorSeed = placeholder, onClick, type = 'squared', color, @@ -85,9 +85,10 @@ export const Avatar = ({ } }; - const fixedColor = color ?? stringToHslColor(entityId ?? '', 75, 25); + const fixedColor = + color ?? stringToHslColor(placeholderColorSeed ?? '', 75, 25); const fixedBackgroundColor = - backgroundColor ?? stringToHslColor(entityId ?? '', 75, 85); + backgroundColor ?? stringToHslColor(placeholderColorSeed ?? '', 75, 85); const showBackgroundColor = showPlaceholder; diff --git a/packages/twenty-ui/src/display/avatar/components/__stories__/AvatarGroup.stories.tsx b/packages/twenty-ui/src/display/avatar/components/__stories__/AvatarGroup.stories.tsx index 710837644b22..e3792988dc5a 100644 --- a/packages/twenty-ui/src/display/avatar/components/__stories__/AvatarGroup.stories.tsx +++ b/packages/twenty-ui/src/display/avatar/components/__stories__/AvatarGroup.stories.tsx @@ -13,7 +13,7 @@ import { AvatarGroup, AvatarGroupProps } from '../AvatarGroup'; const makeAvatar = (userName: string, props: Partial = {}) => ( // eslint-disable-next-line react/jsx-props-no-spreading - + ); const getAvatars = (commonProps: Partial = {}) => [ diff --git a/packages/twenty-ui/src/display/chip/components/EntityChip.tsx b/packages/twenty-ui/src/display/chip/components/AvatarChip.tsx similarity index 60% rename from packages/twenty-ui/src/display/chip/components/EntityChip.tsx rename to packages/twenty-ui/src/display/chip/components/AvatarChip.tsx index 13f95297ceb4..89c4bdabef11 100644 --- a/packages/twenty-ui/src/display/chip/components/EntityChip.tsx +++ b/packages/twenty-ui/src/display/chip/components/AvatarChip.tsx @@ -1,56 +1,47 @@ -import * as React from 'react'; -import { useNavigate } from 'react-router-dom'; import { useTheme } from '@emotion/react'; -import { isNonEmptyString } from '@sniptt/guards'; import { Avatar } from '@ui/display/avatar/components/Avatar'; import { AvatarType } from '@ui/display/avatar/types/AvatarType'; import { Chip, ChipVariant } from '@ui/display/chip/components/Chip'; import { IconComponent } from '@ui/display/icon/types/IconComponent'; +import { isDefined } from '@ui/utilities/isDefined'; import { Nullable } from '@ui/utilities/types/Nullable'; +import { MouseEvent } from 'react'; -export type EntityChipProps = { - linkToEntity?: string; - entityId: string; +export type AvatarChipProps = { name: string; avatarUrl?: string; avatarType?: Nullable; - variant?: EntityChipVariant; + variant?: AvatarChipVariant; LeftIcon?: IconComponent; className?: string; + placeholderColorSeed?: string; + onClick?: (event: MouseEvent) => void; }; -export enum EntityChipVariant { +export enum AvatarChipVariant { Regular = 'regular', Transparent = 'transparent', } -export const EntityChip = ({ - linkToEntity, - entityId, +export const AvatarChip = ({ name, avatarUrl, avatarType = 'rounded', - variant = EntityChipVariant.Regular, + variant = AvatarChipVariant.Regular, LeftIcon, className, -}: EntityChipProps) => { - const navigate = useNavigate(); + placeholderColorSeed, + onClick, +}: AvatarChipProps) => { const theme = useTheme(); - const handleLinkClick = (event: React.MouseEvent) => { - if (isNonEmptyString(linkToEntity)) { - event.stopPropagation(); - navigate(linkToEntity); - } - }; - return ( ) } - clickable={!!linkToEntity} - onClick={handleLinkClick} + clickable={isDefined(onClick)} + onClick={onClick} className={className} /> ); diff --git a/packages/twenty-ui/src/display/chip/components/__stories__/EntityChip.stories.tsx b/packages/twenty-ui/src/display/chip/components/__stories__/EntityChip.stories.tsx index 254fceef4018..2682eec0409e 100644 --- a/packages/twenty-ui/src/display/chip/components/__stories__/EntityChip.stories.tsx +++ b/packages/twenty-ui/src/display/chip/components/__stories__/EntityChip.stories.tsx @@ -1,21 +1,19 @@ import { Meta, StoryObj } from '@storybook/react'; +import { AvatarChip } from '@ui/display/chip/components/AvatarChip'; import { ComponentDecorator, RouterDecorator } from '@ui/testing'; -import { EntityChip } from '../EntityChip'; - -const meta: Meta = { - title: 'UI/Display/Chip/EntityChip', - component: EntityChip, +const meta: Meta = { + title: 'UI/Display/Chip/AvatarChip', + component: AvatarChip, decorators: [RouterDecorator, ComponentDecorator], args: { name: 'Entity name', - linkToEntity: '/entity-link', avatarType: 'squared', }, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = {}; diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index e0329a5bb704..fa961ff5abf3 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -6,8 +6,8 @@ export * from './avatar/types/AvatarSize'; export * from './avatar/types/AvatarType'; export * from './checkmark/components/AnimatedCheckmark'; export * from './checkmark/components/Checkmark'; +export * from './chip/components/AvatarChip'; export * from './chip/components/Chip'; -export * from './chip/components/EntityChip'; export * from './color/components/ColorSample'; export * from './icon/components/IconAddressBook'; export * from './icon/components/IconGmail'; diff --git a/packages/twenty-ui/src/utilities/index.ts b/packages/twenty-ui/src/utilities/index.ts index 1e20365c2bcf..3d99deafeead 100644 --- a/packages/twenty-ui/src/utilities/index.ts +++ b/packages/twenty-ui/src/utilities/index.ts @@ -1,3 +1,4 @@ export * from './color/utils/stringToHslColor'; +export * from './isDefined'; export * from './state/utils/createState'; export * from './types/Nullable'; diff --git a/packages/twenty-ui/src/utilities/isDefined.ts b/packages/twenty-ui/src/utilities/isDefined.ts new file mode 100644 index 000000000000..81eb67203a03 --- /dev/null +++ b/packages/twenty-ui/src/utilities/isDefined.ts @@ -0,0 +1,4 @@ +import { isNull, isUndefined } from '@sniptt/guards'; + +export const isDefined = (value: T | null | undefined): value is T => + !isUndefined(value) && !isNull(value);