Migrate to twenty-ui - navigation/link (#7837)
This PR was created by [GitStart](https://gitstart.com/) to address the requirements from this ticket: [TWNTY-7535](https://clients.gitstart.com/twenty/5449/tickets/TWNTY-7535). --- ### Description. Migrate link components to `twenty-ui` \ \ Fixes #7535 --------- Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com> Co-authored-by: gitstart-twenty <140154534+gitstart-twenty@users.noreply.github.com> Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
committed by
GitHub
parent
02c34d547f
commit
430644448a
@ -1,6 +1,7 @@
|
||||
export * from './accessibility';
|
||||
export * from './components';
|
||||
export * from './display';
|
||||
export * from './input';
|
||||
export * from './layout';
|
||||
export * from './navigation';
|
||||
export * from './testing';
|
||||
|
||||
88
packages/twenty-ui/src/input/components/Toggle.tsx
Normal file
88
packages/twenty-ui/src/input/components/Toggle.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { VisibilityHiddenInput } from '@ui/accessibility';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export type ToggleSize = 'small' | 'medium';
|
||||
|
||||
type ContainerProps = {
|
||||
isOn: boolean;
|
||||
color?: string;
|
||||
toggleSize: ToggleSize;
|
||||
'data-disabled'?: boolean;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.label<ContainerProps>`
|
||||
align-items: center;
|
||||
background-color: ${({ theme, isOn, color }) =>
|
||||
isOn ? (color ?? theme.color.blue) : theme.background.transparent.medium};
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: ${({ toggleSize }) => (toggleSize === 'small' ? 16 : 20)}px;
|
||||
transition: background-color 0.3s ease;
|
||||
width: ${({ toggleSize }) => (toggleSize === 'small' ? 24 : 32)}px;
|
||||
opacity: ${({ 'data-disabled': disabled }) => (disabled ? 0.5 : 1)};
|
||||
pointer-events: ${({ 'data-disabled': disabled }) =>
|
||||
disabled ? 'none' : 'auto'};
|
||||
`;
|
||||
|
||||
const StyledCircle = styled(motion.span)<{
|
||||
size: ToggleSize;
|
||||
}>`
|
||||
display: block;
|
||||
background-color: ${({ theme }) => theme.background.primary};
|
||||
border-radius: 50%;
|
||||
height: ${({ size }) => (size === 'small' ? 12 : 16)}px;
|
||||
width: ${({ size }) => (size === 'small' ? 12 : 16)}px;
|
||||
`;
|
||||
|
||||
export type ToggleProps = {
|
||||
id?: string;
|
||||
value?: boolean;
|
||||
onChange?: (value: boolean, e?: React.MouseEvent<HTMLDivElement>) => void;
|
||||
color?: string;
|
||||
toggleSize?: ToggleSize;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const Toggle = ({
|
||||
id,
|
||||
value = false,
|
||||
onChange,
|
||||
color,
|
||||
toggleSize = 'medium',
|
||||
className,
|
||||
disabled,
|
||||
}: ToggleProps) => {
|
||||
const circleVariants = {
|
||||
on: { x: toggleSize === 'small' ? 10 : 14 },
|
||||
off: { x: 2 },
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer
|
||||
isOn={value}
|
||||
color={color}
|
||||
toggleSize={toggleSize}
|
||||
className={className}
|
||||
data-disabled={disabled}
|
||||
>
|
||||
<VisibilityHiddenInput
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
onChange?.(event.target.checked);
|
||||
}}
|
||||
/>
|
||||
|
||||
<StyledCircle
|
||||
animate={value ? 'on' : 'off'}
|
||||
variants={circleVariants}
|
||||
size={toggleSize}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
1
packages/twenty-ui/src/input/index.ts
Normal file
1
packages/twenty-ui/src/input/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './components/Toggle';
|
||||
@ -1 +1,11 @@
|
||||
export * from './breadcrumb/components/Breadcrumb';
|
||||
export * from './link/components/ActionLink';
|
||||
export * from './link/components/AdvancedSettingsToggle';
|
||||
export * from './link/components/ContactLink';
|
||||
export * from './link/components/GithubVersionLink';
|
||||
export * from './link/components/RawLink';
|
||||
export * from './link/components/RoundedLink';
|
||||
export * from './link/components/SocialLink';
|
||||
export * from './link/components/UndecoratedLink';
|
||||
export * from './link/constants/Cal';
|
||||
export * from './link/constants/GithubLink';
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const StyledButtonLink = styled.a`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
padding: 0 ${({ theme }) => theme.spacing(1)};
|
||||
text-decoration: none;
|
||||
|
||||
:hover {
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionLink = (props: React.ComponentProps<'a'>) => {
|
||||
return (
|
||||
<StyledButtonLink
|
||||
href={props.href}
|
||||
onClick={props.onClick}
|
||||
target={props.target}
|
||||
rel={props.rel}
|
||||
>
|
||||
{props.children}
|
||||
</StyledButtonLink>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,71 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { IconTool } from '@ui/display';
|
||||
import { Toggle } from '@ui/input';
|
||||
import { MAIN_COLORS } from '@ui/theme';
|
||||
import { useId } from 'react';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledLabel = styled.label`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
border-right: 1px solid ${MAIN_COLORS.yellow};
|
||||
height: 16px;
|
||||
position: absolute;
|
||||
left: ${({ theme }) => theme.spacing(-5)};
|
||||
`;
|
||||
|
||||
const StyledToggleContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledIconTool = styled(IconTool)`
|
||||
margin-right: ${({ theme }) => theme.spacing(0.5)};
|
||||
`;
|
||||
|
||||
type AdvancedSettingsToggleProps = {
|
||||
isAdvancedModeEnabled: boolean;
|
||||
setIsAdvancedModeEnabled: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export const AdvancedSettingsToggle = ({
|
||||
isAdvancedModeEnabled,
|
||||
setIsAdvancedModeEnabled,
|
||||
}: AdvancedSettingsToggleProps) => {
|
||||
const onChange = (newValue: boolean) => {
|
||||
setIsAdvancedModeEnabled(newValue);
|
||||
};
|
||||
const inputId = useId();
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledIconContainer>
|
||||
<StyledIconTool size={12} color={MAIN_COLORS.yellow} />
|
||||
</StyledIconContainer>
|
||||
<StyledToggleContainer>
|
||||
<StyledLabel htmlFor={inputId}>Advanced:</StyledLabel>
|
||||
|
||||
<Toggle
|
||||
id={inputId}
|
||||
onChange={onChange}
|
||||
color={MAIN_COLORS.yellow}
|
||||
value={isAdvancedModeEnabled}
|
||||
/>
|
||||
</StyledToggleContainer>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,47 @@
|
||||
import * as React from 'react';
|
||||
import { Theme, withTheme } from '@emotion/react';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
const StyledClickableLink = withTheme(styled.a<{
|
||||
theme: Theme;
|
||||
maxWidth?: number;
|
||||
}>`
|
||||
color: inherit;
|
||||
overflow: hidden;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: ${({ theme }) => theme.border.color.strong};
|
||||
text-overflow: ellipsis;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
|
||||
max-width: ${({ maxWidth }) => maxWidth ?? '100%'};
|
||||
|
||||
&:hover {
|
||||
text-decoration-color: ${({ theme }) => theme.font.color.primary};
|
||||
}
|
||||
`);
|
||||
|
||||
type ContactLinkProps = {
|
||||
href: string;
|
||||
children?: React.ReactNode;
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
maxWidth?: number;
|
||||
};
|
||||
|
||||
export const ContactLink = ({
|
||||
href,
|
||||
children,
|
||||
onClick,
|
||||
maxWidth,
|
||||
}: ContactLinkProps) => (
|
||||
<StyledClickableLink
|
||||
maxWidth={maxWidth}
|
||||
target="_blank"
|
||||
onClick={onClick}
|
||||
href={href}
|
||||
>
|
||||
{children}
|
||||
</StyledClickableLink>
|
||||
);
|
||||
@ -0,0 +1,19 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { IconBrandGithub } from '@ui/display';
|
||||
import { ActionLink } from '@ui/navigation/link/components/ActionLink';
|
||||
import { GITHUB_LINK } from '../constants/GithubLink';
|
||||
|
||||
interface GithubVersionLinkProps {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export const GithubVersionLink = ({ version }: GithubVersionLinkProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<ActionLink href={GITHUB_LINK} target="_blank" rel="noreferrer">
|
||||
<IconBrandGithub size={theme.icon.size.md} />
|
||||
{version}
|
||||
</ActionLink>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import { Link as ReactLink } from 'react-router-dom';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
type RawLinkProps = {
|
||||
className?: string;
|
||||
href: string;
|
||||
children?: React.ReactNode;
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
const StyledClickable = styled.div`
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
`;
|
||||
|
||||
export const RawLink = ({
|
||||
className,
|
||||
href,
|
||||
children,
|
||||
onClick,
|
||||
}: RawLinkProps) => (
|
||||
<div>
|
||||
<StyledClickable className={className}>
|
||||
<ReactLink target="_blank" onClick={onClick} to={href}>
|
||||
{children}
|
||||
</ReactLink>
|
||||
</StyledClickable>
|
||||
</div>
|
||||
);
|
||||
@ -0,0 +1,96 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { FONT_COMMON, THEME_COMMON, ThemeContext } from '@ui/theme';
|
||||
import { MouseEvent, useContext } from 'react';
|
||||
|
||||
type RoundedLinkProps = {
|
||||
href: string;
|
||||
label?: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
const fontSizeMd = FONT_COMMON.size.md;
|
||||
const spacing1 = THEME_COMMON.spacing(1);
|
||||
const spacing2 = THEME_COMMON.spacing(2);
|
||||
|
||||
const spacingMultiplicator = THEME_COMMON.spacingMultiplicator;
|
||||
|
||||
const StyledLink = styled.a<{
|
||||
color: string;
|
||||
background: string;
|
||||
backgroundHover: string;
|
||||
backgroundActive: string;
|
||||
border: string;
|
||||
}>`
|
||||
align-items: center;
|
||||
background-color: ${({ background }) => background};
|
||||
border: 1px solid ${({ border }) => border};
|
||||
|
||||
border-radius: 50px;
|
||||
color: ${({ color }) => color};
|
||||
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-weight: ${fontSizeMd};
|
||||
|
||||
gap: ${spacing1};
|
||||
|
||||
height: 10px;
|
||||
justify-content: center;
|
||||
|
||||
max-width: calc(100% - ${spacingMultiplicator} * 2px);
|
||||
|
||||
min-width: fit-content;
|
||||
|
||||
overflow: hidden;
|
||||
padding: ${spacing1} ${spacing2};
|
||||
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background-color: ${({ backgroundHover }) => backgroundHover};
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: ${({ backgroundActive }) => backgroundActive};
|
||||
}
|
||||
`;
|
||||
|
||||
export const RoundedLink = ({ label, href, onClick }: RoundedLinkProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
const background = theme.background.transparent.lighter;
|
||||
const backgroundHover = theme.background.transparent.light;
|
||||
const backgroundActive = theme.background.transparent.medium;
|
||||
const border = theme.border.color.strong;
|
||||
const color = theme.font.color.primary;
|
||||
|
||||
if (!isNonEmptyString(label)) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
|
||||
onClick?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledLink
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleClick}
|
||||
color={color}
|
||||
background={background}
|
||||
backgroundHover={backgroundHover}
|
||||
backgroundActive={backgroundActive}
|
||||
border={border}
|
||||
>
|
||||
{label}
|
||||
</StyledLink>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { getDisplayValueByUrlType } from '@ui/utilities';
|
||||
import { RoundedLink } from './RoundedLink';
|
||||
|
||||
export enum LinkType {
|
||||
Url = 'url',
|
||||
LinkedIn = 'linkedin',
|
||||
Twitter = 'twitter',
|
||||
}
|
||||
|
||||
type SocialLinkProps = {
|
||||
label: string;
|
||||
href: string;
|
||||
type: LinkType;
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
export const SocialLink = ({ label, href, onClick, type }: SocialLinkProps) => {
|
||||
const displayValue =
|
||||
getDisplayValueByUrlType({ type: type, href: href }) ?? label;
|
||||
|
||||
return <RoundedLink href={href} onClick={onClick} label={displayValue} />;
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
import styled from '@emotion/styled';
|
||||
import React from 'react';
|
||||
import { Link, LinkProps } from 'react-router-dom';
|
||||
|
||||
type StyledLinkProps = LinkProps & {
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
const StyledUndecoratedLink = styled(
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
({ fullWidth: _, ...props }: StyledLinkProps) => <Link {...props} />,
|
||||
)<StyledLinkProps>`
|
||||
text-decoration: none;
|
||||
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
|
||||
`;
|
||||
|
||||
type UndecoratedLinkProps = {
|
||||
to: string | number;
|
||||
children: React.ReactNode;
|
||||
replace?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
export const UndecoratedLink = ({
|
||||
children,
|
||||
to,
|
||||
replace = false,
|
||||
onClick,
|
||||
fullWidth = false,
|
||||
}: UndecoratedLinkProps) => {
|
||||
return (
|
||||
<StyledUndecoratedLink
|
||||
to={to as string}
|
||||
replace={replace}
|
||||
onClick={onClick}
|
||||
fullWidth={fullWidth}
|
||||
>
|
||||
{children}
|
||||
</StyledUndecoratedLink>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,26 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { ActionLink } from '@ui/navigation/link/components/ActionLink';
|
||||
import { ComponentDecorator } from '@ui/testing';
|
||||
|
||||
const meta: Meta<typeof ActionLink> = {
|
||||
title: 'UI/navigation/link/ActionLink',
|
||||
component: ActionLink,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ActionLink>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'Need to reset your password?',
|
||||
onClick: () => alert('Action link clicked'),
|
||||
target: undefined,
|
||||
rel: undefined,
|
||||
},
|
||||
argTypes: {
|
||||
href: { control: false },
|
||||
target: { type: 'string' },
|
||||
rel: { type: 'string' },
|
||||
},
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { fn } from '@storybook/test';
|
||||
|
||||
import { ComponentWithRouterDecorator } from '@ui/testing';
|
||||
import { ContactLink } from '../ContactLink';
|
||||
|
||||
const meta: Meta<typeof ContactLink> = {
|
||||
title: 'UI/Navigation/Link/ContactLink',
|
||||
component: ContactLink,
|
||||
decorators: [ComponentWithRouterDecorator],
|
||||
args: {
|
||||
href: '/test',
|
||||
children: 'Contact Link',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ContactLink>;
|
||||
const clickJestFn = fn();
|
||||
|
||||
export const Email: Story = {
|
||||
args: {
|
||||
href: `mailto:${'email@example.com'}`,
|
||||
children: 'email@example.com',
|
||||
onClick: clickJestFn,
|
||||
},
|
||||
};
|
||||
|
||||
export const Phone: Story = {
|
||||
args: {
|
||||
children: '11111111111',
|
||||
onClick: clickJestFn,
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,15 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { ComponentWithRouterDecorator } from '@ui/testing';
|
||||
import { GithubVersionLink } from '../GithubVersionLink';
|
||||
|
||||
const meta: Meta<typeof GithubVersionLink> = {
|
||||
title: 'UI/Navigation/Link/GithubVersionLink',
|
||||
component: GithubVersionLink,
|
||||
decorators: [ComponentWithRouterDecorator],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof GithubVersionLink>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@ -0,0 +1,35 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { expect, fn, userEvent, within } from '@storybook/test';
|
||||
|
||||
import { ComponentWithRouterDecorator } from '@ui/testing';
|
||||
import { RawLink } from '../RawLink';
|
||||
|
||||
const meta: Meta<typeof RawLink> = {
|
||||
title: 'UI/Navigation/Link/RawLink',
|
||||
component: RawLink,
|
||||
decorators: [ComponentWithRouterDecorator],
|
||||
args: {
|
||||
className: 'RawLink',
|
||||
href: '/test',
|
||||
children: 'Raw Link',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RawLink>;
|
||||
const clickJestFn = fn();
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
onClick: clickJestFn,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(clickJestFn).toHaveBeenCalledTimes(0);
|
||||
const link = canvas.getByRole('link');
|
||||
await userEvent.click(link);
|
||||
|
||||
await expect(clickJestFn).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { expect, fn, userEvent, within } from '@storybook/test';
|
||||
|
||||
import { ComponentWithRouterDecorator } from '@ui/testing';
|
||||
import { RoundedLink } from '../RoundedLink';
|
||||
|
||||
const meta: Meta<typeof RoundedLink> = {
|
||||
title: 'UI/Navigation/Link/RoundedLink',
|
||||
component: RoundedLink,
|
||||
decorators: [ComponentWithRouterDecorator],
|
||||
args: {
|
||||
href: '/test',
|
||||
label: 'Rounded chip',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RoundedLink>;
|
||||
const clickJestFn = fn();
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
onClick: clickJestFn,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(clickJestFn).toHaveBeenCalledTimes(0);
|
||||
const link = canvas.getByRole('link');
|
||||
await userEvent.click(link);
|
||||
|
||||
await expect(clickJestFn).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,49 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { expect, fn, userEvent, within } from '@storybook/test';
|
||||
|
||||
import { ComponentWithRouterDecorator } from '@ui/testing';
|
||||
import { LinkType, SocialLink } from '../SocialLink';
|
||||
|
||||
const meta: Meta<typeof SocialLink> = {
|
||||
title: 'UI/Navigation/Link/SocialLink',
|
||||
component: SocialLink,
|
||||
decorators: [ComponentWithRouterDecorator],
|
||||
args: {
|
||||
href: '/test',
|
||||
label: 'Social Link',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SocialLink>;
|
||||
const clickJestFn = fn();
|
||||
|
||||
const linkedin: LinkType = LinkType.LinkedIn;
|
||||
const twitter: LinkType = LinkType.Twitter;
|
||||
|
||||
export const LinkedIn: Story = {
|
||||
args: {
|
||||
href: '/LinkedIn',
|
||||
label: 'LinkedIn',
|
||||
onClick: clickJestFn,
|
||||
type: linkedin,
|
||||
},
|
||||
};
|
||||
|
||||
export const Twitter: Story = {
|
||||
args: {
|
||||
href: '/Twitter',
|
||||
label: 'Twitter',
|
||||
onClick: clickJestFn,
|
||||
type: twitter,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(clickJestFn).toHaveBeenCalledTimes(0);
|
||||
const link = canvas.getByRole('link');
|
||||
await userEvent.click(link);
|
||||
|
||||
await expect(clickJestFn).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
import { expect } from '@storybook/jest';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { userEvent, within } from '@storybook/test';
|
||||
import { UndecoratedLink } from '@ui/navigation/link/components/UndecoratedLink';
|
||||
import { ComponentWithRouterDecorator } from '@ui/testing';
|
||||
|
||||
const meta: Meta<typeof UndecoratedLink> = {
|
||||
title: 'UI/navigation/link/UndecoratedLink',
|
||||
component: UndecoratedLink,
|
||||
decorators: [ComponentWithRouterDecorator],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof UndecoratedLink>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'Go Home',
|
||||
to: '/home',
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const link = canvas.getByText('Go Home');
|
||||
|
||||
await userEvent.click(link);
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
expect(href).toBe('/home');
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
export * from './ActionLink';
|
||||
export * from './AdvancedSettingsToggle';
|
||||
export * from './ContactLink';
|
||||
export * from './GithubVersionLink';
|
||||
export * from './RawLink';
|
||||
export * from './RoundedLink';
|
||||
export * from './SocialLink';
|
||||
export * from './UndecoratedLink';
|
||||
1
packages/twenty-ui/src/navigation/link/constants/Cal.ts
Normal file
1
packages/twenty-ui/src/navigation/link/constants/Cal.ts
Normal file
@ -0,0 +1 @@
|
||||
export const CAL_LINK = 'https://cal.com/team/twenty/talk-to-us';
|
||||
@ -0,0 +1 @@
|
||||
export const GITHUB_LINK = 'https://github.com/twentyhq/twenty';
|
||||
3
packages/twenty-ui/src/navigation/link/index.ts
Normal file
3
packages/twenty-ui/src/navigation/link/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './components';
|
||||
export * from './constants/Cal';
|
||||
export * from './constants/GithubLink';
|
||||
@ -0,0 +1,82 @@
|
||||
import { Decorator } from '@storybook/react';
|
||||
import {
|
||||
createMemoryRouter,
|
||||
createRoutesFromElements,
|
||||
Outlet,
|
||||
Route,
|
||||
RouterProvider,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { ComponentStorybookLayout } from '../ComponentStorybookLayout';
|
||||
|
||||
interface StrictArgs {
|
||||
[name: string]: unknown;
|
||||
}
|
||||
export type RouteParams = {
|
||||
[param: string]: string;
|
||||
};
|
||||
|
||||
export const isRouteParams = (obj: any): obj is RouteParams => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.keys(obj).every((key) => typeof obj[key] === 'string');
|
||||
};
|
||||
|
||||
export const computeLocation = (
|
||||
routePath: string,
|
||||
routeParams?: RouteParams,
|
||||
) => {
|
||||
return {
|
||||
pathname: routePath.replace(
|
||||
/:(\w+)/g,
|
||||
(paramName) => routeParams?.[paramName] ?? '',
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const Providers = () => (
|
||||
<ComponentStorybookLayout>
|
||||
<Outlet />
|
||||
</ComponentStorybookLayout>
|
||||
);
|
||||
|
||||
const createRouter = ({
|
||||
Story,
|
||||
args,
|
||||
initialEntries,
|
||||
initialIndex,
|
||||
}: {
|
||||
Story: () => JSX.Element;
|
||||
args: StrictArgs;
|
||||
initialEntries?: {
|
||||
pathname: string;
|
||||
}[];
|
||||
initialIndex?: number;
|
||||
}) =>
|
||||
createMemoryRouter(
|
||||
createRoutesFromElements(
|
||||
<Route element={<Providers />}>
|
||||
<Route path={(args.routePath as string) ?? '*'} element={<Story />} />
|
||||
</Route>,
|
||||
),
|
||||
{ initialEntries, initialIndex },
|
||||
);
|
||||
|
||||
export const ComponentWithRouterDecorator: Decorator = (Story, { args }) => {
|
||||
return (
|
||||
<RouterProvider
|
||||
router={createRouter({
|
||||
Story,
|
||||
args,
|
||||
initialEntries:
|
||||
args.routePath &&
|
||||
typeof args.routePath === 'string' &&
|
||||
(args.routeParams === undefined || isRouteParams(args.routeParams))
|
||||
? [computeLocation(args.routePath, args.routeParams)]
|
||||
: [{ pathname: '/' }],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
export * from './ComponentStorybookLayout';
|
||||
export * from './decorators/CatalogDecorator';
|
||||
export * from './decorators/ComponentDecorator';
|
||||
export * from './decorators/ComponentWithRouterDecorator';
|
||||
export * from './decorators/RouterDecorator';
|
||||
export * from './mocks/avatarUrlMock';
|
||||
export * from './types/CatalogStory';
|
||||
|
||||
34
packages/twenty-ui/src/utilities/getDisplayValueByUrlType.ts
Normal file
34
packages/twenty-ui/src/utilities/getDisplayValueByUrlType.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { LinkType } from '@ui/navigation/link';
|
||||
import { isDefined } from './isDefined';
|
||||
|
||||
type getUrlDisplayValueByUrlTypeProps = {
|
||||
type: LinkType;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export const getDisplayValueByUrlType = ({
|
||||
type,
|
||||
href,
|
||||
}: getUrlDisplayValueByUrlTypeProps) => {
|
||||
if (type === 'linkedin') {
|
||||
const matches = href.match(
|
||||
/(?:https?:\/\/)?(?:www.)?linkedin.com\/(?:in|company|school)\/(.*)/,
|
||||
);
|
||||
if (isDefined(matches?.[1])) {
|
||||
return decodeURIComponent(matches?.[1]);
|
||||
} else {
|
||||
return 'LinkedIn';
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'twitter') {
|
||||
const matches = href.match(
|
||||
/(?:https?:\/\/)?(?:www.)?twitter.com\/([-a-zA-Z0-9@:%_+.~#?&//=]*)/,
|
||||
);
|
||||
if (isDefined(matches?.[1])) {
|
||||
return `@${matches?.[1]}`;
|
||||
} else {
|
||||
return '@twitter';
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
export * from './color/utils/stringToHslColor';
|
||||
export * from './getDisplayValueByUrlType';
|
||||
export * from './image/getImageAbsoluteURI';
|
||||
export * from './isDefined';
|
||||
export * from './screen-size/hooks/useScreenSize';
|
||||
export * from './state/utils/createState';
|
||||
export * from './types/Nullable';
|
||||
export * from './screen-size/hooks/useScreenSize';
|
||||
|
||||
Reference in New Issue
Block a user