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:
Johnny Martin
2026-06-23 11:25:50 -04:00
committed by GitHub
parent de610bc4e7
commit d4e4e2612b
11 changed files with 192 additions and 17 deletions
@@ -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
@@ -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';
}
}
};