feat: display Links field as Expandable List (#5374)

Closes #5114
This commit is contained in:
Thaïs
2024-05-15 15:52:23 +02:00
committed by GitHub
parent 38eb293b3c
commit 602d5422a2
23 changed files with 440 additions and 454 deletions

View File

@ -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<HTMLDivElement>(null);
const [isHovered, setIsHovered] = useState(false);
const styledChips = orderedParticipants.map((participant, index) => (
<ParticipantChip key={index} participant={participant} />
));
@ -103,16 +102,11 @@ export const CalendarEventParticipantsResponseStatusField = ({
<EllipsisDisplay>{responseStatus}</EllipsisDisplay>
</StyledLabelContainer>
</StyledLabelAndIconContainer>
<StyledDiv
ref={participantsContainerRef}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<StyledDiv ref={participantsContainerRef}>
{isRightDrawerAnimationCompleted && (
<ExpandableList
isHovered={isHovered}
reference={participantsContainerRef.current || undefined}
forceDisplayHiddenCount
anchorElement={participantsContainerRef.current || undefined}
isChipCountDisplayed
>
{styledChips}
</ExpandableList>

View File

@ -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 (
<StyledContainer maxWidth={maxWidth}>
<ExpandableList
isHovered={isHovered}
reference={reference}
forceDisplayHiddenCount
>
<ExpandableList anchorElement={anchorElement} isChipCountDisplayed>
{activityTargetObjectRecords.map(
(activityTargetObjectRecord, index) => (
<RecordChip

View File

@ -53,12 +53,13 @@ export const ActivityTargetsInlineCell = ({
/>
}
label="Relations"
displayModeContent={
displayModeContent={({ cellElement }) => (
<ActivityTargetChips
anchorElement={cellElement}
activityTargetObjectRecords={activityTargetObjectRecords}
maxWidth={maxWidth}
/>
}
)}
isDisplayModeContentEmpty={activityTargetObjectRecords.length === 0}
/>
</RecordFieldInputScope>

View File

@ -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) ? (
<LinkFieldDisplay />
) : isFieldLinks(fieldDefinition) ? (
<LinksFieldDisplay />
<LinksFieldDisplay
isCellSoftFocused={isCellSoftFocused}
cellElement={cellElement}
fromTableCell={fromTableCell}
/>
) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldDisplay />
) : isFieldFullName(fieldDefinition) ? (
@ -84,9 +91,9 @@ export const FieldDisplay = ({
<SelectFieldDisplay />
) : isFieldMultiSelect(fieldDefinition) ? (
<MultiSelectFieldDisplay
isHovered={isHovered}
reference={reference}
withDropDownBorder={fromTableCell}
isCellSoftFocused={isCellSoftFocused}
cellElement={cellElement}
fromTableCell={fromTableCell}
/>
) : isFieldAddress(fieldDefinition) ? (
<AddressFieldDisplay />

View File

@ -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 <LinksDisplay value={fieldValue} />;
return (
<LinksDisplay
value={fieldValue}
anchorElement={cellElement}
isChipCountDisplayed={isCellSoftFocused}
withExpandedListBorder={fromTableCell}
/>
);
};

View File

@ -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 (
<ExpandableList
isHovered={isHovered}
reference={reference}
withDropDownBorder={withDropDownBorder}
anchorElement={cellElement}
isChipCountDisplayed={isCellSoftFocused}
withExpandedListBorder={fromTableCell}
>
{selectedOptions.map((selectedOption, index) => (
<Tag
@ -33,7 +37,5 @@ export const MultiSelectFieldDisplay = ({
/>
))}
</ExpandableList>
) : (
<></>
);
};

View File

@ -99,7 +99,12 @@ export const RecordInlineCell = ({
isReadOnly={readonly}
/>
}
displayModeContent={<FieldDisplay />}
displayModeContent={({ cellElement, isCellSoftFocused }) => (
<FieldDisplay
cellElement={cellElement}
isCellSoftFocused={isCellSoftFocused}
/>
)}
isDisplayModeContentEmpty={isFieldEmpty}
isDisplayModeFixHeight
editModeContentOnly={isFieldInputOnly}

View File

@ -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<HTMLDivElement>(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<HTMLDivElement | null>(null);
const [isHovered, setIsHovered] = useState(false);
const [isHoveredForDisplayMode, setIsHoveredForDisplayMode] = useState(false);
const [newDisplayModeContent, setNewDisplayModeContent] =
useState<React.ReactNode>(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<ExpandableListProps>(displayModeContent)) {
setNewDisplayModeContent(
React.cloneElement(displayModeContent, {
isHovered: isHoveredForDisplayMode,
reference: reference.current || undefined,
}),
);
}
}, [isHoveredForDisplayMode, displayModeContent, reference]);
const showContent = () => {
if (loading) {
return <StyledInlineCellSkeletonLoader />;
@ -215,7 +210,10 @@ export const RecordInlineCellContainer = ({
isHovered={isHovered}
emptyPlaceholder={showLabel ? 'Empty' : label}
>
{newDisplayModeContent}
{displayModeContent({
isCellSoftFocused,
cellElement: cellElement ?? undefined,
})}
</RecordInlineCellDisplayMode>
{showEditButton && <RecordInlineCellButton Icon={buttonIcon} />}
</StyledClickableContainer>
@ -252,7 +250,7 @@ export const RecordInlineCellContainer = ({
)}
</StyledLabelAndIconContainer>
)}
<StyledValueContainer ref={reference}>
<StyledValueContainer ref={setCellElement}>
{showContent()}
</StyledValueContainer>
</StyledInlineCellBaseContainer>

View File

@ -102,7 +102,13 @@ export const RecordTableCell = ({
isReadOnly={isReadOnly}
/>
}
nonEditModeContent={<FieldDisplay fromTableCell />}
nonEditModeContent={({ isCellSoftFocused, cellElement }) => (
<FieldDisplay
isCellSoftFocused={isCellSoftFocused}
cellElement={cellElement}
fromTableCell
/>
)}
/>
);
};

View File

@ -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<HTMLTableCellElement>(null);
const [isHovered, setIsHovered] = useState(false);
const [newNonEditModeContent, setNewNonEditModeContent] =
useState<ReactElement>(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<HTMLTableCellElement | null>(
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<ExpandableListProps>(nonEditModeContent)) {
setNewNonEditModeContent(
React.cloneElement(nonEditModeContent, {
isHovered: showButton,
reference: reference.current || undefined,
}),
);
}
}, [nonEditModeContent, showButton, reference]);
return (
<StyledTd
ref={reference}
ref={setCellElement}
isSelected={isSelected}
onContextMenu={handleContextMenu}
isInEditMode={isCurrentTableCellInEditMode}
@ -181,7 +173,12 @@ export const RecordTableCellContainer = ({
) : hasSoftFocus ? (
<>
<RecordTableCellSoftFocusMode>
{editModeContentOnly ? editModeContent : newNonEditModeContent}
{editModeContentOnly
? editModeContent
: nonEditModeContent?.({
isCellSoftFocused: true,
cellElement: cellElement ?? undefined,
})}
</RecordTableCellSoftFocusMode>
{showButton && (
<RecordTableCellButton
@ -196,7 +193,10 @@ export const RecordTableCellContainer = ({
<RecordTableCellDisplayMode>
{editModeContentOnly
? editModeContent
: newNonEditModeContent}
: nonEditModeContent?.({
isCellSoftFocused: false,
cellElement: cellElement ?? undefined,
})}
</RecordTableCellDisplayMode>
)}
{showButton && (

View File

@ -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 = ({
<Icon size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
</StyledIconContainer>
)}
<StyledContent>{text}</StyledContent>
<StyledContent>
<OverflowingTextWithTooltip text={text} />
</StyledContent>
</StyledTag>
);
};

View File

@ -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 (
<StyledContainer>
<ExpandableList
anchorElement={anchorElement}
isChipCountDisplayed={isChipCountDisplayed}
withExpandedListBorder={withExpandedListBorder}
>
{links.map(({ url, label, type }, index) =>
type === LinkType.LinkedIn || type === LinkType.Twitter ? (
<SocialLink key={index} href={url} onClick={handleClick} type={type}>
@ -61,6 +70,6 @@ export const LinksDisplay = ({ value }: LinksDisplayProps) => {
</RoundedLink>
),
)}
</StyledContainer>
</ExpandableList>
);
};

View File

@ -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<number, ChildrenProperty>;
setChildrenWidths: Dispatch<SetStateAction<Record<number, number>>>;
isFocusedMode: boolean;
}) => {
return (
<StyledChildrenContainer>
{children.map((child, index) => {
return (
<StyledChildContainer
ref={(el) => {
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}
</StyledChildContainer>
);
})}
</StyledChildrenContainer>
);
};

View File

@ -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<Record<number, number>>(
{},
// 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<HTMLDivElement | null>(null);
const [previousChildrenContainerWidth, setPreviousChildrenContainerWidth] =
useState(childrenContainerElement?.clientWidth ?? 0);
// Used with useListenClickOutside.
const containerRef = useRef<HTMLDivElement>(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 (
<StyledContainer
ref={(el) => {
if (!el) return;
setContainerWidth(el.getBoundingClientRect().width);
}}
ref={containerRef}
onMouseEnter={
isChipCountDisplayedFromProps
? undefined
: () => setIsChipCountDisplayedInternal(true)
}
onMouseLeave={
isChipCountDisplayedFromProps
? undefined
: () => setIsChipCountDisplayedInternal(false)
}
>
<ChildrenContainer
childrenProperties={childrenProperties}
setChildrenWidths={setChildrenWidths}
isFocusedMode={isFocusedMode}
>
{children}
</ChildrenContainer>
{displayHiddenCountChip && (
<StyledChildrenContainer ref={setChildrenContainerElement}>
{children.slice(0, firstHiddenChildIndex).map((child, index) => (
<StyledChildContainer
key={index}
ref={(childElement) => {
if (
// First element is always displayed.
index > 0 &&
isFirstOverflowingChildElement({
containerElement: childrenContainerElement,
childElement,
})
) {
setFirstHiddenChildIndex(index);
}
}}
>
{child}
</StyledChildContainer>
))}
</StyledChildrenContainer>
{canDisplayChipCount && (
<AnimatedContainer>
<Chip
<StyledChipCount
label={`+${hiddenChildrenCount}`}
variant={ChipVariant.Highlighted}
onClick={openDropdownMenu}
onClick={handleChipCountClick}
/>
</AnimatedContainer>
)}
{isDropdownMenuOpen && (
<DropdownMenu
ref={refs.setFloating}
style={floatingStyles}
width={
reference
? Math.max(220, reference.getBoundingClientRect().width)
: undefined
}
{isListExpanded && (
<ExpandedListDropdown
anchorElement={anchorElement ?? childrenContainerElement ?? undefined}
onClickOutside={() => {
resetFirstHiddenChildIndex();
setIsListExpanded(false);
}}
withBorder={withExpandedListBorder}
>
<StyledRelationsListContainer withDropDownBorder={withDropDownBorder}>
{children}
</StyledRelationsListContainer>
</DropdownMenu>
{children}
</ExpandedListDropdown>
)}
</StyledContainer>
);

View File

@ -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 (
<DropdownMenu
ref={refs.setFloating}
style={floatingStyles}
width={
anchorElement
? Math.max(220, anchorElement.getBoundingClientRect().width)
: undefined
}
>
<StyledExpandedListContainer withBorder={withBorder}>
{children}
</StyledExpandedListContainer>
</DropdownMenu>
);
};

View File

@ -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<HTMLDivElement>(null);
return (
<StyledContainer
ref={reference}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<ExpandableList
reference={reference.current || undefined}
forceDisplayHiddenCount={args.forceDisplayHiddenCount}
withDropDownBorder={args.withDropDownBorder}
isHovered={isHovered}
>
{args.children}
</ExpandableList>
</StyledContainer>
);
};
const meta: Meta<typeof ExpandableList> = {
title: 'UI/Layout/ExpandableList/ExpandableList',
component: ExpandableList,
decorators: [ComponentDecorator],
decorators: [
(Story) => (
<StyledContainer>
<Story />
</StyledContainer>
),
ComponentDecorator,
],
args: {
children: [
<Tag key={1} text={'Option 1'} color={MAIN_COLOR_NAMES[0]} />,
<Tag key={2} text={'Option 2'} color={MAIN_COLOR_NAMES[1]} />,
<Tag key={3} text={'Option 3'} color={MAIN_COLOR_NAMES[2]} />,
<Tag key={4} text={'Option 4'} color={MAIN_COLOR_NAMES[3]} />,
<Tag key={5} text={'Option 5'} color={MAIN_COLOR_NAMES[4]} />,
<Tag key={6} text={'Option 6'} color={MAIN_COLOR_NAMES[5]} />,
<Tag key={7} text={'Option 7'} color={MAIN_COLOR_NAMES[6]} />,
],
isHovered: undefined,
reference: undefined,
forceDisplayHiddenCount: false,
withDropDownBorder: false,
children: Array.from({ length: 7 }, (_, index) => (
<Tag
key={index}
text={`Option ${index + 1}`}
color={MAIN_COLOR_NAMES[index]}
/>
)),
isChipCountDisplayed: false,
},
argTypes: {
children: { control: false },
isHovered: { control: false },
reference: { control: false },
anchorElement: { control: false },
},
render: Render,
};
export default meta;
type Story = StoryObj<typeof ExpandableList>;
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 },
};

View File

@ -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 },
});
});
});

View File

@ -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);
});
});

View File

@ -1,30 +0,0 @@
import {
ChildrenProperty,
GAP_WIDTH,
} from '@/ui/layout/expandable-list/components/ExpandableList';
export const getChildrenProperties = (
isFocusedMode: boolean,
availableWidth: number,
childrenWidths: Record<number, number>,
) => {
if (!isFocusedMode) {
return {};
}
let cumulatedChildrenWidth = 0;
const result: Record<number, ChildrenProperty> = {};
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;
};

View File

@ -1,6 +0,0 @@
export const getChipContentWidth = (numberOfChildren: number) => {
if (numberOfChildren <= 1) {
return 0;
}
return 17 + 8 * Math.trunc(Math.log10(numberOfChildren));
};

View File

@ -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;

View File

@ -10,22 +10,15 @@ type RoundedLinkProps = {
onClick?: (event: React.MouseEvent<HTMLElement>) => 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) => (
<div>
{children !== '' ? (
<StyledClickable className={className}>
<ReactLink target="_blank" to={href} onClick={onClick}>
<StyledChip
label={`${children}`}
variant={ChipVariant.Rounded}
size={ChipSize.Small}
/>
</ReactLink>
</StyledClickable>
) : (
<></>
)}
</div>
);
}: RoundedLinkProps) => {
if (!children) return null;
return (
<StyledLink
className={className}
target="_blank"
to={href}
onClick={onClick}
>
<StyledChip
label={`${children}`}
variant={ChipVariant.Rounded}
size={ChipSize.Small}
/>
</StyledLink>
);
};

View File

@ -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<HTMLDivElement | null>(null);
const textRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
event.stopPropagation();
@ -56,7 +47,7 @@ export const OverflowingTextWithTooltip = ({
<StyledOverflowingText
data-testid="tooltip"
className={className}
ref={textRef}
ref={setTextElement}
id={textElementId}
cursorPointer={isTitleOverflowing}
>