From 602d5422a255110a9d587ac1e9786c1966bc86e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tha=C3=AFs?= Date: Wed, 15 May 2024 15:52:23 +0200 Subject: [PATCH] feat: display Links field as Expandable List (#5374) Closes #5114 --- ...arEventParticipantsResponseStatusField.tsx | 14 +- .../components/ActivityTargetChips.tsx | 25 +- .../components/ActivityTargetsInlineCell.tsx | 5 +- .../record-field/components/FieldDisplay.tsx | 25 +- .../display/components/LinksFieldDisplay.tsx | 21 +- .../components/MultiSelectFieldDisplay.tsx | 30 +-- .../components/RecordInlineCell.tsx | 7 +- .../components/RecordInlineCellContainer.tsx | 42 ++-- .../components/RecordTableCell.tsx | 8 +- .../components/RecordTableCellContainer.tsx | 60 ++--- .../modules/ui/display/tag/components/Tag.tsx | 6 +- .../field/display/components/LinksDisplay.tsx | 31 ++- .../components/ChildrenContainer.tsx | 57 ----- .../components/ExpandableList.tsx | 225 ++++++++++-------- .../components/ExpandedListDropdown.tsx | 69 ++++++ .../__stories__/ExpandableList.stories.tsx | 89 ++++--- .../__tests__/getChildProperties.test.ts | 41 ---- .../__tests__/getChipContentWidth.test.ts | 13 - .../utils/getChildProperties.ts | 30 --- .../utils/getChipContentWidth.ts | 6 - .../utils/isFirstOverflowingChildElement.ts | 17 ++ .../link/components/RoundedLink.tsx | 48 ++-- .../tooltip/OverflowingTextWithTooltip.tsx | 25 +- 23 files changed, 440 insertions(+), 454 deletions(-) delete mode 100644 packages/twenty-front/src/modules/ui/layout/expandable-list/components/ChildrenContainer.tsx create mode 100644 packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandedListDropdown.tsx delete mode 100644 packages/twenty-front/src/modules/ui/layout/expandable-list/utils/__tests__/getChildProperties.test.ts delete mode 100644 packages/twenty-front/src/modules/ui/layout/expandable-list/utils/__tests__/getChipContentWidth.test.ts delete mode 100644 packages/twenty-front/src/modules/ui/layout/expandable-list/utils/getChildProperties.ts delete mode 100644 packages/twenty-front/src/modules/ui/layout/expandable-list/utils/getChipContentWidth.ts create mode 100644 packages/twenty-front/src/modules/ui/layout/expandable-list/utils/isFirstOverflowingChildElement.ts diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventParticipantsResponseStatusField.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventParticipantsResponseStatusField.tsx index 9c49e1f6b..30918e5b0 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventParticipantsResponseStatusField.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventParticipantsResponseStatusField.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useRef } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; @@ -88,7 +88,6 @@ export const CalendarEventParticipantsResponseStatusField = ({ ]; const participantsContainerRef = useRef(null); - const [isHovered, setIsHovered] = useState(false); const styledChips = orderedParticipants.map((participant, index) => ( )); @@ -103,16 +102,11 @@ export const CalendarEventParticipantsResponseStatusField = ({ {responseStatus} - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > + {isRightDrawerAnimationCompleted && ( {styledChips} diff --git a/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx b/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx index 3a46146fe..1123a6d1f 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx @@ -2,10 +2,13 @@ import styled from '@emotion/styled'; import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject'; import { RecordChip } from '@/object-record/components/RecordChip'; -import { - ExpandableList, - ExpandableListProps, -} from '@/ui/layout/expandable-list/components/ExpandableList'; +import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; + +type ActivityTargetChipsProps = { + activityTargetObjectRecords: ActivityTargetWithTargetRecord[]; + anchorElement?: HTMLElement; + maxWidth?: number; +}; const StyledContainer = styled.div<{ maxWidth?: number }>` display: flex; @@ -16,20 +19,12 @@ const StyledContainer = styled.div<{ maxWidth?: number }>` export const ActivityTargetChips = ({ activityTargetObjectRecords, - isHovered, - reference, + anchorElement, maxWidth, -}: { - activityTargetObjectRecords: ActivityTargetWithTargetRecord[]; - maxWidth?: number; -} & ExpandableListProps) => { +}: ActivityTargetChipsProps) => { return ( - + {activityTargetObjectRecords.map( (activityTargetObjectRecord, index) => ( } label="Relations" - displayModeContent={ + displayModeContent={({ cellElement }) => ( - } + )} isDisplayModeContentEmpty={activityTargetObjectRecords.length === 0} /> 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 703064580..e41354d9e 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 @@ -3,7 +3,6 @@ import { useContext } from 'react'; import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay'; import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; -import { ExpandableListProps } from '@/ui/layout/expandable-list/components/ExpandableList'; import { FieldContext } from '../contexts/FieldContext'; import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay'; @@ -38,13 +37,17 @@ import { isFieldSelect } from '../types/guards/isFieldSelect'; import { isFieldText } from '../types/guards/isFieldText'; import { isFieldUuid } from '../types/guards/isFieldUuid'; -type FieldDisplayProps = ExpandableListProps; +type FieldDisplayProps = { + isCellSoftFocused?: boolean; + cellElement?: HTMLElement; + fromTableCell?: boolean; +}; export const FieldDisplay = ({ - isHovered, - reference, + isCellSoftFocused, + cellElement, fromTableCell, -}: FieldDisplayProps & { fromTableCell?: boolean }) => { +}: FieldDisplayProps) => { const { fieldDefinition, isLabelIdentifier } = useContext(FieldContext); const isChipDisplay = @@ -75,7 +78,11 @@ export const FieldDisplay = ({ ) : isFieldLink(fieldDefinition) ? ( ) : isFieldLinks(fieldDefinition) ? ( - + ) : isFieldCurrency(fieldDefinition) ? ( ) : isFieldFullName(fieldDefinition) ? ( @@ -84,9 +91,9 @@ export const FieldDisplay = ({ ) : isFieldMultiSelect(fieldDefinition) ? ( ) : isFieldAddress(fieldDefinition) ? ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/LinksFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/LinksFieldDisplay.tsx index ac31aac6d..034209dd2 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/LinksFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/LinksFieldDisplay.tsx @@ -1,8 +1,25 @@ import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField'; import { LinksDisplay } from '@/ui/field/display/components/LinksDisplay'; -export const LinksFieldDisplay = () => { +type LinksFieldDisplayProps = { + isCellSoftFocused?: boolean; + cellElement?: HTMLElement; + fromTableCell?: boolean; +}; + +export const LinksFieldDisplay = ({ + isCellSoftFocused, + cellElement, + fromTableCell, +}: LinksFieldDisplayProps) => { const { fieldValue } = useLinksField(); - return ; + return ( + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx index e733f12f2..1dde1eca8 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx @@ -1,15 +1,17 @@ import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField'; import { Tag } from '@/ui/display/tag/components/Tag'; -import { - ExpandableList, - ExpandableListProps, -} from '@/ui/layout/expandable-list/components/ExpandableList'; +import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; + +type MultiSelectFieldDisplayProps = { + isCellSoftFocused?: boolean; + cellElement?: HTMLElement; + fromTableCell?: boolean; +}; -type MultiSelectFieldDisplayProps = ExpandableListProps; export const MultiSelectFieldDisplay = ({ - isHovered, - reference, - withDropDownBorder, + isCellSoftFocused, + cellElement, + fromTableCell, }: MultiSelectFieldDisplayProps) => { const { fieldValues, fieldDefinition } = useMultiSelectField(); @@ -19,11 +21,13 @@ export const MultiSelectFieldDisplay = ({ ) : []; - return selectedOptions ? ( + if (!selectedOptions) return null; + + return ( {selectedOptions.map((selectedOption, index) => ( ))} - ) : ( - <> ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx index 7527c0eba..624e749d3 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx @@ -99,7 +99,12 @@ export const RecordInlineCell = ({ isReadOnly={readonly} /> } - displayModeContent={} + displayModeContent={({ cellElement, isCellSoftFocused }) => ( + + )} isDisplayModeContentEmpty={isFieldEmpty} isDisplayModeFixHeight editModeContentOnly={isFieldInputOnly} diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx index 7400c712d..48c92d8e6 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useRef, useState } from 'react'; +import React, { useContext, useState } from 'react'; import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; import { Tooltip } from 'react-tooltip'; import { css, useTheme } from '@emotion/react'; @@ -7,7 +7,6 @@ import { IconComponent } from 'twenty-ui'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay'; -import { ExpandableListProps } from '@/ui/layout/expandable-list/components/ExpandableList'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; import { useInlineCell } from '../hooks/useInlineCell'; @@ -111,7 +110,13 @@ type RecordInlineCellContainerProps = { buttonIcon?: IconComponent; editModeContent?: React.ReactNode; editModeContentOnly?: boolean; - displayModeContent: React.ReactNode; + displayModeContent: ({ + isCellSoftFocused, + cellElement, + }: { + isCellSoftFocused: boolean; + cellElement?: HTMLDivElement; + }) => React.ReactNode; customEditHotkeyScope?: HotkeyScope; isDisplayModeContentEmpty?: boolean; isDisplayModeFixHeight?: boolean; @@ -136,24 +141,25 @@ export const RecordInlineCellContainer = ({ loading = false, }: RecordInlineCellContainerProps) => { const { entityId, fieldDefinition } = useContext(FieldContext); - const reference = useRef(null); + // Used by some fields in ExpandableList as an anchor for the floating element. + // floating-ui mentions that `useState` must be used instead of `useRef`, + // see https://floating-ui.com/docs/useFloating#elements + const [cellElement, setCellElement] = useState(null); const [isHovered, setIsHovered] = useState(false); - const [isHoveredForDisplayMode, setIsHoveredForDisplayMode] = useState(false); - const [newDisplayModeContent, setNewDisplayModeContent] = - useState(displayModeContent); + const [isCellSoftFocused, setIsCellSoftFocused] = useState(false); const handleContainerMouseEnter = () => { if (!readonly) { setIsHovered(true); } - setIsHoveredForDisplayMode(true); + setIsCellSoftFocused(true); }; const handleContainerMouseLeave = () => { if (!readonly) { setIsHovered(false); } - setIsHoveredForDisplayMode(false); + setIsCellSoftFocused(false); }; const { isInlineCellInEditMode, openInlineCell } = useInlineCell(); @@ -174,17 +180,6 @@ export const RecordInlineCellContainer = ({ const theme = useTheme(); const labelId = `label-${entityId}-${fieldDefinition?.metadata?.fieldName}`; - useEffect(() => { - if (React.isValidElement(displayModeContent)) { - setNewDisplayModeContent( - React.cloneElement(displayModeContent, { - isHovered: isHoveredForDisplayMode, - reference: reference.current || undefined, - }), - ); - } - }, [isHoveredForDisplayMode, displayModeContent, reference]); - const showContent = () => { if (loading) { return ; @@ -215,7 +210,10 @@ export const RecordInlineCellContainer = ({ isHovered={isHovered} emptyPlaceholder={showLabel ? 'Empty' : label} > - {newDisplayModeContent} + {displayModeContent({ + isCellSoftFocused, + cellElement: cellElement ?? undefined, + })} {showEditButton && } @@ -252,7 +250,7 @@ export const RecordInlineCellContainer = ({ )} )} - + {showContent()} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCell.tsx index 9d89372d7..a53f8c722 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCell.tsx @@ -102,7 +102,13 @@ export const RecordTableCell = ({ isReadOnly={isReadOnly} /> } - nonEditModeContent={} + nonEditModeContent={({ isCellSoftFocused, cellElement }) => ( + + )} /> ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.tsx index 17cc3011a..28f30bf9b 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.tsx @@ -1,10 +1,4 @@ -import React, { - ReactElement, - useContext, - useEffect, - useRef, - useState, -} from 'react'; +import React, { ReactElement, useContext, useState } from 'react'; import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; import { IconArrowUpRight } from 'twenty-ui'; @@ -20,7 +14,6 @@ import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/rec import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext'; import { isSoftFocusOnTableCellComponentFamilyState } from '@/object-record/record-table/states/isSoftFocusOnTableCellComponentFamilyState'; import { isTableCellInEditModeComponentFamilyState } from '@/object-record/record-table/states/isTableCellInEditModeComponentFamilyState'; -import { ExpandableListProps } from '@/ui/layout/expandable-list/components/ExpandableList'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId'; @@ -58,7 +51,13 @@ const StyledCellBaseContainer = styled.div<{ softFocus: boolean }>` export type RecordTableCellContainerProps = { editModeContent: ReactElement; - nonEditModeContent: ReactElement; + nonEditModeContent?: ({ + isCellSoftFocused, + cellElement, + }: { + isCellSoftFocused: boolean; + cellElement?: HTMLTableCellElement; + }) => ReactElement; editHotkeyScope?: HotkeyScope; transparent?: boolean; maxContentWidth?: number; @@ -76,10 +75,14 @@ export const RecordTableCellContainer = ({ editHotkeyScope, }: RecordTableCellContainerProps) => { const { columnIndex } = useContext(RecordTableCellContext); - const reference = useRef(null); - const [isHovered, setIsHovered] = useState(false); - const [newNonEditModeContent, setNewNonEditModeContent] = - useState(nonEditModeContent); + // Used by some fields in ExpandableList as an anchor for the floating element. + // floating-ui mentions that `useState` must be used instead of `useRef`, + // see https://floating-ui.com/docs/useFloating#elements + const [cellElement, setCellElement] = useState( + null, + ); + const [isCellBaseContainerHovered, setIsCellBaseContainerHovered] = + useState(false); const { isReadOnly, isSelected, recordId } = useContext( RecordTableRowContext, ); @@ -127,13 +130,13 @@ export const RecordTableCellContainer = ({ const handleContainerMouseEnter = () => { onCellMouseEnter({ cellPosition, - isHovered, - setIsHovered, + isHovered: isCellBaseContainerHovered, + setIsHovered: setIsCellBaseContainerHovered, }); }; const handleContainerMouseLeave = () => { - setIsHovered(false); + setIsCellBaseContainerHovered(false); }; const editModeContentOnly = useIsFieldInputOnly(); @@ -150,20 +153,9 @@ export const RecordTableCellContainer = ({ (!isFirstColumn || !isEmpty) && !isReadOnly; - useEffect(() => { - if (React.isValidElement(nonEditModeContent)) { - setNewNonEditModeContent( - React.cloneElement(nonEditModeContent, { - isHovered: showButton, - reference: reference.current || undefined, - }), - ); - } - }, [nonEditModeContent, showButton, reference]); - return ( - {editModeContentOnly ? editModeContent : newNonEditModeContent} + {editModeContentOnly + ? editModeContent + : nonEditModeContent?.({ + isCellSoftFocused: true, + cellElement: cellElement ?? undefined, + })} {showButton && ( {editModeContentOnly ? editModeContent - : newNonEditModeContent} + : nonEditModeContent?.({ + isCellSoftFocused: false, + cellElement: cellElement ?? undefined, + })} )} {showButton && ( diff --git a/packages/twenty-front/src/modules/ui/display/tag/components/Tag.tsx b/packages/twenty-front/src/modules/ui/display/tag/components/Tag.tsx index 3a06c0234..2df73fd35 100644 --- a/packages/twenty-front/src/modules/ui/display/tag/components/Tag.tsx +++ b/packages/twenty-front/src/modules/ui/display/tag/components/Tag.tsx @@ -1,6 +1,6 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { IconComponent } from 'twenty-ui'; +import { IconComponent, OverflowingTextWithTooltip } from 'twenty-ui'; import { ThemeColor } from '@/ui/theme/constants/MainColorNames'; import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema'; @@ -69,7 +69,9 @@ export const Tag = ({ )} - {text} + + + ); }; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx index c4fadbe05..4ee52c7b0 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx @@ -1,8 +1,10 @@ import { MouseEventHandler, useMemo } from 'react'; -import styled from '@emotion/styled'; import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata'; -import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay'; +import { + ExpandableList, + ExpandableListProps, +} from '@/ui/layout/expandable-list/components/ExpandableList'; import { RoundedLink } from '@/ui/navigation/link/components/RoundedLink'; import { LinkType, @@ -13,16 +15,19 @@ import { isDefined } from '~/utils/isDefined'; import { getAbsoluteUrl } from '~/utils/url/getAbsoluteUrl'; import { getUrlHostName } from '~/utils/url/getUrlHostName'; -const StyledContainer = styled(EllipsisDisplay)` - display: flex; - gap: ${({ theme }) => theme.spacing(1)}; -`; - -type LinksDisplayProps = { +type LinksDisplayProps = Pick< + ExpandableListProps, + 'anchorElement' | 'isChipCountDisplayed' | 'withExpandedListBorder' +> & { value?: FieldLinksValue; }; -export const LinksDisplay = ({ value }: LinksDisplayProps) => { +export const LinksDisplay = ({ + anchorElement, + isChipCountDisplayed, + withExpandedListBorder, + value, +}: LinksDisplayProps) => { const links = useMemo( () => [ @@ -49,7 +54,11 @@ export const LinksDisplay = ({ value }: LinksDisplayProps) => { const handleClick: MouseEventHandler = (event) => event.stopPropagation(); return ( - + {links.map(({ url, label, type }, index) => type === LinkType.LinkedIn || type === LinkType.Twitter ? ( @@ -61,6 +70,6 @@ export const LinksDisplay = ({ value }: LinksDisplayProps) => { ), )} - + ); }; diff --git a/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ChildrenContainer.tsx b/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ChildrenContainer.tsx deleted file mode 100644 index a22cff682..000000000 --- a/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ChildrenContainer.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Dispatch, ReactElement, SetStateAction } from 'react'; -import styled from '@emotion/styled'; - -import { ChildrenProperty } from '@/ui/layout/expandable-list/components/ExpandableList'; - -const StyledChildContainer = styled.div<{ - shrink?: number; - isVisible?: boolean; - displayHiddenCount?: boolean; -}>` - display: ${({ isVisible = true }) => (isVisible ? 'flex' : 'none')}; - flex-shrink: ${({ shrink = 1 }) => shrink}; - overflow: ${({ displayHiddenCount }) => - displayHiddenCount ? 'hidden' : 'none'}; -`; - -const StyledChildrenContainer = styled.div` - align-items: center; - display: flex; - gap: ${({ theme }) => theme.spacing(1)}; - overflow: hidden; -`; -export const ChildrenContainer = ({ - children, - childrenProperties, - setChildrenWidths, - isFocusedMode, -}: { - children: ReactElement[]; - childrenProperties: Record; - setChildrenWidths: Dispatch>>; - isFocusedMode: boolean; -}) => { - return ( - - {children.map((child, index) => { - return ( - { - if (!el || isFocusedMode) return; - setChildrenWidths((prevState) => { - prevState[index] = el.getBoundingClientRect().width; - return prevState; - }); - }} - key={index} - displayHiddenCount={isFocusedMode} - isVisible={childrenProperties[index]?.isVisible} - shrink={childrenProperties[index]?.shrink} - > - {child} - - ); - })} - - ); -}; diff --git a/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandableList.tsx b/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandableList.tsx index 6fe0f6d3d..fbea95c8d 100644 --- a/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandableList.tsx +++ b/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandableList.tsx @@ -1,48 +1,49 @@ -import { ReactElement, useEffect, useState } from 'react'; +import { ReactElement, useCallback, useEffect, useRef, useState } from 'react'; import styled from '@emotion/styled'; -import { offset, useFloating } from '@floating-ui/react'; import { Chip, ChipVariant } from 'twenty-ui'; import { AnimatedContainer } from '@/object-record/record-table/components/AnimatedContainer'; -import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; -import { ChildrenContainer } from '@/ui/layout/expandable-list/components/ChildrenContainer'; -import { getChildrenProperties } from '@/ui/layout/expandable-list/utils/getChildProperties'; -import { getChipContentWidth } from '@/ui/layout/expandable-list/utils/getChipContentWidth'; - -export const GAP_WIDTH = 4; +import { ExpandedListDropdown } from '@/ui/layout/expandable-list/components/ExpandedListDropdown'; +import { isFirstOverflowingChildElement } from '@/ui/layout/expandable-list/utils/isFirstOverflowingChildElement'; +import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { isDefined } from '~/utils/isDefined'; const StyledContainer = styled.div` align-items: center; display: flex; gap: ${({ theme }) => theme.spacing(1)}; justify-content: space-between; + min-width: 100%; width: 100%; `; -const StyledRelationsListContainer = styled.div<{ - withDropDownBorder?: boolean; -}>` - backdrop-filter: ${({ theme }) => theme.blur.strong}; - background-color: ${({ theme }) => theme.background.secondary}; - border-radius: ${({ theme }) => theme.border.radius.sm}; - box-shadow: '0px 2px 4px ${({ theme }) => - theme.boxShadow.light}, 2px 4px 16px ${({ theme }) => - theme.boxShadow.strong}'; +const StyledChildrenContainer = styled.div` display: flex; - flex-wrap: wrap; gap: ${({ theme }) => theme.spacing(1)}; - padding: ${({ theme }) => theme.spacing(2)}; - outline: ${(props) => - props.withDropDownBorder - ? `1px solid ${props.theme.font.color.extraLight}` - : 'none'}; + overflow: hidden; + max-width: 100%; + flex: 0 1 fit-content; + position: relative; // Needed so children elements compute their offsetLeft relatively to this element. +`; + +const StyledChildContainer = styled.div` + display: flex; + flex-shrink: 0; + overflow: hidden; + + &:last-child { + flex-shrink: 1; + } +`; + +const StyledChipCount = styled(Chip)` + flex-shrink: 0; `; export type ExpandableListProps = { - isHovered?: boolean; - reference?: HTMLDivElement; - forceDisplayHiddenCount?: boolean; - withDropDownBorder?: boolean; + anchorElement?: HTMLElement; + isChipCountDisplayed?: boolean; + withExpandedListBorder?: boolean; }; export type ChildrenProperty = { @@ -52,95 +53,127 @@ export type ChildrenProperty = { export const ExpandableList = ({ children, - isHovered, - reference, - forceDisplayHiddenCount = false, - withDropDownBorder = false, + anchorElement, + isChipCountDisplayed: isChipCountDisplayedFromProps, + withExpandedListBorder = false, }: { children: ReactElement[]; } & ExpandableListProps) => { - const [containerWidth, setContainerWidth] = useState(0); - const [isDropdownMenuOpen, setIsDropdownMenuOpen] = useState(false); - const [childrenWidths, setChildrenWidths] = useState>( - {}, + // isChipCountDisplayedInternal => uncontrolled display of the chip count. + // isChipCountDisplayedFromProps => controlled display of the chip count. + // If isChipCountDisplayedFromProps is provided, isChipCountDisplayedInternal is not taken into account. + const [isChipCountDisplayedInternal, setIsChipCountDisplayedInternal] = + useState(false); + const isChipCountDisplayed = isDefined(isChipCountDisplayedFromProps) + ? isChipCountDisplayedFromProps + : isChipCountDisplayedInternal; + + const [isListExpanded, setIsListExpanded] = useState(false); + + // Used with floating-ui if anchorElement is not provided. + // floating-ui mentions that `useState` must be used instead of `useRef` + // @see https://floating-ui.com/docs/useFloating#elements + const [childrenContainerElement, setChildrenContainerElement] = + useState(null); + const [previousChildrenContainerWidth, setPreviousChildrenContainerWidth] = + useState(childrenContainerElement?.clientWidth ?? 0); + + // Used with useListenClickOutside. + const containerRef = useRef(null); + + const [firstHiddenChildIndex, setFirstHiddenChildIndex] = useState( + children.length, ); - // Because Chip width depends on the number of hidden children which depends on the Chip width, we have a circular dependency - // To avoid it, we set the Chip width and make sure it can display its content (a number greater than 1) - const chipContentWidth = getChipContentWidth(children.length); - const chipContainerWidth = chipContentWidth + 2 * GAP_WIDTH; // Because Chip component has 4px padding-left and right - const availableWidth = containerWidth - (chipContainerWidth + GAP_WIDTH); // Because there is a 4px gap between ChildrenContainer and ChipContainer - const isFocusedMode = - (isHovered || forceDisplayHiddenCount) && - Object.values(childrenWidths).length > 0; + const hiddenChildrenCount = children.length - firstHiddenChildIndex; + const canDisplayChipCount = isChipCountDisplayed && hiddenChildrenCount > 0; - const childrenProperties = getChildrenProperties( - isFocusedMode, - availableWidth, - childrenWidths, - ); - - const hiddenChildrenCount = Object.values(childrenProperties).filter( - (childProperties) => !childProperties.isVisible, - ).length; - - const displayHiddenCountChip = isFocusedMode && hiddenChildrenCount > 0; - - const { refs, floatingStyles } = useFloating({ - // @ts-expect-error placement accepts 'start' as value even if the typing does not permit it - placement: 'start', - middleware: [offset({ mainAxis: -1, crossAxis: -1 })], - elements: { reference }, - }); - - const openDropdownMenu = (event: React.MouseEvent) => { + const handleChipCountClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); - setIsDropdownMenuOpen(true); - }; + setIsListExpanded(true); + }, []); + const resetFirstHiddenChildIndex = useCallback(() => { + setFirstHiddenChildIndex(children.length); + }, [children.length]); + + // Recompute first hidden child when: + // - isChipCountDisplayed changes + // - children length changes useEffect(() => { - if (!isHovered) { - setIsDropdownMenuOpen(false); - } - }, [isHovered]); + resetFirstHiddenChildIndex(); + }, [isChipCountDisplayed, children.length, resetFirstHiddenChildIndex]); + + useListenClickOutside({ + refs: [containerRef], + callback: () => { + // Handle container resize + if ( + childrenContainerElement?.clientWidth !== previousChildrenContainerWidth + ) { + resetFirstHiddenChildIndex(); + setPreviousChildrenContainerWidth( + childrenContainerElement?.clientWidth ?? 0, + ); + } + }, + }); return ( { - if (!el) return; - setContainerWidth(el.getBoundingClientRect().width); - }} + ref={containerRef} + onMouseEnter={ + isChipCountDisplayedFromProps + ? undefined + : () => setIsChipCountDisplayedInternal(true) + } + onMouseLeave={ + isChipCountDisplayedFromProps + ? undefined + : () => setIsChipCountDisplayedInternal(false) + } > - - {children} - - {displayHiddenCountChip && ( + + {children.slice(0, firstHiddenChildIndex).map((child, index) => ( + { + if ( + // First element is always displayed. + index > 0 && + isFirstOverflowingChildElement({ + containerElement: childrenContainerElement, + childElement, + }) + ) { + setFirstHiddenChildIndex(index); + } + }} + > + {child} + + ))} + + {canDisplayChipCount && ( - )} - {isDropdownMenuOpen && ( - { + resetFirstHiddenChildIndex(); + setIsListExpanded(false); + }} + withBorder={withExpandedListBorder} > - - {children} - - + {children} + )} ); diff --git a/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandedListDropdown.tsx b/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandedListDropdown.tsx new file mode 100644 index 000000000..d5b93758e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/expandable-list/components/ExpandedListDropdown.tsx @@ -0,0 +1,69 @@ +import { ReactNode } from 'react'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { offset, useFloating } from '@floating-ui/react'; + +import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; +import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; + +type ExpandedListDropdownProps = { + anchorElement?: HTMLElement; + children: ReactNode; + onClickOutside?: () => void; + withBorder?: boolean; +}; + +const StyledExpandedListContainer = styled.div<{ + withBorder?: boolean; +}>` + backdrop-filter: ${({ theme }) => theme.blur.strong}; + background-color: ${({ theme }) => theme.background.secondary}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + box-shadow: ${({ theme }) => + `0px 2px 4px ${theme.boxShadow.light}, 2px 4px 16px ${theme.boxShadow.strong}`}; + display: flex; + flex-wrap: wrap; + gap: ${({ theme }) => theme.spacing(1)}; + padding: ${({ theme }) => theme.spacing(2)}; + + ${({ theme, withBorder }) => + withBorder && + css` + outline: 1px solid ${theme.font.color.extraLight}; + `}; +`; + +export const ExpandedListDropdown = ({ + anchorElement, + children, + onClickOutside, + withBorder, +}: ExpandedListDropdownProps) => { + const { refs, floatingStyles } = useFloating({ + // @ts-expect-error placement accepts 'start' as value even if the typing does not permit it + placement: 'start', + middleware: [offset({ mainAxis: -1, crossAxis: -1 })], + elements: { reference: anchorElement }, + }); + + useListenClickOutside({ + refs: [refs.floating], + callback: onClickOutside ?? (() => {}), + }); + + return ( + + + {children} + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/expandable-list/components/__stories__/ExpandableList.stories.tsx b/packages/twenty-front/src/modules/ui/layout/expandable-list/components/__stories__/ExpandableList.stories.tsx index 7cf3bcb38..9294f2625 100644 --- a/packages/twenty-front/src/modules/ui/layout/expandable-list/components/__stories__/ExpandableList.stories.tsx +++ b/packages/twenty-front/src/modules/ui/layout/expandable-list/components/__stories__/ExpandableList.stories.tsx @@ -1,13 +1,11 @@ -import { ReactElement, useRef, useState } from 'react'; import styled from '@emotion/styled'; +import { expect } from '@storybook/jest'; import { Meta, StoryObj } from '@storybook/react'; +import { userEvent, within } from '@storybook/test'; import { ComponentDecorator } from 'packages/twenty-ui'; import { Tag } from '@/ui/display/tag/components/Tag'; -import { - ExpandableList, - ExpandableListProps, -} from '@/ui/layout/expandable-list/components/ExpandableList'; +import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; import { MAIN_COLOR_NAMES } from '@/ui/theme/constants/MainColorNames'; const StyledContainer = styled.div` @@ -15,60 +13,55 @@ const StyledContainer = styled.div` width: 300px; `; -type RenderProps = ExpandableListProps & { - children: ReactElement[]; -}; - -const Render = (args: RenderProps) => { - const [isHovered, setIsHovered] = useState(false); - const reference = useRef(null); - - return ( - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - - {args.children} - - - ); -}; - const meta: Meta = { title: 'UI/Layout/ExpandableList/ExpandableList', component: ExpandableList, - decorators: [ComponentDecorator], + decorators: [ + (Story) => ( + + + + ), + ComponentDecorator, + ], args: { - children: [ - , - , - , - , - , - , - , - ], - isHovered: undefined, - reference: undefined, - forceDisplayHiddenCount: false, - withDropDownBorder: false, + children: Array.from({ length: 7 }, (_, index) => ( + + )), + isChipCountDisplayed: false, }, argTypes: { children: { control: false }, - isHovered: { control: false }, - reference: { control: false }, + anchorElement: { control: false }, }, - render: Render, }; export default meta; type Story = StoryObj; export const Default: Story = {}; + +export const WithChipCount: Story = { + args: { isChipCountDisplayed: true }, +}; + +export const WithExpandedList: Story = { + ...WithChipCount, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const chipCount = await canvas.findByText('+3'); + + await userEvent.click(chipCount); + + expect(await canvas.findByText('Option 7')).toBeDefined(); + }, +}; + +export const WithExpandedListBorder: Story = { + ...WithExpandedList, + args: { ...WithExpandedList.args, withExpandedListBorder: true }, +}; diff --git a/packages/twenty-front/src/modules/ui/layout/expandable-list/utils/__tests__/getChildProperties.test.ts b/packages/twenty-front/src/modules/ui/layout/expandable-list/utils/__tests__/getChildProperties.test.ts deleted file mode 100644 index 56e75c67d..000000000 --- a/packages/twenty-front/src/modules/ui/layout/expandable-list/utils/__tests__/getChildProperties.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { getChildrenProperties } from '@/ui/layout/expandable-list/utils/getChildProperties'; - -describe('getChildrenProperties', () => { - it('should return default value when isFocused is False', () => { - const isFocused = false; - const availableWidth = 100; - expect(getChildrenProperties(isFocused, availableWidth, {})).toEqual({}); - - expect( - getChildrenProperties(isFocused, availableWidth, { 0: 40, 1: 40 }), - ).toEqual({}); - }); - - it('should return proper value when isFocused is True', () => { - const isFocused = true; - const availableWidth = 100; - expect(getChildrenProperties(isFocused, availableWidth, {})).toEqual({}); - - expect( - getChildrenProperties(isFocused, availableWidth, { 0: 40, 1: 40 }), - ).toEqual({ - 0: { shrink: 0, isVisible: true }, - 1: { shrink: 0, isVisible: true }, - }); - expect( - getChildrenProperties(isFocused, availableWidth, { - 0: 40, - 1: 40, - 2: 40, - 3: 40, - 4: 40, - }), - ).toEqual({ - 0: { shrink: 0, isVisible: true }, - 1: { shrink: 0, isVisible: true }, - 2: { shrink: 1, isVisible: true }, - 3: { shrink: 1, isVisible: false }, - 4: { shrink: 1, isVisible: false }, - }); - }); -}); diff --git a/packages/twenty-front/src/modules/ui/layout/expandable-list/utils/__tests__/getChipContentWidth.test.ts b/packages/twenty-front/src/modules/ui/layout/expandable-list/utils/__tests__/getChipContentWidth.test.ts deleted file mode 100644 index 613452107..000000000 --- a/packages/twenty-front/src/modules/ui/layout/expandable-list/utils/__tests__/getChipContentWidth.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { getChipContentWidth } from '@/ui/layout/expandable-list/utils/getChipContentWidth'; - -describe('getChipContentWidth', () => { - it('should return proper value', () => { - expect(getChipContentWidth(0)).toEqual(0); - expect(getChipContentWidth(1)).toEqual(0); - expect(getChipContentWidth(2)).toEqual(17); - expect(getChipContentWidth(20)).toEqual(25); - expect(getChipContentWidth(200)).toEqual(33); - expect(getChipContentWidth(2000)).toEqual(41); - expect(getChipContentWidth(20000)).toEqual(49); - }); -}); diff --git a/packages/twenty-front/src/modules/ui/layout/expandable-list/utils/getChildProperties.ts b/packages/twenty-front/src/modules/ui/layout/expandable-list/utils/getChildProperties.ts deleted file mode 100644 index 1528bedfc..000000000 --- a/packages/twenty-front/src/modules/ui/layout/expandable-list/utils/getChildProperties.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - ChildrenProperty, - GAP_WIDTH, -} from '@/ui/layout/expandable-list/components/ExpandableList'; - -export const getChildrenProperties = ( - isFocusedMode: boolean, - availableWidth: number, - childrenWidths: Record, -) => { - if (!isFocusedMode) { - return {}; - } - let cumulatedChildrenWidth = 0; - const result: Record = {}; - Object.values(childrenWidths).forEach((width, index) => { - // Because there is a 4px gap between children - const childWidth = width + GAP_WIDTH; - let shrink = 1; - let isVisible = true; - if (cumulatedChildrenWidth > availableWidth) { - isVisible = false; - } else if (cumulatedChildrenWidth + childWidth <= availableWidth) { - shrink = 0; - } - result[index] = { shrink, isVisible }; - cumulatedChildrenWidth += childWidth; - }); - return result; -}; diff --git a/packages/twenty-front/src/modules/ui/layout/expandable-list/utils/getChipContentWidth.ts b/packages/twenty-front/src/modules/ui/layout/expandable-list/utils/getChipContentWidth.ts deleted file mode 100644 index dcf50862f..000000000 --- a/packages/twenty-front/src/modules/ui/layout/expandable-list/utils/getChipContentWidth.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const getChipContentWidth = (numberOfChildren: number) => { - if (numberOfChildren <= 1) { - return 0; - } - return 17 + 8 * Math.trunc(Math.log10(numberOfChildren)); -}; diff --git a/packages/twenty-front/src/modules/ui/layout/expandable-list/utils/isFirstOverflowingChildElement.ts b/packages/twenty-front/src/modules/ui/layout/expandable-list/utils/isFirstOverflowingChildElement.ts new file mode 100644 index 000000000..6be49b7a8 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/expandable-list/utils/isFirstOverflowingChildElement.ts @@ -0,0 +1,17 @@ +import { isDefined } from '~/utils/isDefined'; + +export const isFirstOverflowingChildElement = ({ + containerElement, + childElement, +}: { + containerElement: HTMLElement | null; + childElement: HTMLElement | null; +}) => + isDefined(containerElement) && + isDefined(childElement) && + // First element is always displayed. + isDefined(childElement.previousElementSibling) && + containerElement.scrollWidth > containerElement.clientWidth && + childElement.offsetLeft > containerElement.clientWidth && + (childElement.previousElementSibling as HTMLElement).offsetLeft < + containerElement.clientWidth; diff --git a/packages/twenty-front/src/modules/ui/navigation/link/components/RoundedLink.tsx b/packages/twenty-front/src/modules/ui/navigation/link/components/RoundedLink.tsx index aced9c080..e54f9ebdb 100644 --- a/packages/twenty-front/src/modules/ui/navigation/link/components/RoundedLink.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/link/components/RoundedLink.tsx @@ -10,22 +10,15 @@ type RoundedLinkProps = { onClick?: (event: React.MouseEvent) => void; }; -const StyledClickable = styled.div` - overflow: hidden; - white-space: nowrap; - - a { - color: inherit; - overflow: hidden; - text-decoration: none; - text-overflow: ellipsis; - } +const StyledLink = styled(ReactLink)` + max-width: 100%; `; const StyledChip = styled(Chip)` border-color: ${({ theme }) => theme.border.color.strong}; box-sizing: border-box; padding: ${({ theme }) => theme.spacing(2)}; + max-width: 100%; `; export const RoundedLink = ({ @@ -33,20 +26,21 @@ export const RoundedLink = ({ className, href, onClick, -}: RoundedLinkProps) => ( -
- {children !== '' ? ( - - - - - - ) : ( - <> - )} -
-); +}: RoundedLinkProps) => { + if (!children) return null; + + return ( + + + + ); +}; diff --git a/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx b/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx index f8ee4ebcd..71d0dbaea 100644 --- a/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx +++ b/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; import { createPortal } from 'react-dom'; import styled from '@emotion/styled'; import { v4 as uuidV4 } from 'uuid'; @@ -29,22 +29,13 @@ export const OverflowingTextWithTooltip = ({ mutliline?: boolean; }) => { const textElementId = `title-id-${uuidV4()}`; + const [textElement, setTextElement] = useState(null); - const textRef = useRef(null); - - const [isTitleOverflowing, setIsTitleOverflowing] = useState(false); - - useEffect(() => { - const isOverflowing = - (text?.length ?? 0) > 0 && textRef.current - ? textRef.current?.scrollHeight > textRef.current?.clientHeight || - textRef.current.scrollWidth > textRef.current.clientWidth - : false; - - if (isTitleOverflowing !== isOverflowing) { - setIsTitleOverflowing(isOverflowing); - } - }, [isTitleOverflowing, text]); + const isTitleOverflowing = + (text?.length ?? 0) > 0 && + !!textElement && + (textElement.scrollHeight > textElement.clientHeight || + textElement.scrollWidth > textElement.clientWidth); const handleTooltipClick = (event: React.MouseEvent) => { event.stopPropagation(); @@ -56,7 +47,7 @@ export const OverflowingTextWithTooltip = ({