fix(share): the user picked to invite shows in the field before it is sent

The share dialog stored the pick as String(value) while its options are
keyed by numeric user ids. CustomSelect looked the pick up with a strict
comparison, found nothing and left the trigger on its placeholder, although
the Invite button was already enabled and the invite went through. The
phone opens the same dialog from the Mehr sheet, so it showed the same.

CustomSelect now compares the held value and the option values as text, so
a caller holding "5" finds the option keyed 5 and the other way round. A
held null or undefined still matches nothing. onChange keeps handing back
the option value unchanged. No other caller mixes the two kinds today; the
place form had worked around the same trap by keying its options as
strings, and its comment now says why the string stays.

Tests: CustomSelect matches across both kinds and marks the pick in the
list, the desktop dialog shows the picked name and clears after the invite,
and the phone share sheet does the same through the real dialog.

Refs #2478
This commit is contained in:
Maurice
2026-09-23 16:37:53 +02:00
parent 8d78c988c3
commit e8ec3427e5
5 changed files with 185 additions and 9 deletions
@@ -1196,9 +1196,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
options={[
{ value: '', label: t('places.noCategory') },
...(categories || []).map(c => ({
// form.category_id is a string; CustomSelect matches options by
// strict equality, so the option value must be a string too —
// otherwise the chosen category never renders in the trigger.
// A string like form.category_id, so the picked option hands
// back the same kind of value the form already keeps.
value: String(c.id),
label: c.name,
})),
@@ -1,4 +1,4 @@
// FE-COMP-MEMBERS-001 to FE-COMP-MEMBERS-056
// FE-COMP-MEMBERS-001 to FE-COMP-MEMBERS-057
import type { Mock } from 'vitest';
import { act, render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -725,6 +725,26 @@ describe('TripMembersModal', () => {
expect(addToast).toHaveBeenCalledWith('alice added', 'success', undefined);
});
// #2478: the pick showed nowhere, only the Invite button knew about it.
it('FE-COMP-MEMBERS-057: the picked user shows in the field before the invite is sent', async () => {
const user = userEvent.setup();
server.use(http.post('/api/trips/1/members', () => HttpResponse.json({ success: true })));
render(<TripMembersModal {...defaultProps} />);
await screen.findByText('Invite User');
const trigger = screen.getByText('Select user…').closest('button') as HTMLButtonElement;
await user.click(trigger);
await user.click(await screen.findByRole('button', { name: 'alice' }));
expect(trigger).toHaveTextContent('alice');
expect(screen.queryByText('Select user…')).toBeNull();
expect(screen.getByRole('button', { name: 'Invite' })).toBeEnabled();
// Once sent, the field is free for the next pick again.
await user.click(screen.getByRole('button', { name: 'Invite' }));
await waitFor(() => expect(trigger).toHaveTextContent('Select user…'));
});
it('FE-COMP-MEMBERS-042: transferring ownership reloads the app', async () => {
const onClose = vi.fn();
const reload = vi.fn();
@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { render, screen, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import CustomSelect from './CustomSelect';
@@ -145,6 +145,46 @@ describe('CustomSelect', () => {
expect(menu.style.maxWidth).toBe('180px');
});
// #2478: the trip share dialog stored String(value) against options keyed by numeric
// user ids, so the pick never showed in the trigger although it was made.
const USERS = [
{ value: 7, label: 'alice' },
{ value: 12, label: 'bob' },
];
it('FE-COMP-SELECT-014: a value held as text finds the option keyed by the same number', async () => {
const user = userEvent.setup();
render(<CustomSelect value="12" onChange={onChange} options={USERS} placeholder="Pick a user" />);
const trigger = screen.getByRole('button');
expect(trigger).toHaveTextContent('bob');
expect(screen.queryByText('Pick a user')).toBeNull();
// The open list marks the same option with its check, and only that one.
await user.click(trigger);
const menu = within(screen.getByText('alice').closest('div[style*="position: fixed"]') as HTMLElement);
expect(menu.getByRole('button', { name: 'bob' }).querySelector('svg')).not.toBeNull();
expect(menu.getByRole('button', { name: 'alice' }).querySelector('svg')).toBeNull();
});
it('FE-COMP-SELECT-015: a numeric value finds the option keyed by the same text', () => {
render(<CustomSelect value={3} onChange={onChange} options={[{ value: '3', label: 'March' }, { value: '4', label: 'April' }]} placeholder="Month" />);
expect(screen.getByRole('button')).toHaveTextContent('March');
});
it('FE-COMP-SELECT-016: a caller holding nothing still sees the placeholder', () => {
render(<CustomSelect value={null} onChange={onChange} options={[{ value: '', label: 'Nobody' }, ...USERS]} placeholder="Pick a user" />);
expect(screen.getByRole('button')).toHaveTextContent('Pick a user');
});
it('FE-COMP-SELECT-017: picking hands back the option value as it is', async () => {
const user = userEvent.setup();
render(<CustomSelect value="" onChange={onChange} options={USERS} />);
await user.click(screen.getByRole('button'));
await user.click(screen.getByRole('button', { name: 'alice' }));
expect(onChange).toHaveBeenCalledWith(7);
});
it('FE-COMP-SELECT-008: disabled state prevents the dropdown from opening', async () => {
const user = userEvent.setup();
render(<CustomSelect value="" onChange={onChange} options={OPTIONS} disabled={true} placeholder="Pick" />);
+18 -4
View File
@@ -4,8 +4,8 @@ import { ChevronDown, Check } from 'lucide-react'
import { useAnchoredPosition, scrollAnchorIntoView } from '../../hooks/useAnchoredPosition'
interface SelectOption {
// Callers use both string keys and numeric ids (e.g. day/place ids) as values;
// the component only does strict-equality lookups and key rendering, so either works.
// Callers use both string keys and numeric ids (e.g. day/place ids) as values, and
// the value they hold need not be of the same kind as the options: see sameValue.
value: string | number
label: string
icon?: React.ReactNode
@@ -36,6 +36,20 @@ interface CustomSelectProps {
menuFit?: 'anchor' | 'content'
}
/**
* Whether an option is the one the caller holds.
*
* onChange hands back the option's own value, and callers routinely store it in another
* form: the trip share dialog kept String(value) against options keyed by numeric user
* ids, so a strict comparison never found the pick and the trigger stayed on its
* placeholder while the invite button already knew who was chosen (#2478). An id reads
* the same as a number or as text, so both sides are compared as text. A caller holding
* nothing (null or undefined at runtime) matches no option.
*/
function sameValue(held: string | number | null | undefined, option: string | number): boolean {
return held != null && String(held) === String(option)
}
/** How wide a content-fitted menu may get before it stops reading as a menu. */
const MENU_MAX_WIDTH = 420
@@ -92,7 +106,7 @@ export default function CustomSelect({
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
const selected = options.find(o => o.value === value)
const selected = options.find(o => sameValue(value, o.value))
const filtered = searchable && search
? (() => {
const q = search.toLowerCase()
@@ -232,7 +246,7 @@ export default function CustomSelect({
</div>
)
}
const isSelected = option.value === value
const isSelected = sameValue(value, option.value)
return (
<button
key={option.value}
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { server } from '../../../helpers/msw/server'
import { useAuthStore } from '../../../../src/store/authStore'
import { useTripStore } from '../../../../src/store/tripStore'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { buildTrip, buildUser } from '../../../helpers/factories'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-SHOST-030 to FE-MOB-SHOST-031 (001 to 029 live in MTripSheets.test.tsx)
//
// The phone has no share sheet of its own: the Mehr sheet opens the desktop
// TripMembersModal through this host. Every other sheet is stubbed away so the real
// modal is what the phone user gets (#2478).
vi.mock('../../../../src/mobile/screens/trip/sheets/MPlaceSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MDaySheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MDaysSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MAccommodationSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MTransportSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MBrowseActionsSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MMehrSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MExportSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MNoteSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MImportSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MPlaceEditSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MReservationSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MTransportFormSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MCostSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/roadtrip/MRtStopSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/roadtrip/MRtStaySheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/roadtrip/MRtKindSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/roadtrip/MRtInfoSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/roadtrip/MRtCorridorSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/roadtrip/MRtDraftSheet', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/settings/MConfirmSheet', () => ({ default: () => null }))
vi.mock('../../../../src/components/Planner/BookingImportModal', () => ({ default: () => null }))
vi.mock('../../../../src/components/Planner/AirTrailImportModal', () => ({ default: () => null }))
vi.mock('../../../../src/components/Planner/TransitJourneyModal', () => ({ default: () => null }))
vi.mock('../../../../src/components/Trips/TripFormModal', () => ({ default: () => null }))
import MTripSheets from '../../../../src/mobile/screens/trip/sheets/MTripSheets'
const owner = buildUser({ id: 1, username: 'owner' })
const alice = buildUser({ id: 2, username: 'alice' })
function openShareSheet() {
const planner = buildPlanner()
render(<MTripSheets planner={planner} shell={buildShell({ sheet: { id: 'members' } })} />)
return { planner }
}
describe('MTripSheets share sheet', () => {
beforeEach(() => {
resetAllStores()
seedStore(useAuthStore, { user: owner, isAuthenticated: true })
seedStore(useTripStore, { trip: buildTrip({ id: 1, title: 'Japan 2026', user_id: owner.id }) })
server.use(
http.get('/api/trips/1/members', () =>
HttpResponse.json({ owner: { id: owner.id, username: owner.username, avatar_url: null }, members: [], current_user_id: owner.id }),
),
http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: null })),
http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: null })),
http.get('/api/auth/users', () => HttpResponse.json({ users: [alice] })),
)
})
it('FE-MOB-SHOST-030: the user picked on the phone shows in the field before the invite is sent', async () => {
const user = userEvent.setup()
openShareSheet()
const trigger = (await screen.findByText('Select user…')).closest('button') as HTMLButtonElement
await user.click(trigger)
await user.click(await screen.findByRole('button', { name: 'alice' }))
expect(trigger).toHaveTextContent('alice')
expect(screen.queryByText('Select user…')).toBeNull()
expect(screen.getByRole('button', { name: 'Invite' })).toBeEnabled()
})
it('FE-MOB-SHOST-031: sending the invite adds the pick and frees the field again', async () => {
const user = userEvent.setup()
let body: unknown = null
server.use(
http.post('/api/trips/1/members', async ({ request }) => {
body = await request.json()
return HttpResponse.json({ success: true })
}),
)
const { planner } = openShareSheet()
const trigger = (await screen.findByText('Select user…')).closest('button') as HTMLButtonElement
await user.click(trigger)
await user.click(await screen.findByRole('button', { name: 'alice' }))
await user.click(screen.getByRole('button', { name: 'Invite' }))
await waitFor(() => expect(planner.refreshMembers).toHaveBeenCalled())
expect(body).toEqual({ identifier: 'alice' })
expect(trigger).toHaveTextContent('Select user…')
})
})