feat(ui): additional social providers to link components (#21716)
The current link component matches only to linkedin, twitter and facebook. It is currently missing the x handle. In addition to this, we should also accomodate for instagram, bluesky and tiktok. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21716?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@@ -30,9 +30,24 @@ describe('checkUrlType', () => {
|
||||
expect(checkUrlType('instagram.com/ptcrash')).toBe(LinkType.Instagram);
|
||||
});
|
||||
|
||||
it('should detect TikTok urls', () => {
|
||||
expect(checkUrlType('https://www.tiktok.com/@twenty')).toBe(
|
||||
LinkType.TikTok,
|
||||
);
|
||||
expect(checkUrlType('tiktok.com/@twenty')).toBe(LinkType.TikTok);
|
||||
});
|
||||
|
||||
it('should detect Bluesky urls', () => {
|
||||
expect(checkUrlType('https://bsky.app/profile/twenty.bsky.social')).toBe(
|
||||
LinkType.Bluesky,
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to a generic url type', () => {
|
||||
expect(checkUrlType('https://example.com')).toBe(LinkType.Url);
|
||||
expect(checkUrlType('not-a-url')).toBe(LinkType.Url);
|
||||
expect(checkUrlType('https://instagram.com')).toBe(LinkType.Url);
|
||||
expect(checkUrlType('https://www.tiktok.com/video/123')).toBe(LinkType.Url);
|
||||
expect(checkUrlType('https://bsky.app')).toBe(LinkType.Url);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ describe('isSocialLinkType', () => {
|
||||
expect(isSocialLinkType(LinkType.Twitter)).toBe(true);
|
||||
expect(isSocialLinkType(LinkType.Facebook)).toBe(true);
|
||||
expect(isSocialLinkType(LinkType.Instagram)).toBe(true);
|
||||
expect(isSocialLinkType(LinkType.TikTok)).toBe(true);
|
||||
expect(isSocialLinkType(LinkType.Bluesky)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a generic url type', () => {
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import { LinkType } from 'twenty-ui/navigation';
|
||||
export const checkUrlType = (url: string) => {
|
||||
if (/^(https?:\/\/)?(www\.)?linkedin\.com\/.+$/.test(url)) {
|
||||
return LinkType.LinkedIn;
|
||||
}
|
||||
if (/^(https?:\/\/)?(www\.)?twitter\.com\/.+$/.test(url)) {
|
||||
return LinkType.Twitter;
|
||||
}
|
||||
if (/^(https?:\/\/)?(www\.)?x\.com\/.+$/.test(url)) {
|
||||
return LinkType.Twitter;
|
||||
}
|
||||
if (/^(https?:\/\/)?(www\.)?facebook\.com\/.+$/.test(url)) {
|
||||
return LinkType.Facebook;
|
||||
}
|
||||
if (/^(https?:\/\/)?(www\.)?instagram\.com\/.+$/.test(url)) {
|
||||
return LinkType.Instagram;
|
||||
}
|
||||
import { LinkType, SOCIAL_LINK_PROVIDERS } from 'twenty-ui/navigation';
|
||||
|
||||
return LinkType.Url;
|
||||
export const checkUrlType = (url: string) => {
|
||||
const provider = SOCIAL_LINK_PROVIDERS.find((socialLinkProvider) =>
|
||||
socialLinkProvider.detectPattern.test(url),
|
||||
);
|
||||
|
||||
return provider?.type ?? LinkType.Url;
|
||||
};
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { LinkType } from 'twenty-ui/navigation';
|
||||
|
||||
// Link types rendered as a social handle (via SocialLink) instead of a plain RoundedLink
|
||||
const SOCIAL_LINK_TYPES = [
|
||||
LinkType.LinkedIn,
|
||||
LinkType.Twitter,
|
||||
LinkType.Facebook,
|
||||
LinkType.Instagram,
|
||||
];
|
||||
import { type LinkType, SOCIAL_LINK_PROVIDERS } from 'twenty-ui/navigation';
|
||||
|
||||
export const isSocialLinkType = (type: LinkType): boolean =>
|
||||
SOCIAL_LINK_TYPES.includes(type);
|
||||
SOCIAL_LINK_PROVIDERS.some(
|
||||
(socialLinkProvider) => socialLinkProvider.type === type,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export enum LinkType {
|
||||
Url = 'url',
|
||||
LinkedIn = 'linkedin',
|
||||
Twitter = 'twitter',
|
||||
Facebook = 'facebook',
|
||||
Instagram = 'instagram',
|
||||
TikTok = 'tiktok',
|
||||
Bluesky = 'bluesky',
|
||||
}
|
||||
@@ -1,16 +1,9 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { type LinkType } from '@ui/navigation/SocialLink/LinkType';
|
||||
import { RoundedLink } from '@ui/navigation/RoundedLink/RoundedLink';
|
||||
import { getDisplayValueByUrlType } from '@ui/utilities';
|
||||
|
||||
export enum LinkType {
|
||||
Url = 'url',
|
||||
LinkedIn = 'linkedin',
|
||||
Twitter = 'twitter',
|
||||
Facebook = 'facebook',
|
||||
Instagram = 'instagram',
|
||||
}
|
||||
|
||||
type SocialLinkProps = {
|
||||
label: string;
|
||||
href: string;
|
||||
|
||||
@@ -2,14 +2,15 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { expect, fn, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { ComponentWithRouterDecorator } from '@ui/testing';
|
||||
import { LinkType, SocialLink } from '@ui/navigation/SocialLink/SocialLink';
|
||||
import { LinkType } from '@ui/navigation/SocialLink/LinkType';
|
||||
import { SocialLink } from '@ui/navigation/SocialLink/SocialLink';
|
||||
|
||||
const meta: Meta<typeof SocialLink> = {
|
||||
title: 'UI/Navigation/Link/SocialLink',
|
||||
component: SocialLink,
|
||||
decorators: [ComponentWithRouterDecorator],
|
||||
args: {
|
||||
href: '/test',
|
||||
href: 'https://twenty.com',
|
||||
label: 'Social Link',
|
||||
},
|
||||
};
|
||||
@@ -18,24 +19,21 @@ 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',
|
||||
href: 'https://www.linkedin.com/in/johndoe',
|
||||
label: 'LinkedIn',
|
||||
onClick: clickJestFn,
|
||||
type: linkedin,
|
||||
type: LinkType.LinkedIn,
|
||||
},
|
||||
};
|
||||
|
||||
export const Twitter: Story = {
|
||||
args: {
|
||||
href: '/Twitter',
|
||||
href: 'https://twitter.com/johndoe',
|
||||
label: 'Twitter',
|
||||
onClick: clickJestFn,
|
||||
type: twitter,
|
||||
type: LinkType.Twitter,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -47,3 +45,48 @@ export const Twitter: Story = {
|
||||
await expect(clickJestFn).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
};
|
||||
|
||||
export const X: Story = {
|
||||
args: {
|
||||
href: 'https://x.com/johndoe',
|
||||
label: 'X',
|
||||
onClick: clickJestFn,
|
||||
type: LinkType.Twitter,
|
||||
},
|
||||
};
|
||||
|
||||
export const Facebook: Story = {
|
||||
args: {
|
||||
href: 'https://www.facebook.com/johndoe',
|
||||
label: 'Facebook',
|
||||
onClick: clickJestFn,
|
||||
type: LinkType.Facebook,
|
||||
},
|
||||
};
|
||||
|
||||
export const Instagram: Story = {
|
||||
args: {
|
||||
href: 'https://www.instagram.com/johndoe',
|
||||
label: 'Instagram',
|
||||
onClick: clickJestFn,
|
||||
type: LinkType.Instagram,
|
||||
},
|
||||
};
|
||||
|
||||
export const TikTok: Story = {
|
||||
args: {
|
||||
href: 'https://www.tiktok.com/@johndoe',
|
||||
label: 'TikTok',
|
||||
onClick: clickJestFn,
|
||||
type: LinkType.TikTok,
|
||||
},
|
||||
};
|
||||
|
||||
export const Bluesky: Story = {
|
||||
args: {
|
||||
href: 'https://bsky.app/profile/johndoe.bsky.social',
|
||||
label: 'Bluesky',
|
||||
onClick: clickJestFn,
|
||||
type: LinkType.Bluesky,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { LinkType } from '@ui/navigation/SocialLink/LinkType';
|
||||
|
||||
export type SocialLinkProvider = {
|
||||
type: LinkType;
|
||||
detectPattern: RegExp;
|
||||
handlePattern: RegExp;
|
||||
reservedPaths: string[];
|
||||
handlePrefix: string;
|
||||
fallbackLabel: string;
|
||||
};
|
||||
|
||||
export const SOCIAL_LINK_PROVIDERS: SocialLinkProvider[] = [
|
||||
{
|
||||
type: LinkType.LinkedIn,
|
||||
detectPattern: /^(?:https?:\/\/)?(?:www\.)?linkedin\.com\/.+$/,
|
||||
handlePattern:
|
||||
/(?:https?:\/\/)?(?:www\.)?linkedin\.com\/(?:in|company|school)\/([^/?#]+)/,
|
||||
reservedPaths: [],
|
||||
handlePrefix: '',
|
||||
fallbackLabel: 'LinkedIn',
|
||||
},
|
||||
{
|
||||
type: LinkType.Twitter,
|
||||
detectPattern: /^(?:https?:\/\/)?(?:www\.)?(?:twitter|x)\.com\/.+$/,
|
||||
handlePattern: /(?:https?:\/\/)?(?:www\.)?(?:twitter|x)\.com\/([^/?#]+)/,
|
||||
reservedPaths: [
|
||||
'home',
|
||||
'explore',
|
||||
'notifications',
|
||||
'messages',
|
||||
'settings',
|
||||
'search',
|
||||
'i',
|
||||
'intent',
|
||||
],
|
||||
handlePrefix: '@',
|
||||
fallbackLabel: 'Twitter',
|
||||
},
|
||||
{
|
||||
type: LinkType.Facebook,
|
||||
detectPattern: /^(?:https?:\/\/)?(?:www\.)?facebook\.com\/.+$/,
|
||||
handlePattern: /(?:https?:\/\/)?(?:www\.)?facebook\.com\/([^/?#]+)/,
|
||||
reservedPaths: [],
|
||||
handlePrefix: '',
|
||||
fallbackLabel: 'Facebook',
|
||||
},
|
||||
{
|
||||
type: LinkType.Instagram,
|
||||
detectPattern: /^(?:https?:\/\/)?(?:www\.)?instagram\.com\/.+$/,
|
||||
handlePattern: /(?:https?:\/\/)?(?:www\.)?instagram\.com\/([^/?#]+)/,
|
||||
reservedPaths: [
|
||||
'p',
|
||||
'reel',
|
||||
'reels',
|
||||
'explore',
|
||||
'stories',
|
||||
'tv',
|
||||
'accounts',
|
||||
'about',
|
||||
],
|
||||
handlePrefix: '@',
|
||||
fallbackLabel: 'Instagram',
|
||||
},
|
||||
{
|
||||
type: LinkType.TikTok,
|
||||
detectPattern: /^(?:https?:\/\/)?(?:www\.)?tiktok\.com\/@.+$/,
|
||||
handlePattern: /(?:https?:\/\/)?(?:www\.)?tiktok\.com\/@([^/?#]+)/,
|
||||
reservedPaths: [],
|
||||
handlePrefix: '@',
|
||||
fallbackLabel: 'TikTok',
|
||||
},
|
||||
{
|
||||
type: LinkType.Bluesky,
|
||||
detectPattern: /^(?:https?:\/\/)?(?:www\.)?bsky\.app\/profile\/.+$/,
|
||||
handlePattern: /(?:https?:\/\/)?(?:www\.)?bsky\.app\/profile\/([^/?#]+)/,
|
||||
reservedPaths: [],
|
||||
handlePrefix: '',
|
||||
fallbackLabel: 'Bluesky',
|
||||
},
|
||||
];
|
||||
@@ -54,5 +54,8 @@ export { NavigationBar } from './NavigationBar/NavigationBar';
|
||||
export { NavigationBarItem } from './NavigationBarItem/NavigationBarItem';
|
||||
export { RawLink } from './RawLink/RawLink';
|
||||
export { RoundedLink } from './RoundedLink/RoundedLink';
|
||||
export { LinkType, SocialLink } from './SocialLink/SocialLink';
|
||||
export { LinkType } from './SocialLink/LinkType';
|
||||
export { SocialLink } from './SocialLink/SocialLink';
|
||||
export type { SocialLinkProvider } from './SocialLink/socialLinkProviders';
|
||||
export { SOCIAL_LINK_PROVIDERS } from './SocialLink/socialLinkProviders';
|
||||
export { UndecoratedLink } from './UndecoratedLink/UndecoratedLink';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LinkType } from '@ui/navigation/SocialLink/SocialLink';
|
||||
import { LinkType } from '@ui/navigation/SocialLink/LinkType';
|
||||
|
||||
import { getDisplayValueByUrlType } from '../getDisplayValueByUrlType';
|
||||
|
||||
@@ -86,12 +86,20 @@ describe('getDisplayValueByUrlType', () => {
|
||||
expect(result).toBe('@johndoe');
|
||||
});
|
||||
|
||||
it('should return "@twitter" when no username can be extracted', () => {
|
||||
it('should return "Twitter" when no username can be extracted', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Twitter,
|
||||
href: 'https://www.twitter.com',
|
||||
});
|
||||
expect(result).toBe('@twitter');
|
||||
expect(result).toBe('Twitter');
|
||||
});
|
||||
|
||||
it('should return "Twitter" for a reserved app route', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Twitter,
|
||||
href: 'https://x.com/home',
|
||||
});
|
||||
expect(result).toBe('Twitter');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,6 +219,100 @@ describe('getDisplayValueByUrlType', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('x', () => {
|
||||
it('should extract handle from an x.com URL as a Twitter type', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Twitter,
|
||||
href: 'https://x.com/johndoe',
|
||||
});
|
||||
expect(result).toBe('@johndoe');
|
||||
});
|
||||
|
||||
it('should handle an x.com URL without protocol', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Twitter,
|
||||
href: 'x.com/johndoe',
|
||||
});
|
||||
expect(result).toBe('@johndoe');
|
||||
});
|
||||
|
||||
it('should ignore the path segments of a tweet permalink', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Twitter,
|
||||
href: 'https://x.com/johndoe/status/123456',
|
||||
});
|
||||
expect(result).toBe('@johndoe');
|
||||
});
|
||||
|
||||
it('should ignore a query string', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Twitter,
|
||||
href: 'https://x.com/johndoe?lang=en',
|
||||
});
|
||||
expect(result).toBe('@johndoe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tiktok', () => {
|
||||
it('should extract handle from a TikTok profile URL', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.TikTok,
|
||||
href: 'https://www.tiktok.com/@johndoe',
|
||||
});
|
||||
expect(result).toBe('@johndoe');
|
||||
});
|
||||
|
||||
it('should handle a TikTok URL without protocol or www', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.TikTok,
|
||||
href: 'tiktok.com/@johndoe',
|
||||
});
|
||||
expect(result).toBe('@johndoe');
|
||||
});
|
||||
|
||||
it('should ignore a trailing path segment', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.TikTok,
|
||||
href: 'https://www.tiktok.com/@johndoe/video/123',
|
||||
});
|
||||
expect(result).toBe('@johndoe');
|
||||
});
|
||||
|
||||
it('should return "TikTok" when no handle can be extracted', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.TikTok,
|
||||
href: 'https://www.tiktok.com/',
|
||||
});
|
||||
expect(result).toBe('TikTok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bluesky', () => {
|
||||
it('should extract handle from a Bluesky profile URL', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Bluesky,
|
||||
href: 'https://bsky.app/profile/johndoe.bsky.social',
|
||||
});
|
||||
expect(result).toBe('johndoe.bsky.social');
|
||||
});
|
||||
|
||||
it('should handle a Bluesky URL without protocol', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Bluesky,
|
||||
href: 'bsky.app/profile/johndoe.bsky.social',
|
||||
});
|
||||
expect(result).toBe('johndoe.bsky.social');
|
||||
});
|
||||
|
||||
it('should return "Bluesky" when no handle can be extracted', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Bluesky,
|
||||
href: 'https://bsky.app/profile/',
|
||||
});
|
||||
expect(result).toBe('Bluesky');
|
||||
});
|
||||
});
|
||||
|
||||
describe('url type', () => {
|
||||
it('should return undefined for generic url type', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import { type LinkType } from '@ui/navigation';
|
||||
import { type LinkType } from '@ui/navigation/SocialLink/LinkType';
|
||||
import { SOCIAL_LINK_PROVIDERS } from '@ui/navigation/SocialLink/socialLinkProviders';
|
||||
import { isDefined } from '@ui/utilities/utils/isDefined';
|
||||
|
||||
// Instagram path segments that are app routes, not user handles
|
||||
const INSTAGRAM_RESERVED_PATHS = [
|
||||
'p',
|
||||
'reel',
|
||||
'reels',
|
||||
'explore',
|
||||
'stories',
|
||||
'tv',
|
||||
'accounts',
|
||||
'about',
|
||||
];
|
||||
|
||||
type getUrlDisplayValueByUrlTypeProps = {
|
||||
type GetDisplayValueByUrlTypeProps = {
|
||||
type: LinkType;
|
||||
href: string;
|
||||
};
|
||||
@@ -21,50 +10,23 @@ type getUrlDisplayValueByUrlTypeProps = {
|
||||
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';
|
||||
}
|
||||
}: GetDisplayValueByUrlTypeProps) => {
|
||||
const provider = SOCIAL_LINK_PROVIDERS.find(
|
||||
(socialLinkProvider) => socialLinkProvider.type === type,
|
||||
);
|
||||
|
||||
if (!isDefined(provider)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
const handle = href.match(provider.handlePattern)?.[1];
|
||||
|
||||
if (
|
||||
!isDefined(handle) ||
|
||||
provider.reservedPaths.includes(handle.toLowerCase())
|
||||
) {
|
||||
return provider.fallbackLabel;
|
||||
}
|
||||
|
||||
if (type === 'facebook') {
|
||||
const matches = href.match(/(?:https?:\/\/)?(?:www.)?facebook.com\/(.+)/);
|
||||
if (isDefined(matches?.[1])) {
|
||||
return decodeURIComponent(matches?.[1]);
|
||||
} else {
|
||||
return 'Facebook';
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'instagram') {
|
||||
const matches = href.match(
|
||||
/(?:https?:\/\/)?(?:www\.)?instagram\.com\/([^/?#]+)/,
|
||||
);
|
||||
const handle = matches?.[1];
|
||||
if (
|
||||
isDefined(handle) &&
|
||||
!INSTAGRAM_RESERVED_PATHS.includes(handle.toLowerCase())
|
||||
) {
|
||||
return `@${decodeURIComponent(handle)}`;
|
||||
} else {
|
||||
return 'Instagram';
|
||||
}
|
||||
}
|
||||
return `${provider.handlePrefix}${decodeURIComponent(handle)}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user