mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
fix(tours): prevent duplicate day assignments
This commit is contained in:
@@ -129,6 +129,36 @@ describe('ToursSidebar', () => {
|
||||
expect(onToursChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables already-assigned days and refreshes availability when the picker is reopened', async () => {
|
||||
const dayOne = { id: 7, trip_id: 1, day_number: 1, title: 'Day one', date: null } as never
|
||||
const dayTwo = { id: 8, trip_id: 1, day_number: 2, title: 'Day two', date: null } as never
|
||||
const dayThree = { id: 9, trip_id: 1, day_number: 3, title: 'Day three', date: null } as never
|
||||
useTripStore.setState({ assignments: { '7': [{ id: 1, day_id: 7, place_id: tour.place_id }] } as never })
|
||||
const onAssignToDay = vi.fn((placeId: number, dayId: number) => {
|
||||
useTripStore.setState(state => ({
|
||||
assignments: { ...state.assignments, [String(dayId)]: [{ id: 2, day_id: dayId, place_id: placeId }] } as never,
|
||||
}))
|
||||
return true
|
||||
})
|
||||
render(<ToursSidebar canAssign tripId={1} days={[dayOne, dayTwo, dayThree]} tours={[tour]} onAssignToDay={onAssignToDay} onSelectTour={vi.fn()} />)
|
||||
|
||||
const openPicker = () => fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }))
|
||||
openPicker()
|
||||
expect(screen.getByRole('button', { name: /Day one/ })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /Day two/ })).toBeEnabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Day two/ }))
|
||||
expect(onAssignToDay).toHaveBeenCalledTimes(1)
|
||||
expect(onAssignToDay).toHaveBeenCalledWith(tour.place_id, 8)
|
||||
|
||||
openPicker()
|
||||
expect(screen.getByRole('button', { name: /Day one/ })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /Day two/ })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /Day three/ })).toBeEnabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Day three/ }))
|
||||
expect(onAssignToDay).toHaveBeenCalledTimes(2)
|
||||
expect(onAssignToDay).toHaveBeenLastCalledWith(tour.place_id, 9)
|
||||
})
|
||||
|
||||
it('neither refreshes nor reports success when assignment fails', async () => {
|
||||
toast.success.mockClear()
|
||||
const onToursChanged = vi.fn()
|
||||
|
||||
@@ -178,7 +178,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
|
||||
tour={tour}
|
||||
selected={tour.place_id === selectedPlaceId}
|
||||
onSelect={(selectedTour, opener) => onSelectTour?.(selectedTour, opener)}
|
||||
action={<TourDayMenu days={days} label={t('tours.addToDay')} disabled={!canAssign} onPick={(day, index) => { void handleAssign(tour, day, index) }} />}
|
||||
action={<TourDayMenu days={days} placeId={tour.place_id} label={t('tours.addToDay')} disabled={!canAssign} onPick={(day, index) => { void handleAssign(tour, day, index) }} />}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -471,6 +471,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
variant="button"
|
||||
primary
|
||||
days={days}
|
||||
placeId={planner.saveOutcome.place_id}
|
||||
label={t('tours.planner.assignToDay')}
|
||||
disabled={!canAssign || assigning}
|
||||
onPick={day => { void assignSavedTour(day.id) }}
|
||||
@@ -498,6 +499,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
action={canAssign && days.length > 0 ? (
|
||||
<TourDayMenu
|
||||
days={days}
|
||||
placeId={tour.place_id}
|
||||
label={`${t('tours.addToDay')}: ${tour.name}`}
|
||||
onPick={day => { if (canAssign) void onAssignToDay(tour.place_id, day.id) }}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { formatDate } from '../../utils/formatters'
|
||||
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
|
||||
import { DialogButton, fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { BOX, Eyebrow } from '../Planner/bookings/bookingParts'
|
||||
import { SoftPill } from '../Planner/planParts'
|
||||
import { hikeSourceBadgeLabel } from './tourPresentation'
|
||||
@@ -73,8 +74,9 @@ function dayMenuLabel(day: Day, index: number, t: Translate, locale: string): st
|
||||
* Putting a tour on a day: a round "+" on a row, or a footer button with a
|
||||
* chevron, both opening the trip's days as a menu.
|
||||
*/
|
||||
export function TourDayMenu({ days, onPick, disabled = false, label, variant = 'round', primary = false }: {
|
||||
export function TourDayMenu({ days, placeId, onPick, disabled = false, label, variant = 'round', primary = false }: {
|
||||
days: Day[]
|
||||
placeId?: number
|
||||
onPick: (day: Day, index: number) => void
|
||||
disabled?: boolean
|
||||
label: string
|
||||
@@ -83,9 +85,16 @@ export function TourDayMenu({ days, onPick, disabled = false, label, variant = '
|
||||
}) {
|
||||
const { t, locale } = useTranslation()
|
||||
const menu = useContextMenu()
|
||||
const items = days.map((day, index) => ({ label: dayMenuLabel(day, index, t, locale), onClick: () => onPick(day, index) }))
|
||||
const assignments = useTripStore(state => state.assignments ?? {})
|
||||
const assigned = (dayId: number) => placeId != null && (assignments[String(dayId)] ?? []).some(assignment => assignment.place_id === placeId)
|
||||
const items = days.map((day, index) => ({
|
||||
label: dayMenuLabel(day, index, t, locale),
|
||||
disabled: assigned(day.id),
|
||||
onClick: () => onPick(day, index),
|
||||
}))
|
||||
const hasAvailableDay = items.some(item => !item.disabled)
|
||||
const open = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (disabled || items.length === 0) return
|
||||
if (disabled || !hasAvailableDay) return
|
||||
if (menu.menu) { event.stopPropagation(); menu.close(); return }
|
||||
menu.open(event, items, true)
|
||||
}
|
||||
@@ -93,13 +102,13 @@ export function TourDayMenu({ days, onPick, disabled = false, label, variant = '
|
||||
<>
|
||||
{variant === 'round' ? (
|
||||
<Tooltip label={label} disabled={!!menu.menu}>
|
||||
<button type="button" onClick={open} disabled={disabled || items.length === 0} aria-label={label} aria-haspopup="menu" aria-expanded={!!menu.menu}
|
||||
<button type="button" onClick={open} disabled={disabled || !hasAvailableDay} aria-label={label} aria-haspopup="menu" aria-expanded={!!menu.menu}
|
||||
className={`grid h-[26px] w-[26px] flex-none place-items-center rounded-full shadow-sm ring-1 transition-colors disabled:cursor-default disabled:opacity-40 ${menu.menu ? 'bg-accent text-accent-text ring-transparent' : 'bg-surface-card text-content-muted ring-edge-faint enabled:hover:bg-accent enabled:hover:text-accent-text enabled:hover:ring-transparent'}`}>
|
||||
<Plus size={13} strokeWidth={2.4} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<DialogButton variant={primary ? 'primary' : 'secondary'} onClick={open} disabled={disabled || items.length === 0}
|
||||
<DialogButton variant={primary ? 'primary' : 'secondary'} onClick={open} disabled={disabled || !hasAvailableDay}
|
||||
aria-haspopup="menu" aria-expanded={!!menu.menu} icon={<Plus size={14} strokeWidth={2} />}>
|
||||
{label}
|
||||
<ChevronDown size={13} strokeWidth={2.2} className={`transition-transform ${menu.menu ? 'rotate-180' : ''}`} />
|
||||
|
||||
@@ -79,4 +79,13 @@ describe('ContextMenu', () => {
|
||||
});
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not activate a disabled menu item', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<ContextMenu menu={makeMenu(100, 200, [{ label: 'Already assigned', onClick, disabled: true }])} onClose={onClose} />)
|
||||
const item = screen.getByRole('button', { name: 'Already assigned' })
|
||||
expect(item).toBeDisabled()
|
||||
fireEvent.click(item)
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ interface MenuItem {
|
||||
label?: string
|
||||
icon?: LucideIcon
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
danger?: boolean
|
||||
divider?: boolean
|
||||
}
|
||||
@@ -84,15 +85,15 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button type="button" key={i} onClick={() => { item.onClick?.(); onClose() }} style={{
|
||||
<button type="button" key={i} disabled={item.disabled} onClick={() => { if (item.disabled) return; item.onClick?.(); onClose() }} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '7px 10px', borderRadius: 7, border: 'none',
|
||||
background: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: 'none', cursor: item.disabled ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, textAlign: 'left',
|
||||
color: item.danger ? '#ef4444' : 'var(--text-primary)',
|
||||
color: item.danger ? '#ef4444' : 'var(--text-primary)', opacity: item.disabled ? 0.45 : 1,
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = item.danger ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)'}
|
||||
onMouseEnter={e => { if (!item.disabled) e.currentTarget.style.background = item.danger ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
>
|
||||
{Icon && <Icon size={13} style={{ flexShrink: 0, color: item.danger ? '#ef4444' : 'var(--text-faint)' }} />}
|
||||
|
||||
Reference in New Issue
Block a user