[REFACTOR] Twenty UI multi barrel (#11301)

# Introduction
closes https://github.com/twentyhq/core-team-issues/issues/591
Same than for `twenty-shared` made in
https://github.com/twentyhq/twenty/pull/11083.

## TODO
- [x] Manual migrate twenty-website twenty-ui imports

## What's next:
- Generate barrel and migration script factorization within own package
+ tests
- Refactoring using preconstruct ? TimeBox
- Lint circular dependencies
- Lint import from barrel and forbid them

### Preconstruct
We need custom rollup plugins addition, but preconstruct does not expose
its rollup configuration. It might be possible to handle this using the
babel overrides. But was a big tunnel.
We could give it a try afterwards ! ( allowing cjs interop and stuff
like that )
Stuck to vite lib app

Closed related PRs:
- https://github.com/twentyhq/twenty/pull/11294
- https://github.com/twentyhq/twenty/pull/11203
This commit is contained in:
Paul Rastoin
2025-04-03 11:47:55 +02:00
committed by GitHub
parent 8c9fcfe5a4
commit 4a4e65fe4a
1009 changed files with 5757 additions and 2828 deletions

View File

@ -1,7 +1,7 @@
import { Meta, StoryObj } from '@storybook/react';
import { Pill } from '@ui/components/Pill/Pill';
import { ComponentDecorator } from '../../../testing/decorators/ComponentDecorator';
import { Pill } from '../Pill';
const meta: Meta<typeof Pill> = {
title: 'UI/Display/Pill',

View File

@ -0,0 +1,37 @@
import { AvatarChipsLeftComponent } from '@ui/components/avatar-chip/AvatarChipLeftComponent';
import { AvatarChipsCommonProps } from '@ui/components/avatar-chip/types/AvatarChipsCommonProps.type';
import { Chip, ChipVariant } from '@ui/components/chip/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 { Nullable } from '@ui/utilities';
import { isDefined } from 'twenty-shared/utils';
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,56 @@
import { AvatarChipsLeftComponent } from '@ui/components/avatar-chip/AvatarChipLeftComponent';
import { AvatarChipsCommonProps } from '@ui/components/avatar-chip/types/AvatarChipsCommonProps.type';
import { AvatarChipVariant } from '@ui/components/avatar-chip/types/AvatarChipsVariant.type';
import { ChipVariant } from '@ui/components/chip/Chip';
import { LinkChip, LinkChipProps } from '@ui/components/chip/LinkChip';
export type LinkAvatarChipProps = Omit<AvatarChipsCommonProps, 'clickable'> & {
to: string;
onClick?: LinkChipProps['onClick'];
variant?: AvatarChipVariant;
isLabelHidden?: boolean;
};
export const LinkAvatarChip = ({
to,
onClick,
name,
LeftIcon,
LeftIconColor,
avatarType,
avatarUrl,
className,
isIconInverted,
maxWidth,
placeholderColorSeed,
size,
variant,
isLabelHidden,
}: LinkAvatarChipProps) => (
<LinkChip
to={to}
onClick={onClick}
label={name}
isLabelHidden={isLabelHidden}
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/components/avatar-chip/AvatarChipLeftComponent';
import { ChipSize } from '@ui/components/chip/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

@ -0,0 +1,156 @@
import { Theme, withTheme } from '@emotion/react';
import { styled } from '@linaria/react';
import { ReactNode } from 'react';
import { OverflowingTextWithTooltip } from '@ui/display/tooltip/OverflowingTextWithTooltip';
export enum ChipSize {
Large = 'large',
Small = 'small',
}
export enum ChipAccent {
TextPrimary = 'text-primary',
TextSecondary = 'text-secondary',
}
export enum ChipVariant {
Highlighted = 'highlighted',
Regular = 'regular',
Transparent = 'transparent',
Rounded = 'rounded',
}
export type ChipProps = {
size?: ChipSize;
disabled?: boolean;
clickable?: boolean;
label: string;
isLabelHidden?: boolean;
maxWidth?: number;
variant?: ChipVariant;
accent?: ChipAccent;
leftComponent?: (() => ReactNode) | null;
rightComponent?: (() => ReactNode) | null;
className?: string;
};
const StyledContainer = withTheme(styled.div<
Pick<
ChipProps,
'accent' | 'clickable' | 'disabled' | 'maxWidth' | 'size' | 'variant'
> & { theme: Theme }
>`
--chip-horizontal-padding: ${({ theme }) => theme.spacing(1)};
--chip-vertical-padding: ${({ theme }) => theme.spacing(1)};
text-decoration: none;
align-items: center;
color: ${({ theme, accent, disabled }) =>
disabled
? theme.font.color.light
: accent === ChipAccent.TextPrimary
? theme.font.color.primary
: theme.font.color.secondary};
cursor: ${({ clickable, disabled, variant }) =>
variant === ChipVariant.Transparent
? 'inherit'
: clickable
? 'pointer'
: disabled
? 'not-allowed'
: 'inherit'};
display: inline-flex;
justify-content: flex-start;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme, size }) =>
size === ChipSize.Large ? theme.spacing(4) : theme.spacing(3)};
max-width: ${({ maxWidth }) =>
maxWidth
? `calc(${maxWidth}px - 2 * var(--chip-horizontal-padding))`
: '200px'};
overflow: hidden;
padding: var(--chip-vertical-padding) var(--chip-horizontal-padding);
user-select: none;
font-weight: ${({ theme, accent }) =>
accent === ChipAccent.TextSecondary ? theme.font.weight.medium : 'inherit'};
&:hover {
background-color: ${({ theme, variant, disabled }) =>
variant === ChipVariant.Regular && !disabled
? theme.background.transparent.light
: variant === ChipVariant.Highlighted
? theme.background.transparent.medium
: 'inherit'};
}
&:active {
background-color: ${({ theme, disabled, variant }) =>
variant === ChipVariant.Regular && !disabled
? theme.background.transparent.medium
: variant === ChipVariant.Highlighted
? theme.background.transparent.strong
: 'inherit'};
}
background-color: ${({ theme, variant }) =>
variant === ChipVariant.Highlighted
? theme.background.transparent.light
: variant === ChipVariant.Rounded
? theme.background.transparent.lighter
: 'inherit'};
border: ${({ theme, variant }) =>
variant === ChipVariant.Rounded
? `1px solid ${theme.border.color.medium}`
: 'none'};
border-radius: ${({ theme, variant }) =>
variant === ChipVariant.Rounded ? '50px' : theme.border.radius.sm};
& > svg {
flex-shrink: 0;
}
padding-left: ${({ theme, variant }) =>
variant === ChipVariant.Transparent
? theme.spacing(0)
: 'var(--chip-horizontal-padding)'};
`);
export const Chip = ({
size = ChipSize.Small,
label,
isLabelHidden = false,
disabled = false,
clickable = true,
variant = ChipVariant.Regular,
leftComponent = null,
rightComponent = null,
accent = ChipAccent.TextPrimary,
className,
maxWidth,
}: ChipProps) => {
return (
<StyledContainer
data-testid="chip"
accent={accent}
clickable={clickable}
disabled={disabled}
size={size}
variant={variant}
className={className}
maxWidth={maxWidth}
>
{leftComponent?.()}
{!isLabelHidden && (
<OverflowingTextWithTooltip size={size} text={label} />
)}
{rightComponent?.()}
</StyledContainer>
);
};

View File

@ -0,0 +1,55 @@
import styled from '@emotion/styled';
import {
Chip,
ChipAccent,
ChipProps,
ChipSize,
ChipVariant,
} from '@ui/components/chip/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,
isLabelHidden = false,
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}
isLabelHidden={isLabelHidden}
clickable={true}
variant={variant}
leftComponent={leftComponent}
rightComponent={rightComponent}
accent={accent}
className={className}
maxWidth={maxWidth}
/>
</StyledLink>
);
};

