[BUGFIX] Account owner should not be clickable & [Refactor] Chip.tsx links (#10359)

# Introduction

closes #10196 
Initially fixing the `Account Owner` record field value should not be
clickable and redirects on current page bug.
This has been fixed computing whereas the current filed is a workspace
member dynamically rendering a stale Chip components instead of an
interactive one

## Refactor
Refactored the `AvatarChip` `to` props logic to be scoped to lower level
scope `Chip`.
Now we have `LinkChip` `Chip`, `LinkAvatarChip` and `AvatarChip` all
exported from twenty-ui.

The caller has to determine which one to call from the design system

## New rule regarding chip links
As discussed with @charlesBochet and @FelixMalfait 
A chip link will now ***always*** have `to` defined. ( and optionally an
`onClick` ).
`ChipLinks` cannot be used as buttons anymore

## Factorization
Deleted the `RecordIndexRecordChip.tsx` file ( aka
`RecordIdentifierChip` component ) that was duplicating some logic,
refactored the `RecordChip` in order to handle what was covered by
`RecordIdentifierChip`

## Conclusion
As always any suggestions are more than welcomed ! Took few opinionated
decision/refactor regarding nested long ternaries rendering `ReactNode`
elements

## Misc


https://github.com/user-attachments/assets/8ef11fb2-7ba6-4e96-bd59-b0be5a425156

---------

Co-authored-by: Mohammed Razak <mohammedrazak2001@gmail.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Paul Rastoin
2025-02-25 15:36:17 +01:00
committed by GitHub
parent fc0e98b53e
commit 89e11b4626
24 changed files with 387 additions and 302 deletions

View File

@ -125,7 +125,7 @@ export const CalendarEventDetails = ({
size={ChipSize.Large} size={ChipSize.Large}
variant={ChipVariant.Highlighted} variant={ChipVariant.Highlighted}
clickable={false} clickable={false}
leftComponent={<IconCalendarEvent size={theme.icon.size.md} />} leftComponent={() => <IconCalendarEvent size={theme.icon.size.md} />}
label="Event" label="Event"
/> />
<StyledHeader> <StyledHeader>

View File

@ -51,16 +51,20 @@ export const MessageThreadSubscribersChip = ({
<Chip <Chip
label={label} label={label}
variant={ChipVariant.Highlighted} variant={ChipVariant.Highlighted}
leftComponent={ leftComponent={() => {
isOnlyOneSubscriber ? ( if (isOnlyOneSubscriber) {
<Avatar return (
avatarUrl={firstAvatarUrl} <Avatar
placeholderColorSeed={firstAvatarColorSeed} avatarUrl={firstAvatarUrl}
placeholder={firstAvatarPlaceholder} placeholderColorSeed={firstAvatarColorSeed}
size="md" placeholder={firstAvatarPlaceholder}
type={'rounded'} size="md"
/> type={'rounded'}
) : ( />
);
}
return (
<AvatarGroup <AvatarGroup
avatars={subscriberNames.map((name, index) => ( avatars={subscriberNames.map((name, index) => (
<Avatar <Avatar
@ -71,9 +75,9 @@ export const MessageThreadSubscribersChip = ({
/> />
))} ))}
/> />
) );
} }}
rightComponent={<IconChevronDown size={theme.icon.size.sm} />} rightComponent={() => <IconChevronDown size={theme.icon.size.sm} />}
clickable clickable
/> />
); );

View File

@ -16,7 +16,11 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
import { IconCalendar, OverflowingTextWithTooltip } from 'twenty-ui'; import {
IconCalendar,
OverflowingTextWithTooltip,
isModifiedEvent,
} from 'twenty-ui';
import { formatToHumanReadableDate } from '~/utils/date-utils'; import { formatToHumanReadableDate } from '~/utils/date-utils';
import { getFileNameAndExtension } from '~/utils/file/getFileNameAndExtension'; import { getFileNameAndExtension } from '~/utils/file/getFileNameAndExtension';
@ -145,7 +149,7 @@ export const AttachmentRow = ({
const handleOpenDocument = (e: React.MouseEvent) => { const handleOpenDocument = (e: React.MouseEvent) => {
// Cmd/Ctrl+click opens new tab, right click opens context menu // Cmd/Ctrl+click opens new tab, right click opens context menu
if (e.metaKey || e.ctrlKey || e.button === 2) { if (isModifiedEvent(e) || e.button === 2) {
return; return;
} }

View File

@ -1,4 +1,10 @@
import { AvatarChip, AvatarChipVariant } from 'twenty-ui'; import {
AvatarChip,
AvatarChipVariant,
ChipSize,
LinkAvatarChip,
isModifiedEvent,
} from 'twenty-ui';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage'; import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
@ -6,14 +12,16 @@ import { useRecordChipData } from '@/object-record/hooks/useRecordChipData';
import { recordIndexOpenRecordInSelector } from '@/object-record/record-index/states/selectors/recordIndexOpenRecordInSelector'; import { recordIndexOpenRecordInSelector } from '@/object-record/record-index/states/selectors/recordIndexOpenRecordInSelector';
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType'; import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { MouseEvent } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
export type RecordChipProps = { export type RecordChipProps = {
objectNameSingular: string; objectNameSingular: string;
record: ObjectRecord; record: ObjectRecord;
className?: string; className?: string;
variant?: AvatarChipVariant; variant?: AvatarChipVariant;
forceDisableClick?: boolean;
maxWidth?: number;
to?: string | undefined;
size?: ChipSize;
}; };
export const RecordChip = ({ export const RecordChip = ({
@ -21,6 +29,10 @@ export const RecordChip = ({
record, record,
className, className,
variant, variant,
maxWidth,
to,
size,
forceDisableClick = false,
}: RecordChipProps) => { }: RecordChipProps) => {
const { recordChipData } = useRecordChipData({ const { recordChipData } = useRecordChipData({
objectNameSingular, objectNameSingular,
@ -33,30 +45,52 @@ export const RecordChip = ({
recordIndexOpenRecordInSelector, recordIndexOpenRecordInSelector,
); );
const handleClick = (e: MouseEvent<Element>) => { // TODO temporary until we create a record show page for Workspaces members
e.stopPropagation(); if (forceDisableClick) {
if (recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL) { return (
openRecordInCommandMenu({ <AvatarChip
recordId: record.id, size={size}
objectNameSingular, maxWidth={maxWidth}
}); placeholderColorSeed={record.id}
} name={recordChipData.name}
}; avatarType={recordChipData.avatarType}
avatarUrl={recordChipData.avatarUrl ?? ''}
className={className}
/>
);
}
const isSidePanelViewOpenRecordInType =
recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL;
const onClick = isSidePanelViewOpenRecordInType
? () =>
openRecordInCommandMenu({
recordId: record.id,
objectNameSingular,
})
: undefined;
return ( return (
<AvatarChip <LinkAvatarChip
size={size}
maxWidth={maxWidth}
placeholderColorSeed={record.id} placeholderColorSeed={record.id}
name={recordChipData.name} name={recordChipData.name}
avatarType={recordChipData.avatarType} avatarType={recordChipData.avatarType}
avatarUrl={recordChipData.avatarUrl ?? ''} avatarUrl={recordChipData.avatarUrl ?? ''}
className={className} className={className}
variant={variant} variant={variant}
onClick={handleClick} to={to ?? getLinkToShowPage(objectNameSingular, record)}
to={ onClick={(clickEvent) => {
recordIndexOpenRecordIn === ViewOpenRecordInType.RECORD_PAGE // TODO refactor wrapper event listener to avoid colliding events
? getLinkToShowPage(objectNameSingular, record) clickEvent.stopPropagation();
: undefined
} const isModifiedEventResult = isModifiedEvent(clickEvent);
if (isSidePanelViewOpenRecordInType && !isModifiedEventResult) {
clickEvent.preventDefault();
onClick?.();
}
}}
/> />
); );
}; };

View File

@ -8,8 +8,11 @@ import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/r
import { isRecordBoardCompactModeActiveComponentState } from '@/object-record/record-board/states/isRecordBoardCompactModeActiveComponentState'; import { isRecordBoardCompactModeActiveComponentState } from '@/object-record/record-board/states/isRecordBoardCompactModeActiveComponentState';
import { recordBoardVisibleFieldDefinitionsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardVisibleFieldDefinitionsComponentSelector'; import { recordBoardVisibleFieldDefinitionsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardVisibleFieldDefinitionsComponentSelector';
import { RecordBoardCardBody } from '@/object-record/record-board/record-board-card/components/RecordBoardCardBody';
import { RecordBoardCardHeader } from '@/object-record/record-board/record-board-card/components/RecordBoardCardHeader';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect'; import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect';
import { AppPath } from '@/types/AppPath';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { RecordBoardScrollWrapperContext } from '@/ui/utilities/scroll/contexts/ScrollWrapperContexts'; import { RecordBoardScrollWrapperContext } from '@/ui/utilities/scroll/contexts/ScrollWrapperContexts';
import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2'; import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2';
@ -19,12 +22,10 @@ import styled from '@emotion/styled';
import { useContext, useState } from 'react'; import { useContext, useState } from 'react';
import { InView, useInView } from 'react-intersection-observer'; import { InView, useInView } from 'react-intersection-observer';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import { AnimatedEaseInOut } from 'twenty-ui'; import { AnimatedEaseInOut } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { RecordBoardCardBody } from '@/object-record/record-board/record-board-card/components/RecordBoardCardBody';
import { RecordBoardCardHeader } from '@/object-record/record-board/record-board-card/components/RecordBoardCardHeader';
import { useNavigateApp } from '~/hooks/useNavigateApp'; import { useNavigateApp } from '~/hooks/useNavigateApp';
import { AppPath } from '@/types/AppPath';
const StyledBoardCard = styled.div<{ selected: boolean }>` const StyledBoardCard = styled.div<{ selected: boolean }>`
background-color: ${({ theme, selected }) => background-color: ${({ theme, selected }) =>
@ -169,7 +170,7 @@ export const RecordBoardCard = ({
onMouseLeave={onMouseLeaveBoard} onMouseLeave={onMouseLeaveBoard}
onClick={handleCardClick} onClick={handleCardClick}
> >
{labelIdentifierField && ( {isDefined(labelIdentifierField) && (
<RecordBoardCardHeader <RecordBoardCardHeader
identifierFieldDefinition={labelIdentifierField} identifierFieldDefinition={labelIdentifierField}
isCreating={isCreating} isCreating={isCreating}

View File

@ -1,4 +1,4 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { RecordChip } from '@/object-record/components/RecordChip';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection';
import { RecordBoardCardHeaderContainer } from '@/object-record/record-board/record-board-card/components/RecordBoardCardHeaderContainer'; import { RecordBoardCardHeaderContainer } from '@/object-record/record-board/record-board-card/components/RecordBoardCardHeaderContainer';
@ -16,14 +16,12 @@ import {
} from '@/object-record/record-field/contexts/FieldContext'; } from '@/object-record/record-field/contexts/FieldContext';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { getFieldButtonIcon } from '@/object-record/record-field/utils/getFieldButtonIcon'; import { getFieldButtonIcon } from '@/object-record/record-field/utils/getFieldButtonIcon';
import { RecordIdentifierChip } from '@/object-record/record-index/components/RecordIndexRecordChip';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { recordIndexOpenRecordInSelector } from '@/object-record/record-index/states/selectors/recordIndexOpenRecordInSelector'; import { recordIndexOpenRecordInSelector } from '@/object-record/record-index/states/selectors/recordIndexOpenRecordInSelector';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell'; import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { RecordInlineCellEditMode } from '@/object-record/record-inline-cell/components/RecordInlineCellEditMode'; import { RecordInlineCellEditMode } from '@/object-record/record-inline-cell/components/RecordInlineCellEditMode';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope'; import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2'; import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2';
@ -32,6 +30,7 @@ import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Dispatch, SetStateAction, useContext, useState } from 'react'; import { Dispatch, SetStateAction, useContext, useState } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
import { import {
AvatarChipVariant, AvatarChipVariant,
Checkbox, Checkbox,
@ -123,8 +122,6 @@ export const RecordBoardCardHeader = ({
recordIndexOpenRecordInSelector, recordIndexOpenRecordInSelector,
); );
const { openRecordInCommandMenu } = useCommandMenu();
return ( return (
<RecordBoardCardHeaderContainer showCompactView={showCompactView}> <RecordBoardCardHeaderContainer showCompactView={showCompactView}>
<StopPropagationContainer> <StopPropagationContainer>
@ -156,7 +153,7 @@ export const RecordBoardCardHeader = ({
) : isIdentifierEmpty ? ( ) : isIdentifierEmpty ? (
<FieldContext.Provider <FieldContext.Provider
value={{ value={{
recordId: (record as ObjectRecord).id, recordId,
maxWidth: 156, maxWidth: 156,
recoilScopeId: recoilScopeId:
(isCreating ? 'new' : recordId) + (isCreating ? 'new' : recordId) +
@ -182,27 +179,19 @@ export const RecordBoardCardHeader = ({
<RecordInlineCell /> <RecordInlineCell />
</FieldContext.Provider> </FieldContext.Provider>
) : ( ) : (
<RecordIdentifierChip isDefined(record) && (
objectNameSingular={objectMetadataItem.nameSingular} <RecordChip
record={record as ObjectRecord} objectNameSingular={objectMetadataItem.nameSingular}
variant={AvatarChipVariant.Transparent} record={record}
maxWidth={150} variant={AvatarChipVariant.Transparent}
onClick={ maxWidth={150}
recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL to={
? () => { recordIndexOpenRecordIn === ViewOpenRecordInType.RECORD_PAGE
openRecordInCommandMenu({ ? indexIdentifierUrl(recordId)
recordId, : undefined
objectNameSingular: objectMetadataItem.nameSingular, }
}); />
} )
: undefined
}
to={
recordIndexOpenRecordIn === ViewOpenRecordInType.RECORD_PAGE
? indexIdentifierUrl(recordId)
: undefined
}
/>
)} )}
</StopPropagationContainer> </StopPropagationContainer>

View File

@ -1,52 +1,22 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { RecordChip } from '@/object-record/components/RecordChip'; import { RecordChip } from '@/object-record/components/RecordChip';
import { useChipFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useChipFieldDisplay'; import { useChipFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useChipFieldDisplay';
import { RecordIdentifierChip } from '@/object-record/record-index/components/RecordIndexRecordChip'; import { isDefined } from 'twenty-shared';
import { recordIndexOpenRecordInSelector } from '@/object-record/record-index/states/selectors/recordIndexOpenRecordInSelector';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { useRecoilValue } from 'recoil';
import { ChipSize } from 'twenty-ui'; import { ChipSize } from 'twenty-ui';
export const ChipFieldDisplay = () => { export const ChipFieldDisplay = () => {
const { const { recordValue, objectNameSingular, labelIdentifierLink } =
recordValue, useChipFieldDisplay();
objectNameSingular,
isLabelIdentifier,
labelIdentifierLink,
} = useChipFieldDisplay();
const recordIndexOpenRecordIn = useRecoilValue( if (!isDefined(recordValue)) {
recordIndexOpenRecordInSelector,
);
const { openRecordInCommandMenu } = useCommandMenu();
if (!recordValue) {
return null; return null;
} }
return isLabelIdentifier ? ( return (
<RecordIdentifierChip <RecordChip
objectNameSingular={objectNameSingular} objectNameSingular={objectNameSingular}
record={recordValue} record={recordValue}
size={ChipSize.Small} size={ChipSize.Small}
to={ to={labelIdentifierLink}
recordIndexOpenRecordIn === ViewOpenRecordInType.RECORD_PAGE
? labelIdentifierLink
: undefined
}
onClick={
recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL
? () => {
openRecordInCommandMenu({
recordId: recordValue.id,
objectNameSingular,
});
}
: undefined
}
/> />
) : (
<RecordChip objectNameSingular={objectNameSingular} record={recordValue} />
); );
}; };

View File

@ -1,17 +1,22 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordChip } from '@/object-record/components/RecordChip'; import { RecordChip } from '@/object-record/components/RecordChip';
import { useRelationToOneFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay'; import { useRelationToOneFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay';
import { isDefined } from 'twenty-shared';
export const RelationToOneFieldDisplay = () => { export const RelationToOneFieldDisplay = () => {
const { fieldValue, fieldDefinition, generateRecordChipData } = const { fieldValue, fieldDefinition, generateRecordChipData } =
useRelationToOneFieldDisplay(); useRelationToOneFieldDisplay();
if ( if (
!fieldValue || !isDefined(fieldValue) ||
!fieldDefinition?.metadata.relationObjectMetadataNameSingular !isDefined(fieldDefinition?.metadata.relationObjectMetadataNameSingular)
) { ) {
return null; return null;
} }
const isWorkspaceMemberFieldMetadataRelation =
fieldDefinition.metadata.relationObjectMetadataNameSingular ===
CoreObjectNameSingular.WorkspaceMember;
const recordChipData = generateRecordChipData(fieldValue); const recordChipData = generateRecordChipData(fieldValue);
return ( return (
@ -19,6 +24,7 @@ export const RelationToOneFieldDisplay = () => {
key={recordChipData.recordId} key={recordChipData.recordId}
objectNameSingular={recordChipData.objectNameSingular} objectNameSingular={recordChipData.objectNameSingular}
record={fieldValue} record={fieldValue}
forceDisableClick={isWorkspaceMemberFieldMetadataRelation}
/> />
); );
}; };

View File

@ -1,53 +0,0 @@
import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandardObjectIcon';
import { useRecordChipData } from '@/object-record/hooks/useRecordChipData';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isNonEmptyString } from '@sniptt/guards';
import { AvatarChip, AvatarChipVariant, ChipSize } from 'twenty-ui';
export type RecordIdentifierChipProps = {
objectNameSingular: string;
record: ObjectRecord;
variant?: AvatarChipVariant;
size?: ChipSize;
to?: string;
maxWidth?: number;
onClick?: () => void;
};
export const RecordIdentifierChip = ({
objectNameSingular,
record,
variant,
size,
onClick,
to,
maxWidth,
}: RecordIdentifierChipProps) => {
const { recordChipData } = useRecordChipData({
objectNameSingular,
record,
});
const { Icon: LeftIcon, IconColor: LeftIconColor } =
useGetStandardObjectIcon(objectNameSingular);
if (!isNonEmptyString(recordChipData.name.trim())) {
return null;
}
return (
<AvatarChip
placeholderColorSeed={record.id}
name={recordChipData.name}
avatarType={recordChipData.avatarType}
avatarUrl={recordChipData.avatarUrl ?? ''}
to={to}
onClick={onClick}
variant={variant}
LeftIcon={LeftIcon}
LeftIconColor={LeftIconColor}
size={size}
maxWidth={maxWidth}
/>
);
};

View File

@ -4,7 +4,6 @@ import { ConnectedAccountProvider } from 'twenty-shared';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { import {
AvatarChip, AvatarChip,
AvatarChipVariant,
IconApi, IconApi,
IconCalendar, IconCalendar,
IconCsv, IconCsv,
@ -71,7 +70,6 @@ export const ActorDisplay = ({
LeftIcon={LeftIcon} LeftIcon={LeftIcon}
avatarUrl={avatarUrl ?? undefined} avatarUrl={avatarUrl ?? undefined}
isIconInverted={isIconInverted} isIconInverted={isIconInverted}
variant={AvatarChipVariant.Transparent}
/> />
); );
}; };

View File

@ -1,6 +1,10 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { ReactElement, useCallback, useEffect, useRef, useState } from 'react'; import { ReactElement, useCallback, useEffect, useRef, useState } from 'react';
import { AnimatedContainer, Chip, ChipVariant } from 'twenty-ui'; import {
AnimatedContainer,
ChipSize,
OverflowingTextWithTooltip,
} from 'twenty-ui';
import { ExpandedListDropdown } from '@/ui/layout/expandable-list/components/ExpandedListDropdown'; import { ExpandedListDropdown } from '@/ui/layout/expandable-list/components/ExpandedListDropdown';
import { isFirstOverflowingChildElement } from '@/ui/layout/expandable-list/utils/isFirstOverflowingChildElement'; import { isFirstOverflowingChildElement } from '@/ui/layout/expandable-list/utils/isFirstOverflowingChildElement';
@ -34,7 +38,7 @@ const StyledChildContainer = styled.div`
} }
`; `;
const StyledChipCount = styled(Chip)` const StyledUnShrinkableContainer = styled.div`
flex-shrink: 0; flex-shrink: 0;
`; `;
@ -150,11 +154,12 @@ export const ExpandableList = ({
</StyledChildrenContainer> </StyledChildrenContainer>
{canDisplayChipCount && ( {canDisplayChipCount && (
<AnimatedContainer> <AnimatedContainer>
<StyledChipCount <StyledUnShrinkableContainer onClick={handleChipCountClick}>
label={`+${hiddenChildrenCount}`} <OverflowingTextWithTooltip
variant={ChipVariant.Highlighted} text={`+${hiddenChildrenCount}`}
onClick={handleChipCountClick} size={ChipSize.Small}
/> />
</StyledUnShrinkableContainer>
</AnimatedContainer> </AnimatedContainer>
)} )}
{isListExpanded && ( {isListExpanded && (

View File

@ -102,7 +102,7 @@ export const RightDrawerTopBar = () => {
<Chip <Chip
disabled={isNewViewableRecordLoading} disabled={isNewViewableRecordLoading}
label={label} label={label}
leftComponent={<Icon size={theme.icon.size.md} />} leftComponent={() => <Icon size={theme.icon.size.md} />}
size={ChipSize.Large} size={ChipSize.Large}
accent={ChipAccent.TextSecondary} accent={ChipAccent.TextSecondary}
clickable={false} clickable={false}

View File

@ -0,0 +1,37 @@
import { AvatarChipsLeftComponent } from '@ui/display/avatar-chip/components/AvatarChipLeftComponent';
import { AvatarChipsCommonProps } from '@ui/display/avatar-chip/types/AvatarChipsCommonProps.type';
import { Chip, ChipVariant } from '@ui/display/chip/components/Chip';
export type AvatarChipProps = AvatarChipsCommonProps;
export const AvatarChip = ({
name,
LeftIcon,
LeftIconColor,
avatarType,
avatarUrl,
className,
isIconInverted,
maxWidth,
placeholderColorSeed,
size,
}: AvatarChipProps) => (
<Chip
label={name}
variant={ChipVariant.Transparent}
size={size}
leftComponent={() => (
<AvatarChipsLeftComponent
name={name}
LeftIcon={LeftIcon}
LeftIconColor={LeftIconColor}
avatarType={avatarType}
avatarUrl={avatarUrl}
isIconInverted={isIconInverted}
placeholderColorSeed={placeholderColorSeed}
/>
)}
clickable={false}
className={className}
maxWidth={maxWidth}
/>
);

View File

@ -0,0 +1,74 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Avatar } from '@ui/display/avatar/components/Avatar';
import { AvatarType } from '@ui/display/avatar/types/AvatarType';
import { IconComponent } from '@ui/display/icon/types/IconComponent';
import { isDefined } from 'twenty-shared';
import { Nullable } from 'vitest';
const StyledInvertedIconContainer = styled.div<{ backgroundColor: string }>`
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 4px;
background-color: ${({ backgroundColor }) => backgroundColor};
`;
export type AvatarChipsLeftComponentProps = {
name: string;
avatarUrl?: string;
avatarType?: Nullable<AvatarType>;
LeftIcon?: IconComponent;
LeftIconColor?: string;
isIconInverted?: boolean;
placeholderColorSeed?: string;
};
export const AvatarChipsLeftComponent: React.FC<
AvatarChipsLeftComponentProps
> = ({
LeftIcon,
placeholderColorSeed,
avatarType,
avatarUrl,
name,
isIconInverted = false,
LeftIconColor,
}) => {
const theme = useTheme();
if (!isDefined(LeftIcon)) {
return (
<Avatar
avatarUrl={avatarUrl}
placeholderColorSeed={placeholderColorSeed}
placeholder={name}
size="sm"
type={avatarType}
/>
);
}
if (isIconInverted) {
return (
<StyledInvertedIconContainer
backgroundColor={theme.background.invertedSecondary}
>
<LeftIcon
color="white"
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledInvertedIconContainer>
);
}
return (
<LeftIcon
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={LeftIconColor || 'currentColor'}
/>
);
};

View File

@ -0,0 +1,53 @@
import { AvatarChipsLeftComponent } from '@ui/display/avatar-chip/components/AvatarChipLeftComponent';
import { AvatarChipsCommonProps } from '@ui/display/avatar-chip/types/AvatarChipsCommonProps.type';
import { AvatarChipVariant } from '@ui/display/avatar-chip/types/AvatarChipsVariant.type';
import { ChipVariant } from '@ui/display/chip/components/Chip';
import { LinkChip, LinkChipProps } from '@ui/display/chip/components/LinkChip';
export type LinkAvatarChipProps = Omit<AvatarChipsCommonProps, 'clickable'> & {
to: string;
onClick?: LinkChipProps['onClick'];
variant?: AvatarChipVariant;
};
export const LinkAvatarChip = ({
to,
onClick,
name,
LeftIcon,
LeftIconColor,
avatarType,
avatarUrl,
className,
isIconInverted,
maxWidth,
placeholderColorSeed,
size,
variant,
}: LinkAvatarChipProps) => (
<LinkChip
to={to}
onClick={onClick}
label={name}
variant={
//Regular but Highlighted -> missleading
variant === AvatarChipVariant.Regular
? ChipVariant.Highlighted
: ChipVariant.Regular
}
size={size}
leftComponent={() => (
<AvatarChipsLeftComponent
name={name}
LeftIcon={LeftIcon}
LeftIconColor={LeftIconColor}
avatarType={avatarType}
avatarUrl={avatarUrl}
isIconInverted={isIconInverted}
placeholderColorSeed={placeholderColorSeed}
/>
)}
className={className}
maxWidth={maxWidth}
/>
);

View File

@ -0,0 +1,8 @@
import { AvatarChipsLeftComponentProps } from '@ui/display/avatar-chip/components/AvatarChipLeftComponent';
import { ChipSize } from '@ui/display/chip/components/Chip';
export type AvatarChipsCommonProps = {
size?: ChipSize;
className?: string;
maxWidth?: number;
} & AvatarChipsLeftComponentProps;

View File

@ -0,0 +1,4 @@
export enum AvatarChipVariant {
Regular = 'regular',
Transparent = 'transparent',
}

View File

@ -1,121 +0,0 @@
import { styled } from '@linaria/react';
import { Avatar } from '@ui/display/avatar/components/Avatar';
import { AvatarType } from '@ui/display/avatar/types/AvatarType';
import { Chip, ChipSize, ChipVariant } from '@ui/display/chip/components/Chip';
import { IconComponent } from '@ui/display/icon/types/IconComponent';
import { ThemeContext } from '@ui/theme';
import { Nullable } from '@ui/utilities/types/Nullable';
import { MouseEvent, useContext } from 'react';
import { isDefined } from 'twenty-shared';
// Import Link from react-router-dom instead of UndecoratedLink
import { Link } from 'react-router-dom';
export type AvatarChipProps = {
name: string;
avatarUrl?: string;
avatarType?: Nullable<AvatarType>;
variant?: AvatarChipVariant;
size?: ChipSize;
LeftIcon?: IconComponent;
LeftIconColor?: string;
isIconInverted?: boolean;
className?: string;
placeholderColorSeed?: string;
onClick?: (event: MouseEvent) => void;
to?: string;
maxWidth?: number;
};
export enum AvatarChipVariant {
Regular = 'regular',
Transparent = 'transparent',
}
const StyledInvertedIconContainer = styled.div<{ backgroundColor: string }>`
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 4px;
background-color: ${({ backgroundColor }) => backgroundColor};
`;
// Ideally we would use the UndecoratedLink component from @ui/navigation
// but it led to a bug probably linked to circular dependencies, which was hard to solve
const StyledLink = styled(Link)`
text-decoration: none;
`;
export const AvatarChip = ({
name,
avatarUrl,
avatarType = 'rounded',
variant = AvatarChipVariant.Regular,
LeftIcon,
LeftIconColor,
isIconInverted,
className,
placeholderColorSeed,
onClick,
to,
size = ChipSize.Small,
maxWidth,
}: AvatarChipProps) => {
const { theme } = useContext(ThemeContext);
const chip = (
<Chip
label={name}
variant={
isDefined(onClick) || isDefined(to)
? variant === AvatarChipVariant.Regular
? ChipVariant.Highlighted
: ChipVariant.Regular
: ChipVariant.Transparent
}
size={size}
leftComponent={
isDefined(LeftIcon) ? (
isIconInverted === true ? (
<StyledInvertedIconContainer
backgroundColor={theme.background.invertedSecondary}
>
<LeftIcon
color="white"
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
/>
</StyledInvertedIconContainer>
) : (
<LeftIcon
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
color={LeftIconColor || 'currentColor'}
/>
)
) : (
<Avatar
avatarUrl={avatarUrl}
placeholderColorSeed={placeholderColorSeed}
placeholder={name}
size="sm"
type={avatarType}
/>
)
}
clickable={isDefined(onClick) || isDefined(to)}
onClick={to ? undefined : onClick}
className={className}
maxWidth={maxWidth}
/>
);
if (!isDefined(to)) return chip;
return (
<StyledLink to={to} onClick={onClick}>
{chip}
</StyledLink>
);
};

View File

@ -1,6 +1,6 @@
import { Theme, withTheme } from '@emotion/react'; import { Theme, withTheme } from '@emotion/react';
import { styled } from '@linaria/react'; import { styled } from '@linaria/react';
import { MouseEvent, ReactNode } from 'react'; import { ReactNode } from 'react';
import { OverflowingTextWithTooltip } from '@ui/display/tooltip/OverflowingTextWithTooltip'; import { OverflowingTextWithTooltip } from '@ui/display/tooltip/OverflowingTextWithTooltip';
@ -21,7 +21,7 @@ export enum ChipVariant {
Rounded = 'rounded', Rounded = 'rounded',
} }
type ChipProps = { export type ChipProps = {
size?: ChipSize; size?: ChipSize;
disabled?: boolean; disabled?: boolean;
clickable?: boolean; clickable?: boolean;
@ -29,10 +29,9 @@ type ChipProps = {
maxWidth?: number; maxWidth?: number;
variant?: ChipVariant; variant?: ChipVariant;
accent?: ChipAccent; accent?: ChipAccent;
leftComponent?: ReactNode; leftComponent?: (() => ReactNode) | null;
rightComponent?: ReactNode; rightComponent?: (() => ReactNode) | null;
className?: string; className?: string;
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
}; };
const StyledContainer = withTheme(styled.div< const StyledContainer = withTheme(styled.div<
@ -128,10 +127,9 @@ export const Chip = ({
disabled = false, disabled = false,
clickable = true, clickable = true,
variant = ChipVariant.Regular, variant = ChipVariant.Regular,
leftComponent, leftComponent = null,
rightComponent, rightComponent = null,
accent = ChipAccent.TextPrimary, accent = ChipAccent.TextPrimary,
onClick,
className, className,
maxWidth, maxWidth,
}: ChipProps) => { }: ChipProps) => {
@ -143,13 +141,12 @@ export const Chip = ({
disabled={disabled} disabled={disabled}
size={size} size={size}
variant={variant} variant={variant}
onClick={onClick}
className={className} className={className}
maxWidth={maxWidth} maxWidth={maxWidth}
> >
{leftComponent} {leftComponent?.()}
<OverflowingTextWithTooltip size={size} text={label} /> <OverflowingTextWithTooltip size={size} text={label} />
{rightComponent} {rightComponent?.()}
</StyledContainer> </StyledContainer>
); );
}; };

View File

@ -0,0 +1,53 @@
import styled from '@emotion/styled';
import {
Chip,
ChipAccent,
ChipProps,
ChipSize,
ChipVariant,
} from '@ui/display/chip/components/Chip';
import { MouseEvent } from 'react';
import { Link } from 'react-router-dom';
export type LinkChipProps = Omit<
ChipProps,
'onClick' | 'disabled' | 'clickable'
> & {
to: string;
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
};
// Ideally we would use the UndecoratedLink component from @ui/navigation
// but it led to a bug probably linked to circular dependencies, which was hard to solve
const StyledLink = styled(Link)`
text-decoration: none;
`;
export const LinkChip = ({
to,
size = ChipSize.Small,
label,
variant = ChipVariant.Regular,
leftComponent = null,
rightComponent = null,
accent = ChipAccent.TextPrimary,
className,
maxWidth,
onClick,
}: LinkChipProps) => {
return (
<StyledLink to={to} onClick={onClick}>
<Chip
size={size}
label={label}
clickable={true}
variant={variant}
leftComponent={leftComponent}
rightComponent={rightComponent}
accent={accent}
className={className}
maxWidth={maxWidth}
/>
</StyledLink>
);
};

View File

@ -1,5 +1,5 @@
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { AvatarChip } from '@ui/display/chip/components/AvatarChip'; import { AvatarChip } from '@ui/display/avatar-chip/components/AvatarChip';
import { ComponentDecorator, RouterDecorator } from '@ui/testing'; import { ComponentDecorator, RouterDecorator } from '@ui/testing';

View File

@ -1,3 +1,8 @@
export * from './avatar-chip/components/AvatarChip';
export * from './avatar-chip/components/AvatarChipLeftComponent';
export * from './avatar-chip/components/LinkAvatarChip';
export * from './avatar-chip/types/AvatarChipsCommonProps.type';
export * from './avatar-chip/types/AvatarChipsVariant.type';
export * from './avatar/components/Avatar'; export * from './avatar/components/Avatar';
export * from './avatar/components/AvatarGroup'; export * from './avatar/components/AvatarGroup';
export * from './avatar/components/states/isInvalidAvatarUrlState'; export * from './avatar/components/states/isInvalidAvatarUrlState';
@ -7,8 +12,8 @@ export * from './avatar/types/AvatarType';
export * from './banner/components/Banner'; export * from './banner/components/Banner';
export * from './checkmark/components/AnimatedCheckmark'; export * from './checkmark/components/AnimatedCheckmark';
export * from './checkmark/components/Checkmark'; export * from './checkmark/components/Checkmark';
export * from './chip/components/AvatarChip';
export * from './chip/components/Chip'; export * from './chip/components/Chip';
export * from './chip/components/LinkChip';
export * from './color/components/ColorSample'; export * from './color/components/ColorSample';
export * from './icon/components/IconAddressBook'; export * from './icon/components/IconAddressBook';
export * from './icon/components/IconGmail'; export * from './icon/components/IconGmail';

View File

@ -0,0 +1,16 @@
type LimitedMouseEvent = Pick<
MouseEvent,
'button' | 'metaKey' | 'altKey' | 'ctrlKey' | 'shiftKey'
>;
export const isModifiedEvent = ({
altKey,
ctrlKey,
shiftKey,
metaKey,
button,
}: LimitedMouseEvent) => {
const pressedKey = [altKey, ctrlKey, shiftKey, metaKey].some((key) => key);
const isLeftClick = button === 0;
return pressedKey || !isLeftClick;
};

View File

@ -10,6 +10,7 @@ export * from './device/getOsControlSymbol';
export * from './device/getOsShortcutSeparator'; export * from './device/getOsShortcutSeparator';
export * from './device/getUserDevice'; export * from './device/getUserDevice';
export * from './dimensions/components/AutogrowWrapper'; export * from './dimensions/components/AutogrowWrapper';
export * from './events/isModifiedEvent';
export * from './responsive/hooks/useIsMobile'; export * from './responsive/hooks/useIsMobile';
export * from './screen-size/hooks/useScreenSize'; export * from './screen-size/hooks/useScreenSize';
export * from './state/utils/createState'; export * from './state/utils/createState';