mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
fix(packing): the weight field keeps what is typed and saves it once, on blur or Enter, instead of a fragment per keystroke on a slow connection (#3005)
This commit is contained in:
@@ -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<HTMLInputElement>('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 }))
|
||||
})
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent', ...badge(active || item.weight_grams != null) }}>
|
||||
<NumericInput
|
||||
value={item.weight_grams ?? ''}
|
||||
readOnly={!canEdit}
|
||||
onValueChange={async raw => {
|
||||
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 }}
|
||||
/>
|
||||
<WeightInput value={item.weight_grams} readOnly={!canEdit} width={36} onSave={saveWeight} />
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -381,16 +375,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '6px 8px' }}>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0 }}>{t('packing.totalWeight')}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent' }}>
|
||||
<NumericInput
|
||||
value={item.weight_grams ?? ''}
|
||||
readOnly={!canEdit}
|
||||
onValueChange={async raw => {
|
||||
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 }}
|
||||
/>
|
||||
<WeightInput value={item.weight_grams} readOnly={!canEdit} width={42} onSave={saveWeight} />
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', userSelect: 'none' }}>g</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
return (
|
||||
<WeightInput
|
||||
value={stored}
|
||||
width={36}
|
||||
onSave={async (grams) => {
|
||||
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(<SlowRow onSave={onSave} />);
|
||||
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(<WeightInput value={300} width={36} onSave={onSave} />);
|
||||
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(<WeightInput value={300} width={36} onSave={onSave} />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.blur(input);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<WeightInput value={450} width={36} onSave={onSave} />);
|
||||
expect(input.value).toBe('450');
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-098: a read-only field never saves', () => {
|
||||
const onSave = vi.fn();
|
||||
render(<WeightInput value={300} width={36} readOnly onSave={onSave} />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: '9' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
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 (
|
||||
<NumericInput
|
||||
value={draft}
|
||||
readOnly={readOnly}
|
||||
onFocus={() => {
|
||||
editing.current = true;
|
||||
}}
|
||||
onValueChange={setDraft}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||
}}
|
||||
placeholder="—"
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user