feat(front): render Instagram URLs as @handles in link fields (#21642)
LinkedIn and X links already show a readable handle in Twenty's link fields. Instagram doesn't — it just shows `instagram.com`, which isn't much help when you're scanning a record. This adds the same handling for Instagram. `instagram.com/ptcrash` now shows as `@ptcrash`, in tables, on record pages, and in the edit menu. Post and reel links (`/p/...`, `/reel/...`) have no handle, so they fall back to `Instagram`. How it works: - `Instagram` added to the `LinkType` enum - `checkUrlType` detects `instagram.com` - `getDisplayValueByUrlType` pulls the handle and prefixes `@` - a shared `isSocialLinkType` helper keeps the three display components in sync Tested with unit tests for both helpers, the updated story, and manually against a record whose Instagram field is `http://instagram.com/ptcrash`. Closes #21644 Co-authored-by: Johnny Martin <ptcrash@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { LinkType, RoundedLink, SocialLink } from 'twenty-ui/navigation';
|
||||
import { RoundedLink, SocialLink } from 'twenty-ui/navigation';
|
||||
import { checkUrlType } from '~/utils/checkUrlType';
|
||||
import { isSocialLinkType } from '~/utils/isSocialLinkType';
|
||||
import { getSafeUrl } from 'twenty-shared/utils';
|
||||
|
||||
type LinkDisplayProps = {
|
||||
@@ -22,11 +23,7 @@ export const LinkDisplay = ({ value }: LinkDisplayProps) => {
|
||||
|
||||
const type = checkUrlType(absoluteUrl);
|
||||
|
||||
if (
|
||||
type === LinkType.LinkedIn ||
|
||||
type === LinkType.Twitter ||
|
||||
type === LinkType.Facebook
|
||||
) {
|
||||
if (isSocialLinkType(type)) {
|
||||
return <SocialLink href={absoluteUrl} type={type} label={displayedValue} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
getUrlHostnameOrThrow,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
import { LinkType, RoundedLink, SocialLink } from 'twenty-ui/navigation';
|
||||
import { RoundedLink, SocialLink } from 'twenty-ui/navigation';
|
||||
import { checkUrlType } from '~/utils/checkUrlType';
|
||||
import { isSocialLinkType } from '~/utils/isSocialLinkType';
|
||||
|
||||
type LinksDisplayProps = {
|
||||
value?: FieldLinksValue;
|
||||
@@ -43,9 +44,7 @@ export const LinksDisplay = ({ value, onLinkClick }: LinksDisplayProps) => {
|
||||
return (
|
||||
<ExpandableList>
|
||||
{links.map(({ url, label, type }, index) =>
|
||||
type === LinkType.LinkedIn ||
|
||||
type === LinkType.Twitter ||
|
||||
type === LinkType.Facebook ? (
|
||||
isSocialLinkType(type) ? (
|
||||
<SocialLink
|
||||
key={index}
|
||||
href={url}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type MouseEvent } from 'react';
|
||||
|
||||
import { LinkType, RoundedLink, SocialLink } from 'twenty-ui/navigation';
|
||||
import { RoundedLink, SocialLink } from 'twenty-ui/navigation';
|
||||
import { checkUrlType } from '~/utils/checkUrlType';
|
||||
import { isSocialLinkType } from '~/utils/isSocialLinkType';
|
||||
import { getSafeUrl } from 'twenty-shared/utils';
|
||||
import { EllipsisDisplay } from './EllipsisDisplay';
|
||||
|
||||
@@ -20,11 +21,7 @@ export const URLDisplay = ({ value }: URLDisplayProps) => {
|
||||
|
||||
const type = checkUrlType(absoluteUrl);
|
||||
|
||||
if (
|
||||
type === LinkType.LinkedIn ||
|
||||
type === LinkType.Twitter ||
|
||||
type === LinkType.Facebook
|
||||
) {
|
||||
if (isSocialLinkType(type)) {
|
||||
return (
|
||||
<EllipsisDisplay>
|
||||
<SocialLink
|
||||
|
||||
+43
-1
@@ -128,6 +128,10 @@ export const SocialMediaLinks: Story = {
|
||||
primaryLinkLabel: 'Twenty on LinkedIn',
|
||||
secondaryLinks: [
|
||||
{ url: 'https://twitter.com/twentycrm', label: 'Twenty on Twitter' },
|
||||
{
|
||||
url: 'https://www.instagram.com/twenty_hq',
|
||||
label: 'Twenty on Instagram',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -136,7 +140,7 @@ export const SocialMediaLinks: Story = {
|
||||
|
||||
await waitFor(() => {
|
||||
const links = canvas.queryAllByRole('link');
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links).toHaveLength(3);
|
||||
});
|
||||
|
||||
const linkedinLink = await canvas.findByText('twenty');
|
||||
@@ -152,6 +156,44 @@ export const SocialMediaLinks: Story = {
|
||||
'href',
|
||||
'https://twitter.com/twentycrm',
|
||||
);
|
||||
|
||||
const instagramLink = await canvas.findByText('@twenty_hq');
|
||||
expect(instagramLink).toBeVisible();
|
||||
expect(instagramLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.instagram.com/twenty_hq',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const InstagramLinks: Story = {
|
||||
args: {
|
||||
value: {
|
||||
primaryLinkUrl: 'https://www.instagram.com/twenty_hq',
|
||||
primaryLinkLabel: 'Twenty on Instagram',
|
||||
secondaryLinks: [
|
||||
{ url: 'https://instagram.com/p/ABC123', label: 'A post' },
|
||||
{ url: 'https://instagram.com/reel/XYZ789', label: 'A reel' },
|
||||
],
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await waitFor(() => {
|
||||
const links = canvas.queryAllByRole('link');
|
||||
expect(links).toHaveLength(3);
|
||||
});
|
||||
|
||||
const handleLink = await canvas.findByText('@twenty_hq');
|
||||
expect(handleLink).toBeVisible();
|
||||
expect(handleLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.instagram.com/twenty_hq',
|
||||
);
|
||||
|
||||
const fallbackLinks = await canvas.findAllByText('Instagram');
|
||||
expect(fallbackLinks).toHaveLength(2);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -23,8 +23,16 @@ describe('checkUrlType', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect Instagram urls', () => {
|
||||
expect(checkUrlType('https://www.instagram.com/ptcrash')).toBe(
|
||||
LinkType.Instagram,
|
||||
);
|
||||
expect(checkUrlType('instagram.com/ptcrash')).toBe(LinkType.Instagram);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { isSocialLinkType } from '~/utils/isSocialLinkType';
|
||||
import { LinkType } from 'twenty-ui/navigation';
|
||||
|
||||
describe('isSocialLinkType', () => {
|
||||
it('should return true for social link types', () => {
|
||||
expect(isSocialLinkType(LinkType.LinkedIn)).toBe(true);
|
||||
expect(isSocialLinkType(LinkType.Twitter)).toBe(true);
|
||||
expect(isSocialLinkType(LinkType.Facebook)).toBe(true);
|
||||
expect(isSocialLinkType(LinkType.Instagram)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a generic url type', () => {
|
||||
expect(isSocialLinkType(LinkType.Url)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,9 @@ export const checkUrlType = (url: string) => {
|
||||
if (/^(https?:\/\/)?(www\.)?facebook\.com\/.+$/.test(url)) {
|
||||
return LinkType.Facebook;
|
||||
}
|
||||
if (/^(https?:\/\/)?(www\.)?instagram\.com\/.+$/.test(url)) {
|
||||
return LinkType.Instagram;
|
||||
}
|
||||
|
||||
return LinkType.Url;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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,
|
||||
];
|
||||
|
||||
export const isSocialLinkType = (type: LinkType): boolean =>
|
||||
SOCIAL_LINK_TYPES.includes(type);
|
||||
@@ -8,6 +8,7 @@ export enum LinkType {
|
||||
LinkedIn = 'linkedin',
|
||||
Twitter = 'twitter',
|
||||
Facebook = 'facebook',
|
||||
Instagram = 'instagram',
|
||||
}
|
||||
|
||||
type SocialLinkProps = {
|
||||
|
||||
@@ -137,6 +137,80 @@ describe('getDisplayValueByUrlType', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('instagram', () => {
|
||||
it('should extract handle from Instagram profile URL with @ prefix', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Instagram,
|
||||
href: 'https://www.instagram.com/ptcrash',
|
||||
});
|
||||
expect(result).toBe('@ptcrash');
|
||||
});
|
||||
|
||||
it('should handle Instagram URL without protocol', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Instagram,
|
||||
href: 'instagram.com/ptcrash',
|
||||
});
|
||||
expect(result).toBe('@ptcrash');
|
||||
});
|
||||
|
||||
it('should handle Instagram URL without www', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Instagram,
|
||||
href: 'https://instagram.com/ptcrash',
|
||||
});
|
||||
expect(result).toBe('@ptcrash');
|
||||
});
|
||||
|
||||
it('should ignore a trailing slash', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Instagram,
|
||||
href: 'https://instagram.com/ptcrash/',
|
||||
});
|
||||
expect(result).toBe('@ptcrash');
|
||||
});
|
||||
|
||||
it('should ignore a query string', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Instagram,
|
||||
href: 'https://instagram.com/ptcrash?hl=en',
|
||||
});
|
||||
expect(result).toBe('@ptcrash');
|
||||
});
|
||||
|
||||
it('should decode URL-encoded characters in the handle', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Instagram,
|
||||
href: 'https://instagram.com/john%20doe',
|
||||
});
|
||||
expect(result).toBe('@john doe');
|
||||
});
|
||||
|
||||
it('should return "Instagram" for a post URL', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Instagram,
|
||||
href: 'https://instagram.com/p/ABC123',
|
||||
});
|
||||
expect(result).toBe('Instagram');
|
||||
});
|
||||
|
||||
it('should return "Instagram" for a reel URL', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Instagram,
|
||||
href: 'https://instagram.com/reel/xyz',
|
||||
});
|
||||
expect(result).toBe('Instagram');
|
||||
});
|
||||
|
||||
it('should return "Instagram" when no handle can be extracted', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
type: LinkType.Instagram,
|
||||
href: 'https://www.instagram.com/',
|
||||
});
|
||||
expect(result).toBe('Instagram');
|
||||
});
|
||||
});
|
||||
|
||||
describe('url type', () => {
|
||||
it('should return undefined for generic url type', () => {
|
||||
const result = getDisplayValueByUrlType({
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { type LinkType } from '@ui/navigation';
|
||||
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: LinkType;
|
||||
href: string;
|
||||
@@ -40,4 +52,19 @@ export const getDisplayValueByUrlType = ({
|
||||
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';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user