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:
@@ -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>
|
||||
);
|
||||
|
||||
+43
-6
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user