[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:
@ -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}
|
||||
/>
|
||||
);
|
||||
@ -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'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
@ -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;
|
||||
@ -0,0 +1,4 @@
|
||||
export enum AvatarChipVariant {
|
||||
Regular = 'regular',
|
||||
Transparent = 'transparent',
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
import { Theme, withTheme } from '@emotion/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { OverflowingTextWithTooltip } from '@ui/display/tooltip/OverflowingTextWithTooltip';
|
||||
|
||||
@ -21,7 +21,7 @@ export enum ChipVariant {
|
||||
Rounded = 'rounded',
|
||||
}
|
||||
|
||||
type ChipProps = {
|
||||
export type ChipProps = {
|
||||
size?: ChipSize;
|
||||
disabled?: boolean;
|
||||
clickable?: boolean;
|
||||
@ -29,10 +29,9 @@ type ChipProps = {
|
||||
maxWidth?: number;
|
||||
variant?: ChipVariant;
|
||||
accent?: ChipAccent;
|
||||
leftComponent?: ReactNode;
|
||||
rightComponent?: ReactNode;
|
||||
leftComponent?: (() => ReactNode) | null;
|
||||
rightComponent?: (() => ReactNode) | null;
|
||||
className?: string;
|
||||
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
|
||||
const StyledContainer = withTheme(styled.div<
|
||||
@ -128,10 +127,9 @@ export const Chip = ({
|
||||
disabled = false,
|
||||
clickable = true,
|
||||
variant = ChipVariant.Regular,
|
||||
leftComponent,
|
||||
rightComponent,
|
||||
leftComponent = null,
|
||||
rightComponent = null,
|
||||
accent = ChipAccent.TextPrimary,
|
||||
onClick,
|
||||
className,
|
||||
maxWidth,
|
||||
}: ChipProps) => {
|
||||
@ -143,13 +141,12 @@ export const Chip = ({
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
variant={variant}
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
maxWidth={maxWidth}
|
||||
>
|
||||
{leftComponent}
|
||||
{leftComponent?.()}
|
||||
<OverflowingTextWithTooltip size={size} text={label} />
|
||||
{rightComponent}
|
||||
{rightComponent?.()}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
53
packages/twenty-ui/src/display/chip/components/LinkChip.tsx
Normal file
53
packages/twenty-ui/src/display/chip/components/LinkChip.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
|
||||
@ -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/AvatarGroup';
|
||||
export * from './avatar/components/states/isInvalidAvatarUrlState';
|
||||
@ -7,8 +12,8 @@ export * from './avatar/types/AvatarType';
|
||||
export * from './banner/components/Banner';
|
||||
export * from './checkmark/components/AnimatedCheckmark';
|
||||
export * from './checkmark/components/Checkmark';
|
||||
export * from './chip/components/AvatarChip';
|
||||
export * from './chip/components/Chip';
|
||||
export * from './chip/components/LinkChip';
|
||||
export * from './color/components/ColorSample';
|
||||
export * from './icon/components/IconAddressBook';
|
||||
export * from './icon/components/IconGmail';
|
||||
|
||||
16
packages/twenty-ui/src/utilities/events/isModifiedEvent.ts
Normal file
16
packages/twenty-ui/src/utilities/events/isModifiedEvent.ts
Normal 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;
|
||||
};
|
||||
@ -10,6 +10,7 @@ export * from './device/getOsControlSymbol';
|
||||
export * from './device/getOsShortcutSeparator';
|
||||
export * from './device/getUserDevice';
|
||||
export * from './dimensions/components/AutogrowWrapper';
|
||||
export * from './events/isModifiedEvent';
|
||||
export * from './responsive/hooks/useIsMobile';
|
||||
export * from './screen-size/hooks/useScreenSize';
|
||||
export * from './state/utils/createState';
|
||||
|
||||
Reference in New Issue
Block a user