Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions e2e/activity-lifecycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,11 @@ test('centers compact mobile dialogs and keeps long forms as sheets', async ({ p
await page.getByRole('button', { name: 'Create an activity' }).click()
await page.getByLabel('Activity name').fill('Modal weekend')
await page.getByLabel(/Add friends/).fill('Maya')
await page.getByLabel(/Add friends/).press('Enter')
await expect(page.getByRole('list', { name: 'Friends ready to add' })).toContainText('Maya')
await page.getByLabel(/Add friends/).fill('Jordan')
await page.getByRole('dialog').getByRole('button', { name: 'Add', exact: true }).click()
await expect(page.getByText('2 friends ready')).toBeVisible()
await page.getByRole('button', { name: 'Create activity' }).click()

await page.getByRole('button', { name: 'Settings' }).click()
Expand All @@ -371,6 +376,9 @@ test('centers compact mobile dialogs and keeps long forms as sheets', async ({ p

await page.getByRole('button', { name: 'Add friend' }).click()
await expect(page.locator('.modal-backdrop')).toHaveClass(/modal-backdrop--center/)
await page.getByLabel(/Friend names/).fill('Sam,Taylor')
await page.getByRole('dialog').getByRole('button', { name: 'Add', exact: true }).click()
await expect(page.getByText('2 friends ready')).toBeVisible()
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click()

await page.getByRole('button', { name: 'Add expense' }).click()
Expand Down
4 changes: 2 additions & 2 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,7 @@ describe('modals', () => {
fireEvent.submit(container.querySelector('form')!)
expect(onSave).not.toHaveBeenCalled()
await user.type(screen.getByLabelText('Activity name'), ' Beach trip ')
await user.type(screen.getByLabelText(/Add friends/), ' Maya, , Jordan ')
await user.type(screen.getByLabelText(/Add friends/), ' Maya Jordan ')
await user.click(screen.getByRole('button', { name: 'Create activity' }))
expect(onSave).toHaveBeenCalledWith('Beach trip', ['Maya', 'Jordan'], 'USD')
await user.click(screen.getByRole('button', { name: 'Cancel' }))
Expand Down Expand Up @@ -671,7 +671,7 @@ describe('modals', () => {
expect(screen.queryByText('Future expenses only')).not.toBeInTheDocument()
fireEvent.submit(container.querySelector('form')!)
expect(onSave).not.toHaveBeenCalled()
await user.type(screen.getByLabelText(/Friend names/), ' Sam, , Taylor ')
await user.type(screen.getByLabelText(/Friend names/), ' SamTaylor ')
await user.click(screen.getByRole('button', { name: 'Add friends' }))
expect(onSave).toHaveBeenCalledWith(['Sam', 'Taylor'])
await user.click(screen.getByRole('button', { name: 'Cancel' }))
Expand Down
13 changes: 12 additions & 1 deletion src/domain/members.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ACTIVITY_EMOJIS, addedFriendsMessage, CURRENT_USER, FRIEND_COLORS, initialsFor, makeId } from './members'
import { ACTIVITY_EMOJIS, addedFriendsMessage, CURRENT_USER, FRIEND_COLORS, initialsFor, makeId, mergeMemberNames, parseMemberNames } from './members'

describe('member domain', () => {
afterEach(() => {
Expand All @@ -26,6 +26,17 @@ describe('member domain', () => {
expect(initialsFor('')).toBe('?')
})

it('parses pasted member names across common English and Chinese separators', () => {
expect(parseMemberNames(' Sam,Taylor、 小明;小红\nJordan; Maya ')).toEqual([
'Sam', 'Taylor', '小明', '小红', 'Jordan', 'Maya',
])
})

it('normalizes whitespace and removes duplicate member names without changing order', () => {
expect(parseMemberNames(' Maya Chen, maya chen, Jordan ')).toEqual(['Maya Chen', 'Jordan'])
expect(mergeMemberNames(['Maya'], 'maya,小红')).toEqual(['Maya', '小红'])
})

it('describes singular and plural additions with no earlier expenses', () => {
expect(addedFriendsMessage(['Jordan'], 0)).toBe('Jordan was added to the activity.')
expect(addedFriendsMessage(['Jordan', 'Sam'], 0)).toBe('Jordan and Sam were added to the activity.')
Expand Down
29 changes: 29 additions & 0 deletions src/domain/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,35 @@ export const ACTIVITY_EMOJIS = ['✦', '⌂', '☀', '✈']

export const makeId = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`

const MEMBER_NAME_SEPARATOR = /[,,、;;\n\r]+/

export function parseMemberNames(value: string): string[] {
const seen = new Set<string>()
const names: string[] = []

for (const candidate of value.split(MEMBER_NAME_SEPARATOR)) {
const name = candidate.trim().replace(/\s+/g, ' ')
const key = name.toLocaleLowerCase()
if (!name || seen.has(key)) continue
seen.add(key)
names.push(name)
}

return names
}

export function mergeMemberNames(current: string[], value: string): string[] {
const seen = new Set(current.map(name => name.toLocaleLowerCase()))
const next = [...current]
for (const name of parseMemberNames(value)) {
const key = name.toLocaleLowerCase()
if (seen.has(key)) continue
seen.add(key)
next.push(name)
}
return next
}

export const initialsFor = (name: string) => name
.trim()
.split(/\s+/)
Expand Down
23 changes: 13 additions & 10 deletions src/features/activity/ActivityModals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Button } from '../../components/Button'
import { SelectMenu, type SelectMenuOption } from '../../components/SelectMenu'
import { activityCurrency, currencyLabel, currencySymbol, defaultCurrencyForLocale, SUPPORTED_CURRENCIES, type CurrencyCode } from '../../domain/currency'
import { createEqualShares, createExactShares, createExpenseTimestamp, createSettlementPayment, money } from '../../domain/expenses'
import { makeId } from '../../domain/members'
import { makeId, mergeMemberNames } from '../../domain/members'
import type { ActivityGroup, Expense, Member, Settlement, SplitMethod } from '../../domain/models'
import { useLocalization } from '../../i18n/LocalizationContext'
import { AiExpenseComposer } from '../aiExpense/AiExpenseComposer'
Expand All @@ -17,14 +17,16 @@ import type { AiExpenseReadyDraft } from '../aiExpense/aiExpenseContract'
import { createAiDraftFromValues, createExpenseFromAiDraft } from '../aiExpense/aiExpenseDrafts'
import { MAX_ACTIVITY_AMOUNT } from '../sharing/sharedActivity'
import { ActivityIdentityControl } from './ActivityIdentityControl'
import { FriendNameInput } from './FriendNameInput'

export function CreateGroupModal({ onClose, onCurrencySelect, onSave }: {
onClose: () => void
onCurrencySelect?: (currency: CurrencyCode) => void
onSave: (name: string, friendNames: string[], currency: CurrencyCode) => void
}) {
const [name, setName] = useState('')
const [friends, setFriends] = useState('')
const [friendDraft, setFriendDraft] = useState('')
const [friendNames, setFriendNames] = useState<string[]>([])
const { locale, t } = useLocalization()
const [currency, setCurrency] = useState<CurrencyCode>(() => defaultCurrencyForLocale(locale))
const currencyOptions: ReadonlyArray<SelectMenuOption<CurrencyCode>> = SUPPORTED_CURRENCIES.map(code => ({
Expand All @@ -37,7 +39,7 @@ export function CreateGroupModal({ onClose, onCurrencySelect, onSave }: {
const submit = (event: FormEvent) => {
event.preventDefault()
if (!name.trim()) return
onSave(name.trim(), friends.split(',').map(friend => friend.trim()).filter(Boolean), currency)
onSave(name.trim(), mergeMemberNames(friendNames, friendDraft), currency)
}

const selectCurrency = (nextCurrency: CurrencyCode) => {
Expand All @@ -51,7 +53,7 @@ export function CreateGroupModal({ onClose, onCurrencySelect, onSave }: {
<form onSubmit={submit}>
<label>{t('group.name')}<input autoFocus value={name} onChange={event => setName(event.target.value)} placeholder={t('group.namePlaceholder')} required /></label>
<label>{t('group.currency')} <small>{t('group.currencyHelp')}</small><SelectMenu value={currency} options={currencyOptions} onChange={selectCurrency} ariaLabel={t('group.chooseCurrency', { currency: currencyLabel(currency, locale) })} menuLabel={t('group.currencyMenu')} /></label>
<label>{t('group.addFriends')} <small>{t('group.addFriendsHelp')}</small><textarea value={friends} onChange={event => setFriends(event.target.value)} placeholder={t('group.addFriendsPlaceholder')} rows={3} /></label>
<FriendNameInput fieldContext="group" draft={friendDraft} names={friendNames} onDraftChange={setFriendDraft} onNamesChange={setFriendNames} />
<div className="split-note"><Users size={18} /><span>{t('group.included')}</span></div>
<div className="modal-actions"><Button onClick={onClose}>{t('common.cancel')}</Button><Button variant="primary" type="submit">{t('group.create')}</Button></div>
</form>
Expand All @@ -60,22 +62,23 @@ export function CreateGroupModal({ onClose, onCurrencySelect, onSave }: {
}

export function AddFriendModal({ existingExpenseCount, onClose, onSave, saving = false }: { existingExpenseCount: number; onClose: () => void; onSave: (names: string[]) => void; saving?: boolean }) {
const [names, setNames] = useState('')
const [draft, setDraft] = useState('')
const [names, setNames] = useState<string[]>([])
const { t } = useLocalization()
const pendingNames = mergeMemberNames(names, draft)

const submit = (event: FormEvent) => {
event.preventDefault()
const parsed = names.split(',').map(name => name.trim()).filter(Boolean)
if (!parsed.length) return
onSave(parsed)
if (!pendingNames.length) return
onSave(pendingNames)
}

return (
<ModalShell eyebrow={t('friend.eyebrow')} title={t('friend.title')} onClose={onClose} mobilePlacement="center">
<form onSubmit={submit}>
<label>{t('friend.names')} <small>{t('friend.namesHelp')}</small><textarea autoFocus value={names} onChange={event => setNames(event.target.value)} placeholder={t('friend.namesPlaceholder')} rows={3} required /></label>
<FriendNameInput draft={draft} names={names} onDraftChange={setDraft} onNamesChange={setNames} />
{existingExpenseCount ? <div className="split-note future-note"><Users size={18} /><span><b>{t('friend.futureOnly')}</b><small>{t(existingExpenseCount === 1 ? 'friend.existingOne' : 'friend.existingMany', { count: existingExpenseCount })}</small></span></div> : null}
<div className="modal-actions"><Button onClick={onClose}>{t('common.cancel')}</Button><Button variant="primary" type="submit" disabled={saving}>{t('friend.add')}</Button></div>
<div className="modal-actions"><Button onClick={onClose}>{t('common.cancel')}</Button><Button variant="primary" type="submit" disabled={saving || !pendingNames.length}>{t('friend.add')}</Button></div>
</form>
</ModalShell>
)
Expand Down
60 changes: 60 additions & 0 deletions src/features/activity/FriendNameInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { describe, expect, it } from 'vitest'
import { LocalizationProvider } from '../../i18n/LocalizationContext'
import { FriendNameInput } from './FriendNameInput'

function TestField({ locale = 'en' }: { locale?: 'en' | 'zh-CN' }) {
const [draft, setDraft] = useState('')
const [names, setNames] = useState<string[]>([])
return (
<LocalizationProvider initialLocale={locale}>
<FriendNameInput draft={draft} names={names} onDraftChange={setDraft} onNamesChange={setNames} />
</LocalizationProvider>
)
}

describe('FriendNameInput', () => {
it('adds one name with Enter and lets the user remove it', async () => {
const user = userEvent.setup()
render(<TestField />)

const input = screen.getByLabelText(/Friend names/)
await user.type(input, 'Maya{Enter}')
expect(screen.getByText('Maya')).toBeVisible()
expect(screen.getByText('1 friend ready')).toBeVisible()
expect(input).toHaveValue('')

await user.type(input, 'maya{Enter}')
expect(screen.getAllByText(/Maya/i)).toHaveLength(1)
expect(input).toHaveValue('maya')
await user.clear(input)

await user.click(screen.getByRole('button', { name: 'Remove Maya' }))
expect(screen.queryByText('Maya')).not.toBeInTheDocument()
expect(screen.getByText('No friends added yet.')).toBeVisible()
})

it('accepts pasted Chinese punctuation, removes duplicates, and localizes the UI', async () => {
const user = userEvent.setup()
render(<TestField locale="zh-CN" />)

await user.type(screen.getByLabelText(/朋友姓名/), '小明,小红、小明')
await user.click(screen.getByRole('button', { name: '添加' }))
expect(screen.getByText('小明')).toBeVisible()
expect(screen.getByText('小红')).toBeVisible()
expect(screen.getByText('已准备添加 2 位朋友')).toBeVisible()
})

it('does not submit Enter while an input method editor is composing', async () => {
const user = userEvent.setup()
render(<TestField />)
const input = screen.getByLabelText(/Friend names/)

await user.type(input, '小明')
fireEvent.keyDown(input, { key: 'Enter', isComposing: true })
expect(screen.queryByRole('list', { name: 'Friends ready to add' })).not.toBeInTheDocument()
expect(input).toHaveValue('小明')
})
})
82 changes: 82 additions & 0 deletions src/features/activity/FriendNameInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useId, type KeyboardEvent } from 'react'
import { Plus, X } from 'lucide-react'
import { useLocalization } from '../../i18n/LocalizationContext'
import { mergeMemberNames } from '../../domain/members'

export function FriendNameInput({
draft,
fieldContext = 'friend',
names,
onDraftChange,
onNamesChange,
}: {
draft: string
fieldContext?: 'friend' | 'group'
names: string[]
onDraftChange: (value: string) => void
onNamesChange: (names: string[]) => void
}) {
const { t } = useLocalization()
const inputId = useId()
const label = fieldContext === 'group' ? t('group.addFriends') : t('friend.names')
const help = fieldContext === 'group' ? t('group.addFriendsHelp') : t('friend.namesHelp')
const placeholder = fieldContext === 'group' ? t('group.addFriendsPlaceholder') : t('friend.namesPlaceholder')
const hasAddableName = mergeMemberNames(names, draft).length > names.length

const addDraft = () => {
const next = mergeMemberNames(names, draft)
if (next.length === names.length) return
onNamesChange(next)
onDraftChange('')
}

const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Enter' || event.nativeEvent.isComposing) return
event.preventDefault()
addDraft()
}

const removeName = (index: number) => {
onNamesChange(names.filter((_, nameIndex) => nameIndex !== index))
}

const pendingCount = mergeMemberNames(names, draft).length

return (
<div className="friend-name-field">
<label htmlFor={inputId}>
{label}
<small>{help}</small>
</label>
<div className="friend-name-entry">
<input
id={inputId}
autoFocus
value={draft}
onChange={event => onDraftChange(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
/>
<button type="button" onClick={addDraft} disabled={!hasAddableName}>
<Plus size={15} />
{t('friend.inputAdd')}
</button>
</div>
{names.length > 0 ? (
<ul className="friend-name-list" aria-label={t('friend.readyList')}>
{names.map((name, index) => (
<li key={`${name}-${index}`}>
<span>{name}</span>
<button type="button" onClick={() => removeName(index)} aria-label={t('friend.removeName', { name })}>
<X size={13} />
</button>
</li>
))}
</ul>
) : null}
<small className="friend-name-status" aria-live="polite">
{pendingCount > 0 ? t(pendingCount === 1 ? 'friend.readyOne' : 'friend.readyMany', { count: pendingCount }) : t('friend.readyEmpty')}
</small>
</div>
)
}
Loading