View File

@ -0,0 +1,71 @@
import { Meta, StoryObj } from '@storybook/react';
import {
CatalogDecorator,
CatalogStory,
ComponentDecorator,
} from '@ui/testing';
import { Chip, ChipAccent, ChipSize, ChipVariant } from '../Chip';
const meta: Meta<typeof Chip> = {
title: 'UI/Display/Chip/Chip',
component: Chip,
};
export default meta;
type Story = StoryObj<typeof Chip>;
export const Default: Story = {
args: {
label: 'Chip test',
size: ChipSize.Small,
variant: ChipVariant.Highlighted,
accent: ChipAccent.TextPrimary,
disabled: false,
clickable: true,
maxWidth: 200,
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof Chip> = {
args: { clickable: true, label: 'Hello' },
argTypes: {
size: { control: false },
variant: { control: false },
disabled: { control: false },
className: { control: false },
rightComponent: { control: false },
leftComponent: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.active'] },
catalog: {
dimensions: [
{
name: 'states',
values: ['default', 'hover', 'active', 'disabled'],
props: (state: string) =>
state === 'default' ? {} : { className: state },
},
{
name: 'variants',
values: Object.values(ChipVariant),
props: (variant: ChipVariant) => ({ variant }),
},
{
name: 'sizes',
values: Object.values(ChipSize),
props: (size: ChipSize) => ({ size }),
},
{
name: 'accents',
values: Object.values(ChipAccent),
props: (accent: ChipAccent) => ({ accent }),
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -0,0 +1,23 @@
import { Meta, StoryObj } from '@storybook/react';
import { AvatarChip } from '@ui/components/avatar-chip/AvatarChip';
import {
ComponentDecorator,
RecoilRootDecorator,
RouterDecorator,
} from '@ui/testing';
const meta: Meta<typeof AvatarChip> = {
title: 'UI/Display/Chip/AvatarChip',
component: AvatarChip,
decorators: [RouterDecorator, ComponentDecorator, RecoilRootDecorator],
args: {
name: 'Entity name',
avatarType: 'squared',
},
};
export default meta;
type Story = StoryObj<typeof AvatarChip>;
export const Default: Story = {};

View File

@ -1 +1,24 @@
export * from './Pill/Pill';
/*
* _____ _
*|_ _|_ _____ _ __ | |_ _ _
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
* | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
* |_| \_/\_/ \___|_| |_|\__|\__, |
* |___/
*/
export type { AvatarChipProps } from './avatar-chip/AvatarChip';
export { AvatarChip } from './avatar-chip/AvatarChip';
export type { AvatarChipsLeftComponentProps } from './avatar-chip/AvatarChipLeftComponent';
export { AvatarChipsLeftComponent } from './avatar-chip/AvatarChipLeftComponent';
export type { LinkAvatarChipProps } from './avatar-chip/LinkAvatarChip';
export { LinkAvatarChip } from './avatar-chip/LinkAvatarChip';
export type { AvatarChipsCommonProps } from './avatar-chip/types/AvatarChipsCommonProps.type';
export { AvatarChipVariant } from './avatar-chip/types/AvatarChipsVariant.type';
export type { ChipProps } from './chip/Chip';
export { ChipSize, ChipAccent, ChipVariant, Chip } from './chip/Chip';
export type { LinkChipProps } from './chip/LinkChip';
export { LinkChip } from './chip/LinkChip';
export { Pill } from './Pill/Pill';
export type { TagColor } from './tag/Tag';
export { Tag } from './tag/Tag';

View File

@ -0,0 +1,142 @@
import { styled } from '@linaria/react';
import { useContext } from 'react';
// TODO prastoin We should forbid barrel import within the twenty-ui package
import { IconComponent } from '@ui/display/icon/types/IconComponent';
import { OverflowingTextWithTooltip } from '@ui/display/tooltip/OverflowingTextWithTooltip';
import {
BORDER_COMMON,
THEME_COMMON,
ThemeColor,
ThemeContext,
ThemeType,
} from '@ui/theme';
import { isDefined } from 'twenty-shared/utils';
const spacing5 = THEME_COMMON.spacing(5);
const spacing2 = THEME_COMMON.spacing(2);
const spacing1 = THEME_COMMON.spacing(1);
const StyledTag = styled.h3<{
theme: ThemeType;
color: TagColor;
weight: TagWeight;
variant: TagVariant;
preventShrink?: boolean;
preventPadding?: boolean;
}>`
align-items: center;
background: ${({ color, theme }) => {
if (color === 'transparent') {
return 'transparent';
} else {
const themeColor = theme.tag.background[color];
if (!isDefined(themeColor)) {
// eslint-disable-next-line no-console
console.warn(`Tag color ${color} is not defined in the theme`);
return theme.tag.background.gray;
} else {
return themeColor;
}
}
}};
border-radius: ${BORDER_COMMON.radius.sm};
color: ${({ color, theme }) =>
color === 'transparent'
? theme.font.color.secondary
: theme.tag.text[color]};
display: inline-flex;
font-size: ${({ theme }) => theme.font.size.md};
font-style: normal;
font-weight: ${({ theme, weight }) =>
weight === 'regular'
? theme.font.weight.regular
: theme.font.weight.medium};
height: ${spacing5};
margin: 0;
overflow: hidden;
padding: ${({ preventPadding }) => (preventPadding ? '0' : `0 ${spacing2}`)};
border: ${({ variant, theme }) =>
variant === 'outline' || variant === 'border'
? `1px ${variant === 'border' ? 'solid' : 'dashed'} ${theme.border.color.strong}`
: 'none'};
gap: ${spacing1};
min-width: ${({ preventShrink }) => (preventShrink ? 'fit-content' : 'none')};
`;
const StyledContent = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledNonShrinkableText = styled.span`
white-space: nowrap;
width: fit-content;
`;
const StyledIconContainer = styled.div`
display: flex;
`;
type TagWeight = 'regular' | 'medium';
type TagVariant = 'solid' | 'outline' | 'border';
export type TagColor = ThemeColor | 'transparent';
type TagProps = {
className?: string;
color: TagColor;
text: string;
Icon?: IconComponent;
onClick?: () => void;
weight?: TagWeight;
variant?: TagVariant;
preventShrink?: boolean;
preventPadding?: boolean;
};
// TODO: Find a way to have ellipsis and shrinkable tag in tag list while keeping good perf for table cells
export const Tag = ({
className,
color,
text,
Icon,
onClick,
weight = 'regular',
variant = 'solid',
preventShrink,
preventPadding,
}: TagProps) => {
const { theme } = useContext(ThemeContext);
return (
<StyledTag
theme={theme}
className={className}
color={color}
onClick={onClick}
weight={weight}
variant={variant}
preventShrink={preventShrink}
preventPadding={preventPadding}
>
{isDefined(Icon) ? (
<StyledIconContainer>
<Icon size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
</StyledIconContainer>
) : (
<></>
)}
{preventShrink ? (
<StyledNonShrinkableText>{text}</StyledNonShrinkableText>
) : (
<StyledContent>
<OverflowingTextWithTooltip text={text} />
</StyledContent>
)}
</StyledTag>
);
};

View File

@ -0,0 +1,105 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { IconUser } from '@ui/display/icon/components/TablerIcons';
import {
CatalogDecorator,
CatalogStory,
ComponentDecorator,
} from '@ui/testing';
import { MAIN_COLOR_NAMES, ThemeColor } from '@ui/theme';
import { Tag } from '../Tag';
const meta: Meta<typeof Tag> = {
title: 'UI/Display/Tag/Tag',
component: Tag,
args: {
text: 'Urgent',
},
};
export default meta;
type Story = StoryObj<typeof Tag>;
export const Default: Story = {
args: {
color: 'red',
onClick: fn(),
},
decorators: [ComponentDecorator],
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const tag = canvas.getByRole('heading', { level: 3 });
await userEvent.click(tag);
await expect(args.onClick).toHaveBeenCalled();
},
};
export const WithLongText: Story = {
decorators: [ComponentDecorator],
args: {
color: 'green',
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
},
parameters: {
container: { width: 100 },
},
};
export const WithIcon: Story = {
decorators: [ComponentDecorator],
args: {
color: 'green',
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
Icon: IconUser,
},
parameters: {
container: { width: 100 },
},
};
export const DontShrink: Story = {
decorators: [ComponentDecorator],
args: {
color: 'green',
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
preventShrink: true,
},
parameters: {
container: { width: 100 },
},
};
export const Catalog: CatalogStory<Story, typeof Tag> = {
argTypes: {
color: { control: false },
},
parameters: {
catalog: {
dimensions: [
{
name: 'colors',
values: MAIN_COLOR_NAMES,
props: (color: ThemeColor) => ({ color }),
},
],
},
},
decorators: [CatalogDecorator],
};
export const EmptyTag: Story = {
decorators: [ComponentDecorator],
args: {
color: 'transparent',
text: 'No Value',
variant: 'outline',
weight: 'medium',
},
parameters: {
container: { width: 'auto' },
},
};