fix(front): honor user-set label on LINKS/URL social links (#22586)

## Problem

On LINKS and URL fields, recognized social links **always** rendered the
derived handle (e.g. `@cristiano`) and ignored any user-set `label`.
This was a regression: `SocialLink` did `getDisplayValueByUrlType(...)
?? label`, and since a provider always matches for social links, `label`
was never reached. Adding the Instagram/TikTok/Bluesky providers in
v2.16 widened the set of affected links.

| Input | Expected | Before |
|-------|----------|--------|
| `instagram.com/cristiano`, label `Cristiano Ronaldo Official` |
`Cristiano Ronaldo Official` | `@cristiano` |

## Fix (display half of #22265)

- `SocialLink`: prefer a non-empty `label`; only derive the handle from
the URL as fallback (then `href`). Prop widened to `string \| null`.
- `LinksDisplay` / `LinkDisplay` / `URLDisplay`: pass the **raw,
nullable** label into `SocialLink` instead of a pre-coalesced string, so
derivation still works when no label is set. `URLDisplay` passes
`label={null}` (URL fields have no label) so handles still render.
- Stories: dropped `label` args the old code silently ignored (keeps
existing visual snapshots stable) and added a `WithCustomLabel` story
asserting precedence.

## What's left (not in this PR)

The **label input in the UI** (issue's second half) is intentionally
deferred. `MultiItemFieldInput` carries the in-progress edit as a single
string and seeds edits with the URL only, so exposing a Label field
cleanly requires a small generalization of that shared component (not a
JSON-serialization workaround). That change needs manual in-app
verification and will be a follow-up.

## Verification

- `twenty-ui` typecheck clean; oxlint clean on all changed files;
`getDisplayValueByUrlType` tests pass (38).
- Added Storybook `play` assertion for the custom-label case.

Fixes #22265 (display half). Related: #16414.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22586?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. -->
This commit is contained in:
Thomas Trompette
2026-07-07 13:03:29 +02:00
committed by GitHub
parent 3bacf7a24b
commit 99f54d9ea8
6 changed files with 71 additions and 22 deletions
@@ -24,7 +24,7 @@ export const LinkDisplay = ({ value }: LinkDisplayProps) => {
const type = checkUrlType(absoluteUrl);
if (isSocialLinkType(type)) {
return <SocialLink href={absoluteUrl} type={type} label={displayedValue} />;
return <SocialLink href={absoluteUrl} type={type} label={value.label} />;
}
return <RoundedLink href={absoluteUrl} label={displayedValue} />;
@@ -35,7 +35,8 @@ export const LinksDisplay = ({ value, onLinkClick }: LinksDisplayProps) => {
}
return {
url: absoluteUrl,
label: label || hostname,
label,
displayLabel: label || hostname,
type: checkUrlType(absoluteUrl),
};
});
@@ -43,7 +44,7 @@ export const LinksDisplay = ({ value, onLinkClick }: LinksDisplayProps) => {
return (
<ExpandableList>
{links.map(({ url, label, type }, index) =>
{links.map(({ url, label, displayLabel, type }, index) =>
isSocialLinkType(type) ? (
<SocialLink
key={index}
@@ -56,7 +57,7 @@ export const LinksDisplay = ({ value, onLinkClick }: LinksDisplayProps) => {
<RoundedLink
key={index}
href={url}
label={label}
label={displayLabel}
onClick={(event) => onLinkClick?.(url, event)}
/>
),
@@ -28,7 +28,7 @@ export const URLDisplay = ({ value }: URLDisplayProps) => {
href={absoluteUrl}
onClick={handleClick}
type={type}
label={displayedValue}
label={null}
/>
</EllipsisDisplay>
);
@@ -125,12 +125,12 @@ export const SocialMediaLinks: Story = {
args: {
value: {
primaryLinkUrl: 'https://www.linkedin.com/company/twenty',
primaryLinkLabel: 'Twenty on LinkedIn',
primaryLinkLabel: '',
secondaryLinks: [
{ url: 'https://twitter.com/twentycrm', label: 'Twenty on Twitter' },
{ url: 'https://twitter.com/twentycrm', label: null },
{
url: 'https://www.instagram.com/twenty_hq',
label: 'Twenty on Instagram',
label: null,
},
],
},
@@ -170,10 +170,10 @@ export const InstagramLinks: Story = {
args: {
value: {
primaryLinkUrl: 'https://www.instagram.com/twenty_hq',
primaryLinkLabel: 'Twenty on Instagram',
primaryLinkLabel: '',
secondaryLinks: [
{ url: 'https://instagram.com/p/ABC123', label: 'A post' },
{ url: 'https://instagram.com/reel/XYZ789', label: 'A reel' },
{ url: 'https://instagram.com/p/ABC123', label: null },
{ url: 'https://instagram.com/reel/XYZ789', label: null },
],
},
},
@@ -197,6 +197,43 @@ export const InstagramLinks: Story = {
},
};
export const SocialLinksWithCustomLabels: Story = {
args: {
value: {
primaryLinkUrl: 'https://www.linkedin.com/company/twenty',
primaryLinkLabel: 'Twenty on LinkedIn',
secondaryLinks: [
{
url: 'https://www.instagram.com/twenty_hq',
label: 'Twenty on Instagram',
},
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
const links = canvas.queryAllByRole('link');
expect(links).toHaveLength(2);
});
const linkedinLink = await canvas.findByText('Twenty on LinkedIn');
expect(linkedinLink).toBeVisible();
expect(linkedinLink).toHaveAttribute(
'href',
'https://www.linkedin.com/company/twenty',
);
const instagramLink = await canvas.findByText('Twenty on Instagram');
expect(instagramLink).toBeVisible();
expect(instagramLink).toHaveAttribute(
'href',
'https://www.instagram.com/twenty_hq',
);
},
};
export const AutomaticLabelFromURL: Story = {
args: {
value: {
@@ -1,19 +1,22 @@
import * as React from 'react';
import { isNonEmptyString } from '@sniptt/guards';
import { type LinkType } from '@ui/navigation/SocialLink/LinkType';
import { RoundedLink } from '@ui/navigation/RoundedLink/RoundedLink';
import { getDisplayValueByUrlType } from '@ui/utilities';
type SocialLinkProps = {
label: string;
label?: string | null;
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;
const displayValue = isNonEmptyString(label)
? label
: (getDisplayValueByUrlType({ type: type, href: href }) ?? href);
return <RoundedLink href={href} onClick={onClick} label={displayValue} />;
};
@@ -11,7 +11,6 @@ const meta: Meta<typeof SocialLink> = {
decorators: [ComponentWithRouterDecorator],
args: {
href: 'https://twenty.com',
label: 'Social Link',
},
};
@@ -22,7 +21,6 @@ const clickJestFn = fn();
export const LinkedIn: Story = {
args: {
href: 'https://www.linkedin.com/in/johndoe',
label: 'LinkedIn',
onClick: clickJestFn,
type: LinkType.LinkedIn,
},
@@ -31,7 +29,6 @@ export const LinkedIn: Story = {
export const Twitter: Story = {
args: {
href: 'https://twitter.com/johndoe',
label: 'Twitter',
onClick: clickJestFn,
type: LinkType.Twitter,
},
@@ -49,7 +46,6 @@ export const Twitter: Story = {
export const X: Story = {
args: {
href: 'https://x.com/johndoe',
label: 'X',
onClick: clickJestFn,
type: LinkType.Twitter,
},
@@ -58,7 +54,6 @@ export const X: Story = {
export const Facebook: Story = {
args: {
href: 'https://www.facebook.com/johndoe',
label: 'Facebook',
onClick: clickJestFn,
type: LinkType.Facebook,
},
@@ -67,7 +62,6 @@ export const Facebook: Story = {
export const Instagram: Story = {
args: {
href: 'https://www.instagram.com/johndoe',
label: 'Instagram',
onClick: clickJestFn,
type: LinkType.Instagram,
},
@@ -76,7 +70,6 @@ export const Instagram: Story = {
export const TikTok: Story = {
args: {
href: 'https://www.tiktok.com/@johndoe',
label: 'TikTok',
onClick: clickJestFn,
type: LinkType.TikTok,
},
@@ -85,8 +78,23 @@ export const TikTok: Story = {
export const Bluesky: Story = {
args: {
href: 'https://bsky.app/profile/johndoe.bsky.social',
label: 'Bluesky',
onClick: clickJestFn,
type: LinkType.Bluesky,
},
};
export const WithCustomLabel: Story = {
args: {
href: 'https://www.instagram.com/cristiano',
label: 'Cristiano Ronaldo Official',
onClick: clickJestFn,
type: LinkType.Instagram,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText('Cristiano Ronaldo Official'),
).toBeInTheDocument();
},
};