Skip to content

Commit 2c9ffe4

Browse files
committed
Support multi-value social links and refactor link handling logic
1 parent 8e921e0 commit 2c9ffe4

5 files changed

Lines changed: 119 additions & 43 deletions

File tree

common/src/api/zod-types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export const zBoolean = z
4444
.union([z.boolean(), z.string()])
4545
.transform((val) => val === true || val === 'true')
4646

47+
const linkValueSchema = z.union([z.string(), z.array(z.string())]).nullable()
48+
4749
// TODO: merge the two below when the deprecated /create-profile is deleted
4850
export const baseProfilesSchema = z.object({
4951
age: z.number().min(18).max(100).optional().nullable(),
@@ -97,7 +99,7 @@ const optionalProfilesSchema = z.object({
9799
image_descriptions: z.any().optional().nullable(),
98100
interests: z.array(z.string()).optional().nullable(),
99101
is_smoker: zBoolean.optional().nullable(),
100-
links: z.record(z.string().nullable()).optional(),
102+
links: z.record(linkValueSchema).optional(),
101103
mbti: z.string().optional().nullable(),
102104
occupation: z.string().optional().nullable(),
103105
occupation_title: z.string().optional().nullable(),

common/src/socials.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,24 @@ export const SITE_ORDER = [
2626
export type Site = (typeof SITE_ORDER)[number]
2727

2828
// this is a lie, actually people can have anything in their links
29-
export type Socials = {[key in Site]?: string}
29+
export type SocialValue = string | string[] | null | undefined
30+
export type Socials = {[key: string]: SocialValue}
31+
32+
export const MULTI_VALUE_SITES = ['site'] as const
33+
34+
export const isMultiValueSite = (site: string) =>
35+
(MULTI_VALUE_SITES as readonly string[]).includes(site)
36+
37+
export const getSocialLinkValues = (value: SocialValue) => {
38+
if (Array.isArray(value)) return value
39+
if (value == null) return []
40+
return [value]
41+
}
42+
43+
export const getSocialEntries = (links: Socials | null | undefined) =>
44+
Object.entries(links ?? {}).flatMap(([platform, value]) =>
45+
getSocialLinkValues(value).map((value, index) => ({platform, value, index})),
46+
)
3047

3148
export const strip = (site: Site, input: string) => stripper[site]?.(input) ?? input
3249

common/tests/unit/socials.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {discordLink} from 'common/constants'
2-
import {getSocialUrl, strip} from 'common/socials'
2+
import {getSocialEntries, getSocialUrl, strip} from 'common/socials'
33

44
describe('strip', () => {
55
describe('x/twitter', () => {
@@ -81,3 +81,19 @@ describe('getSocialUrl', () => {
8181
expect(getSocialUrl('discord', 'not-an-id')).toBe(discordLink)
8282
})
8383
})
84+
85+
describe('getSocialEntries', () => {
86+
it('flattens multi-value website links while preserving single-value links', () => {
87+
expect(
88+
getSocialEntries({
89+
site: ['example.com', 'blog.example.com'],
90+
github: 'username',
91+
x: null,
92+
}),
93+
).toEqual([
94+
{platform: 'site', value: 'example.com', index: 0},
95+
{platform: 'site', value: 'blog.example.com', index: 1},
96+
{platform: 'github', value: 'username', index: 0},
97+
])
98+
})
99+
})

web/components/social-links-section.tsx

Lines changed: 64 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import {PlusIcon, XMarkIcon} from '@heroicons/react/24/solid'
22
import clsx from 'clsx'
33
import {ProfileWithoutUser} from 'common/profiles/profile'
4-
import {PLATFORM_LABELS, type Site, SITE_ORDER, Socials} from 'common/socials'
4+
import {
5+
getSocialEntries,
6+
getSocialLinkValues,
7+
isMultiValueSite,
8+
PLATFORM_LABELS,
9+
type Site,
10+
SITE_ORDER,
11+
Socials,
12+
} from 'common/socials'
513
import {removeNullOrUndefinedProps} from 'common/util/object'
614
import {Fragment, useState} from 'react'
715
import {Button, IconButton} from 'web/components/buttons/button'
@@ -19,49 +27,74 @@ interface SocialLinksSectionProps {
1927

2028
export function SocialLinksSection({profile, setProfile}: SocialLinksSectionProps) {
2129
const t = useT()
22-
const [newLinkPlatform, setNewLinkPlatform] = useState('')
30+
const [newLinkPlatform, setNewLinkPlatform] = useState('site')
2331
const [newLinkValue, setNewLinkValue] = useState('')
2432

25-
const updateUserLink = (platform: string, value: string | null) => {
26-
setProfile(
27-
'links',
28-
removeNullOrUndefinedProps({...((profile.links as Socials) ?? {}), [platform]: value}),
29-
)
33+
const setLinks = (links: Socials) => {
34+
setProfile('links', removeNullOrUndefinedProps(links))
35+
}
36+
37+
const updateUserLink = (platform: string, value: string | null, index = 0) => {
38+
const links = {...((profile.links as Socials) ?? {})}
39+
const currentValue = links[platform]
40+
41+
if (Array.isArray(currentValue)) {
42+
const nextValues = [...currentValue]
43+
if (value == null) {
44+
nextValues.splice(index, 1)
45+
} else {
46+
nextValues[index] = value
47+
}
48+
setLinks({...links, [platform]: nextValues.length > 0 ? nextValues : null})
49+
return
50+
}
51+
52+
setLinks({...links, [platform]: value})
3053
}
3154

3255
const addNewLink = () => {
3356
if (newLinkPlatform && newLinkValue) {
34-
updateUserLink(newLinkPlatform.toLowerCase().trim(), newLinkValue.trim())
35-
setNewLinkPlatform('')
57+
const platform = newLinkPlatform.toLowerCase().trim()
58+
const value = newLinkValue.trim()
59+
const links = {...((profile.links as Socials) ?? {})}
60+
61+
if (isMultiValueSite(platform) && links[platform] != null) {
62+
setLinks({
63+
...links,
64+
[platform]: [...getSocialLinkValues(links[platform]).filter(Boolean), value],
65+
})
66+
} else {
67+
updateUserLink(platform, value)
68+
}
69+
70+
setNewLinkPlatform('site')
3671
setNewLinkValue('')
3772
}
3873
}
3974

4075
return (
4176
<Col className={clsx('pb-4')}>
4277
<div className="grid w-full grid-cols-[8rem_1fr_auto] gap-2">
43-
{Object.entries((profile.links ?? {}) as Socials)
44-
.filter(([_, value]) => value != null)
45-
.map(([platform, value]) => (
46-
<Fragment key={platform}>
47-
<div className="col-span-3 mt-2 flex items-center gap-2 self-center sm:col-span-1">
48-
<SocialIcon site={platform as any} className="text-primary-700 h-4 w-4" />
49-
{PLATFORM_LABELS[platform as Site] ?? platform}
50-
</div>
51-
<Input
52-
type="text"
53-
value={value!}
54-
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
55-
updateUserLink(platform, e.target.value)
56-
}
57-
className="col-span-2 sm:col-span-1"
58-
/>
59-
<IconButton onClick={() => updateUserLink(platform, null)}>
60-
<XMarkIcon className="h-6 w-6" />
61-
<div className="sr-only">{t('common.remove', 'Remove')}</div>
62-
</IconButton>
63-
</Fragment>
64-
))}
78+
{getSocialEntries((profile.links ?? {}) as Socials).map(({platform, value, index}) => (
79+
<Fragment key={`${platform}-${index}`}>
80+
<div className="col-span-3 mt-2 flex items-center gap-2 self-center sm:col-span-1">
81+
<SocialIcon site={platform as any} className="text-primary-700 h-4 w-4" />
82+
{PLATFORM_LABELS[platform as Site] ?? platform}
83+
</div>
84+
<Input
85+
type="text"
86+
value={value}
87+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
88+
updateUserLink(platform, e.target.value, index)
89+
}
90+
className="col-span-2 sm:col-span-1"
91+
/>
92+
<IconButton onClick={() => updateUserLink(platform, null, index)}>
93+
<XMarkIcon className="h-6 w-6" />
94+
<div className="sr-only">{t('common.remove', 'Remove')}</div>
95+
</IconButton>
96+
</Fragment>
97+
))}
6598

6699
{/* Spacer */}
67100
<div className="col-span-3 h-4" />

web/components/user/user-handles.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import clsx from 'clsx'
2-
import {getSocialUrl, PLATFORM_LABELS, Site, SITE_ORDER, Socials} from 'common/socials'
2+
import {
3+
getSocialEntries,
4+
getSocialUrl,
5+
PLATFORM_LABELS,
6+
Site,
7+
SITE_ORDER,
8+
Socials,
9+
} from 'common/socials'
310
import {sortBy} from 'lodash'
411

512
import {Row} from '../layout/row'
@@ -18,20 +25,21 @@ export function UserHandles(props: {links: Socials; className?: string}) {
1825
const {links, className} = props
1926

2027
const display = sortBy(
21-
Object.entries(links),
22-
([platform]) => -[...SITE_ORDER].reverse().indexOf(platform as Site),
28+
getSocialEntries(links),
29+
({platform}) => -[...SITE_ORDER].reverse().indexOf(platform as Site),
2330
)
24-
.filter(([platform, label]) => !!label && !!platform)
25-
.map(([platform, label]) => {
31+
.filter(({platform, value}) => !!value && !!platform)
32+
.map(({platform, value, index}) => {
2633
let renderedLabel: string = LABELS_TO_RENDER.includes(platform)
2734
? PLATFORM_LABELS[platform as Site]
28-
: label
35+
: value
2936
renderedLabel = renderedLabel?.replace(/\/+$/, '') // remove trailing slashes
3037
renderedLabel = renderedLabel?.replace(/^(https?:\/\/)?(www\.)?/, '') // remove protocol and www
3138
return {
3239
platform,
3340
label: renderedLabel,
34-
url: getSocialUrl(platform as any, label),
41+
url: getSocialUrl(platform as any, value),
42+
key: `${platform}-${index}`,
3543
}
3644
})
3745

@@ -44,9 +52,9 @@ export function UserHandles(props: {links: Socials; className?: string}) {
4452
className={clsx('flex-wrap items-center gap-2', className)}
4553
data-testid="profile-social-media-accounts"
4654
>
47-
{display.map(({platform, label, url}) => (
55+
{display.map(({platform, label, url, key}) => (
4856
<a
49-
key={platform}
57+
key={key}
5058
target="_blank"
5159
href={url}
5260
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"

0 commit comments

Comments
 (0)