diff --git a/e2e/activity-lifecycle.spec.ts b/e2e/activity-lifecycle.spec.ts index 2d56260..34e89b1 100644 --- a/e2e/activity-lifecycle.spec.ts +++ b/e2e/activity-lifecycle.spec.ts @@ -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() @@ -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() diff --git a/src/App.test.tsx b/src/App.test.tsx index 1f99452..2de9444 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -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' })) @@ -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/), ' Sam,Taylor ') await user.click(screen.getByRole('button', { name: 'Add friends' })) expect(onSave).toHaveBeenCalledWith(['Sam', 'Taylor']) await user.click(screen.getByRole('button', { name: 'Cancel' })) diff --git a/src/domain/members.test.ts b/src/domain/members.test.ts index fcc59cb..3a8498b 100644 --- a/src/domain/members.test.ts +++ b/src/domain/members.test.ts @@ -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(() => { @@ -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.') diff --git a/src/domain/members.ts b/src/domain/members.ts index 441d525..af9cf55 100644 --- a/src/domain/members.ts +++ b/src/domain/members.ts @@ -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() + 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+/) diff --git a/src/features/activity/ActivityModals.tsx b/src/features/activity/ActivityModals.tsx index 65315a8..b434faf 100644 --- a/src/features/activity/ActivityModals.tsx +++ b/src/features/activity/ActivityModals.tsx @@ -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' @@ -17,6 +17,7 @@ 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 @@ -24,7 +25,8 @@ export function CreateGroupModal({ onClose, onCurrencySelect, onSave }: { onSave: (name: string, friendNames: string[], currency: CurrencyCode) => void }) { const [name, setName] = useState('') - const [friends, setFriends] = useState('') + const [friendDraft, setFriendDraft] = useState('') + const [friendNames, setFriendNames] = useState([]) const { locale, t } = useLocalization() const [currency, setCurrency] = useState(() => defaultCurrencyForLocale(locale)) const currencyOptions: ReadonlyArray> = SUPPORTED_CURRENCIES.map(code => ({ @@ -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) => { @@ -51,7 +53,7 @@ export function CreateGroupModal({ onClose, onCurrencySelect, onSave }: {
-