diff --git a/client/src/components/Packing/PackingListPanelItemRow.test.tsx b/client/src/components/Packing/PackingListPanelItemRow.test.tsx index a7f938ee1..d231cc5a0 100644 --- a/client/src/components/Packing/PackingListPanelItemRow.test.tsx +++ b/client/src/components/Packing/PackingListPanelItemRow.test.tsx @@ -569,7 +569,9 @@ describe('ArtikelZeile — weight and bag', () => { ) const { container } = setup({ bagTrackingEnabled: true, bags: BAGS }) - fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '450' } }) + const weightField = container.querySelector('input[placeholder="—"]')! + fireEvent.change(weightField, { target: { value: '450' } }) + fireEvent.blur(weightField) await waitFor(() => expect(body).toMatchObject({ weight_grams: 450 })) }) @@ -587,7 +589,9 @@ describe('ArtikelZeile — weight and bag', () => { bagTrackingEnabled: true, }) - fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '' } }) + const weightField = container.querySelector('input[placeholder="—"]')! + fireEvent.change(weightField, { target: { value: '' } }) + fireEvent.blur(weightField) await waitFor(() => expect(body).toMatchObject({ weight_grams: null })) }) @@ -596,7 +600,9 @@ describe('ArtikelZeile — weight and bag', () => { server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 }))) const { container } = setup({ bagTrackingEnabled: true }) - fireEvent.change(container.querySelector('input[placeholder="—"]')!, { target: { value: '450' } }) + const weightField = container.querySelector('input[placeholder="—"]')! + fireEvent.change(weightField, { target: { value: '450' } }) + fireEvent.blur(weightField) await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined)) }) @@ -609,6 +615,7 @@ describe('ArtikelZeile — weight and bag', () => { const weight = container.querySelector('input[placeholder="—"]')! expect(weight.readOnly).toBe(true) fireEvent.change(weight, { target: { value: '450' } }) + fireEvent.blur(weight) fireEvent.click(bagButton(container)) await waitFor(() => expect(screen.queryByRole('button', { name: /Carry-on/ })).toBeNull()) @@ -929,6 +936,7 @@ describe('ArtikelZeile — overflow menu', () => { const weight = fieldOf(container, 'Total weight') fireEvent.change(weight, { target: { value: '900' } }) + fireEvent.blur(weight) await waitFor(() => expect(bodies[0]).toMatchObject({ weight_grams: 900 })) // The first entry mirrors the current bag and clears it when tapped. @@ -945,6 +953,7 @@ describe('ArtikelZeile — overflow menu', () => { fireEvent.click(overflowTrigger(container)) fireEvent.change(fieldOf(container, 'Total weight'), { target: { value: '900' } }) + fireEvent.blur(fieldOf(container, 'Total weight')) await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Failed to save', 'error', undefined)) @@ -995,6 +1004,7 @@ describe('ArtikelZeile — overflow menu', () => { fireEvent.click(overflowTrigger(container)) fireEvent.change(fieldOf(container, 'Total weight'), { target: { value: '' } }) + fireEvent.blur(fieldOf(container, 'Total weight')) await waitFor(() => expect(body).toMatchObject({ weight_grams: null })) }) diff --git a/client/src/components/Packing/PackingListPanelItemRow.tsx b/client/src/components/Packing/PackingListPanelItemRow.tsx index 1bf4be338..bba12331b 100644 --- a/client/src/components/Packing/PackingListPanelItemRow.tsx +++ b/client/src/components/Packing/PackingListPanelItemRow.tsx @@ -17,7 +17,7 @@ import PackingShareControl from './PackingShareControl' import { PopoverItem } from './PackingPopover' import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles' import type { TripMember } from './usePackingListPanel' -import { NumericInput } from '../shared/NumericInput' +import { WeightInput } from './PackingListPanelWeightInput' import { bringersOf, packingBringerNames } from './packingBringers' import { Tooltip } from '../shared/Tooltip' import { BringerAvatar, BringersCard, MoreBringers } from './PackingBringersCard' @@ -109,6 +109,10 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa catch { toast.error(t('common.error')) } } + const saveWeight = async (grams: number | null) => { + try { await updatePackingItem(tripId, item.id, { weight_grams: grams }) } catch { toast.error(t('packing.toast.saveError')) } + } + const canDrag = canEdit && !isPlaceholder && !!drag const selectedBag = bags.find(b => b.id === item.bag_id) const quantity = item.quantity || 1 @@ -251,17 +255,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa {bagTrackingEnabled && ( <>
- { - if (!canEdit) return - const v = raw === '' ? null : Number.parseInt(raw) - try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch { toast.error(t('packing.toast.saveError')) } - }} - placeholder="—" - style={{ width: 36, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'end', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }} - /> + g
@@ -381,16 +375,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
{t('packing.totalWeight')}
- { - const v = raw === '' ? null : Number.parseInt(raw) - try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch { toast.error(t('packing.toast.saveError')) } - }} - placeholder="—" - style={{ width: 42, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'end', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }} - /> + g
diff --git a/client/src/components/Packing/PackingListPanelWeightInput.test.tsx b/client/src/components/Packing/PackingListPanelWeightInput.test.tsx new file mode 100644 index 000000000..9ff21668c --- /dev/null +++ b/client/src/components/Packing/PackingListPanelWeightInput.test.tsx @@ -0,0 +1,78 @@ +// FE-COMP-PACKING-095 to FE-COMP-PACKING-098 +import { useState } from 'react'; +import { vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'; +import { WeightInput } from './PackingListPanelWeightInput'; + +/** A row whose stored weight changes only once a save answers, as the store does. */ +function SlowRow({ onSave }: { onSave: (grams: number | null) => void }) { + const [stored, setStored] = useState(null); + return ( + { + onSave(grams); + await new Promise((resolve) => setTimeout(resolve, 300)); + setStored(grams); + }} + /> + ); +} + +describe('WeightInput', () => { + it('FE-COMP-PACKING-095: #3005 keeps every typed digit and saves the whole weight once', async () => { + const onSave = vi.fn(); + render(); + const input = screen.getByRole('textbox') as HTMLInputElement; + + fireEvent.focus(input); + for (const typed of ['2', '25', '250']) { + fireEvent.change(input, { target: { value: typed } }); + expect(input.value).toBe(typed); + } + expect(onSave).not.toHaveBeenCalled(); + + fireEvent.blur(input); + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith(250); + await waitFor(() => expect(input.value).toBe('250')); + }); + + it('FE-COMP-PACKING-096: saves on Enter and stores an empty field as no weight', () => { + const onSave = vi.fn(); + render(); + const input = screen.getByRole('textbox') as HTMLInputElement; + + input.focus(); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onSave).toHaveBeenCalledWith(null); + }); + + it('FE-COMP-PACKING-097: leaves an unchanged weight alone and follows a change from elsewhere', () => { + const onSave = vi.fn(); + const { rerender } = render(); + const input = screen.getByRole('textbox') as HTMLInputElement; + + fireEvent.focus(input); + fireEvent.blur(input); + expect(onSave).not.toHaveBeenCalled(); + + rerender(); + expect(input.value).toBe('450'); + }); + + it('FE-COMP-PACKING-098: a read-only field never saves', () => { + const onSave = vi.fn(); + render(); + const input = screen.getByRole('textbox') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '9' } }); + fireEvent.blur(input); + + expect(onSave).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Packing/PackingListPanelWeightInput.tsx b/client/src/components/Packing/PackingListPanelWeightInput.tsx new file mode 100644 index 000000000..1152488d3 --- /dev/null +++ b/client/src/components/Packing/PackingListPanelWeightInput.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef, useState, type CSSProperties } from 'react'; +import { NumericInput } from '../shared/NumericInput'; + +interface Props { + value: number | null | undefined; + onSave: (grams: number | null) => void | Promise; + readOnly?: boolean; + width: number; +} + +/** + * The weight of a packing item, in grams. It keeps what is typed in a draft of + * its own and saves once, on blur or Enter, the way the quantity does: saving + * every keystroke let a slow reply put the stored value back under the cursor, + * so the next digit went onto the old number and a fragment was saved. + */ +export function WeightInput({ value, onSave, readOnly, width }: Props) { + const stored = value == null ? '' : String(value); + const [draft, setDraft] = useState(stored); + const editing = useRef(false); + // A change from elsewhere (another member, the server's answer) shows only + // while the field is not being typed in. + useEffect(() => { + if (!editing.current) setDraft(stored); + }, [stored]); + + const commit = () => { + editing.current = false; + if (readOnly) return; + const grams = draft.trim() === '' ? null : Math.max(0, Number.parseInt(draft, 10) || 0); + const next = grams == null ? '' : String(grams); + setDraft(next); + if (next !== stored) void onSave(grams); + }; + + const style: CSSProperties = { + width, + border: 'none', + fontSize: 'calc(12px * var(--fs-scale-body, 1))', + textAlign: 'end', + fontFamily: 'inherit', + outline: 'none', + color: 'var(--text-secondary)', + background: 'transparent', + padding: 0, + }; + + return ( + { + editing.current = true; + }} + onValueChange={setDraft} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); + }} + placeholder="—" + style={style} + /> + ); +}