mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-05-24 08:49:39 -04:00
Support multi-value social links and refactor link handling logic
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user