Support multi-value social links and refactor link handling logic

This commit is contained in:
MartinBraquet
2026-05-23 11:56:23 +02:00
parent 8e921e0a53
commit 2c9ffe4632
5 changed files with 119 additions and 43 deletions

View File

@@ -44,6 +44,8 @@ export const zBoolean = z
.union([z.boolean(), z.string()])
.transform((val) => val === true || val === 'true')
const linkValueSchema = z.union([z.string(), z.array(z.string())]).nullable()
// TODO: merge the two below when the deprecated /create-profile is deleted
export const baseProfilesSchema = z.object({
age: z.number().min(18).max(100).optional().nullable(),
@@ -97,7 +99,7 @@ const optionalProfilesSchema = z.object({
image_descriptions: z.any().optional().nullable(),
interests: z.array(z.string()).optional().nullable(),
is_smoker: zBoolean.optional().nullable(),
links: z.record(z.string().nullable()).optional(),
links: z.record(linkValueSchema).optional(),
mbti: z.string().optional().nullable(),
occupation: z.string().optional().nullable(),
occupation_title: z.string().optional().nullable(),

View File

@@ -26,7 +26,24 @@ export const SITE_ORDER = [
export type Site = (typeof SITE_ORDER)[number]
// this is a lie, actually people can have anything in their links
export type Socials = {[key in Site]?: string}
export type SocialValue = string | string[] | null | undefined
export type Socials = {[key: string]: SocialValue}
export const MULTI_VALUE_SITES = ['site'] as const
export const isMultiValueSite = (site: string) =>
(MULTI_VALUE_SITES as readonly string[]).includes(site)
export const getSocialLinkValues = (value: SocialValue) => {
if (Array.isArray(value)) return value
if (value == null) return []
return [value]
}
export const getSocialEntries = (links: Socials | null | undefined) =>
Object.entries(links ?? {}).flatMap(([platform, value]) =>
getSocialLinkValues(value).map((value, index) => ({platform, value, index})),
)
export const strip = (site: Site, input: string) => stripper[site]?.(input) ?? input

View File

@@ -1,5 +1,5 @@
import {discordLink} from 'common/constants'
import {getSocialUrl, strip} from 'common/socials'
import {getSocialEntries, getSocialUrl, strip} from 'common/socials'
describe('strip', () => {
describe('x/twitter', () => {
@@ -81,3 +81,19 @@ describe('getSocialUrl', () => {
expect(getSocialUrl('discord', 'not-an-id')).toBe(discordLink)
})
})
describe('getSocialEntries', () => {
it('flattens multi-value website links while preserving single-value links', () => {
expect(
getSocialEntries({
site: ['example.com', 'blog.example.com'],
github: 'username',
x: null,
}),
).toEqual([
{platform: 'site', value: 'example.com', index: 0},
{platform: 'site', value: 'blog.example.com', index: 1},
{platform: 'github', value: 'username', index: 0},
])
})
})

View File

@@ -1,7 +1,15 @@
import {PlusIcon, XMarkIcon} from '@heroicons/react/24/solid'
import clsx from 'clsx'
import {ProfileWithoutUser} from 'common/profiles/profile'
import {PLATFORM_LABELS, type Site, SITE_ORDER, Socials} from 'common/socials'
import {
getSocialEntries,
getSocialLinkValues,
isMultiValueSite,
PLATFORM_LABELS,
type Site,
SITE_ORDER,
Socials,
} from 'common/socials'
import {removeNullOrUndefinedProps} from 'common/util/object'
import {Fragment, useState} from 'react'
import {Button, IconButton} from 'web/components/buttons/button'
@@ -19,20 +27,47 @@ interface SocialLinksSectionProps {
export function SocialLinksSection({profile, setProfile}: SocialLinksSectionProps) {
const t = useT()
const [newLinkPlatform, setNewLinkPlatform] = useState('')
const [newLinkPlatform, setNewLinkPlatform] = useState('site')
const [newLinkValue, setNewLinkValue] = useState('')
const updateUserLink = (platform: string, value: string | null) => {
setProfile(
'links',
removeNullOrUndefinedProps({...((profile.links as Socials) ?? {}), [platform]: value}),
)
const setLinks = (links: Socials) => {
setProfile('links', removeNullOrUndefinedProps(links))
}
const updateUserLink = (platform: string, value: string | null, index = 0) => {
const links = {...((profile.links as Socials) ?? {})}
const currentValue = links[platform]
if (Array.isArray(currentValue)) {
const nextValues = [...currentValue]
if (value == null) {
nextValues.splice(index, 1)
} else {
nextValues[index] = value
}
setLinks({...links, [platform]: nextValues.length > 0 ? nextValues : null})
return
}
setLinks({...links, [platform]: value})
}
const addNewLink = () => {
if (newLinkPlatform && newLinkValue) {
updateUserLink(newLinkPlatform.toLowerCase().trim(), newLinkValue.trim())
setNewLinkPlatform('')
const platform = newLinkPlatform.toLowerCase().trim()
const value = newLinkValue.trim()
const links = {...((profile.links as Socials) ?? {})}
if (isMultiValueSite(platform) && links[platform] != null) {
setLinks({
...links,
[platform]: [...getSocialLinkValues(links[platform]).filter(Boolean), value],
})
} else {
updateUserLink(platform, value)
}
setNewLinkPlatform('site')
setNewLinkValue('')
}
}
@@ -40,28 +75,26 @@ export function SocialLinksSection({profile, setProfile}: SocialLinksSectionProp
return (
<Col className={clsx('pb-4')}>
<div className="grid w-full grid-cols-[8rem_1fr_auto] gap-2">
{Object.entries((profile.links ?? {}) as Socials)
.filter(([_, value]) => value != null)
.map(([platform, value]) => (
<Fragment key={platform}>
<div className="col-span-3 mt-2 flex items-center gap-2 self-center sm:col-span-1">
<SocialIcon site={platform as any} className="text-primary-700 h-4 w-4" />
{PLATFORM_LABELS[platform as Site] ?? platform}
</div>
<Input
type="text"
value={value!}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
updateUserLink(platform, e.target.value)
}
className="col-span-2 sm:col-span-1"
/>
<IconButton onClick={() => updateUserLink(platform, null)}>
<XMarkIcon className="h-6 w-6" />
<div className="sr-only">{t('common.remove', 'Remove')}</div>
</IconButton>
</Fragment>
))}
{getSocialEntries((profile.links ?? {}) as Socials).map(({platform, value, index}) => (
<Fragment key={`${platform}-${index}`}>
<div className="col-span-3 mt-2 flex items-center gap-2 self-center sm:col-span-1">
<SocialIcon site={platform as any} className="text-primary-700 h-4 w-4" />
{PLATFORM_LABELS[platform as Site] ?? platform}
</div>
<Input
type="text"
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
updateUserLink(platform, e.target.value, index)
}
className="col-span-2 sm:col-span-1"
/>
<IconButton onClick={() => updateUserLink(platform, null, index)}>
<XMarkIcon className="h-6 w-6" />
<div className="sr-only">{t('common.remove', 'Remove')}</div>
</IconButton>
</Fragment>
))}
{/* Spacer */}
<div className="col-span-3 h-4" />

View File

@@ -1,5 +1,12 @@
import clsx from 'clsx'
import {getSocialUrl, PLATFORM_LABELS, Site, SITE_ORDER, Socials} from 'common/socials'
import {
getSocialEntries,
getSocialUrl,
PLATFORM_LABELS,
Site,
SITE_ORDER,
Socials,
} from 'common/socials'
import {sortBy} from 'lodash'
import {Row} from '../layout/row'
@@ -18,20 +25,21 @@ export function UserHandles(props: {links: Socials; className?: string}) {
const {links, className} = props
const display = sortBy(
Object.entries(links),
([platform]) => -[...SITE_ORDER].reverse().indexOf(platform as Site),
getSocialEntries(links),
({platform}) => -[...SITE_ORDER].reverse().indexOf(platform as Site),
)
.filter(([platform, label]) => !!label && !!platform)
.map(([platform, label]) => {
.filter(({platform, value}) => !!value && !!platform)
.map(({platform, value, index}) => {
let renderedLabel: string = LABELS_TO_RENDER.includes(platform)
? PLATFORM_LABELS[platform as Site]
: label
: value
renderedLabel = renderedLabel?.replace(/\/+$/, '') // remove trailing slashes
renderedLabel = renderedLabel?.replace(/^(https?:\/\/)?(www\.)?/, '') // remove protocol and www
return {
platform,
label: renderedLabel,
url: getSocialUrl(platform as any, label),
url: getSocialUrl(platform as any, value),
key: `${platform}-${index}`,
}
})
@@ -44,9 +52,9 @@ export function UserHandles(props: {links: Socials; className?: string}) {
className={clsx('flex-wrap items-center gap-2', className)}
data-testid="profile-social-media-accounts"
>
{display.map(({platform, label, url}) => (
{display.map(({platform, label, url, key}) => (
<a
key={platform}
key={key}
target="_blank"
href={url}
className="border-canvas-300 bg-canvas-0 flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-[12.5px] text-ink-500 transition-colors hover:border-primary-300 hover:text-primary-600"