mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
5c312a8497
The packing list bar gets an export menu next to the import button, both now icon only. The menu prints the open view as a TREK styled page (trip, dates, packed count, one card per category, bags), which the browser can also save as a PDF, and downloads it as a Markdown checklist or as a CSV in the exact format the import reads, bags included, so a list works as a personal template without admin rights. The import now also reads Markdown checklists: headings become categories, "- [x]" marks an item packed, a "3x" prefix sets the quantity and a "(200 g)" suffix the weight. The phone action sheet offers the same exports through the shared usePackingExport hook. The help center gets an export guide with new pictures, the import guide and the wiki page describe the Markdown format, in all 27 languages. Addresses discussions #1420 and #875.
162 lines
6.9 KiB
TypeScript
162 lines
6.9 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { packingApi } from '../../../../src/api/client'
|
|
import MPackingImportSheet from '../../../../src/mobile/screens/trip/tabs/MPackingImportSheet'
|
|
import { useTripStore } from '../../../../src/store/tripStore'
|
|
import type { PackingItem } from '../../../../src/types'
|
|
import { buildPlanner } from '../../../helpers/mobileTrip'
|
|
import { resetAllStores, seedStore } from '../../../helpers/store'
|
|
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
|
|
|
// FE-MOB-PACKIMP-001 to FE-MOB-PACKIMP-014
|
|
|
|
function packItem(overrides: Partial<PackingItem> = {}): PackingItem {
|
|
return {
|
|
id: 1, trip_id: 1, name: 'Passport', checked: 0, category: 'Documents', sort_order: 0,
|
|
...overrides,
|
|
} as PackingItem
|
|
}
|
|
|
|
function setup(text?: string) {
|
|
const onClose = vi.fn()
|
|
const planner = buildPlanner({ tripId: 7 })
|
|
const view = render(<MPackingImportSheet planner={planner} open onClose={onClose} />)
|
|
if (text != null) {
|
|
fireEvent.change(screen.getByPlaceholderText('packing.importPlaceholder'), { target: { value: text } })
|
|
}
|
|
return { onClose, planner, view }
|
|
}
|
|
|
|
describe('MPackingImportSheet', () => {
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
seedStore(useTripStore, { packingItems: [packItem()] })
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('FE-MOB-PACKIMP-001: stays unmounted while closed', () => {
|
|
const planner = buildPlanner()
|
|
render(<MPackingImportSheet planner={planner} open={false} onClose={vi.fn()} />)
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
})
|
|
|
|
it('FE-MOB-PACKIMP-002: renders the hint, textarea and a disabled zero-count submit', () => {
|
|
setup()
|
|
expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'packing.importTitle')
|
|
expect(screen.getByText('packing.importHint')).toBeInTheDocument()
|
|
expect(screen.getByPlaceholderText('packing.importPlaceholder')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'packing.importAction:0' })).toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-PACKIMP-003: counts the parsed lines in the submit label', () => {
|
|
setup('Documents, Passport, 30\nClothing, Socks\n\n')
|
|
expect(screen.getByRole('button', { name: 'packing.importAction:2' })).toBeEnabled()
|
|
})
|
|
|
|
it('FE-MOB-PACKIMP-004: warns when text parses to nothing', () => {
|
|
setup(',')
|
|
expect(screen.getByText('packing.importEmpty')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'packing.importAction:0' })).toBeDisabled()
|
|
})
|
|
|
|
it('FE-MOB-PACKIMP-005: keeps the warning hidden for blank input', () => {
|
|
setup(' ')
|
|
expect(screen.queryByText('packing.importEmpty')).toBeNull()
|
|
})
|
|
|
|
it('FE-MOB-PACKIMP-006: posts the parsed items and appends the response to the store', async () => {
|
|
const imported = [packItem({ id: 50, name: 'Socks', category: 'Clothing' })]
|
|
const spy = vi.spyOn(packingApi, 'bulkImport').mockResolvedValue({ items: imported, count: 1 })
|
|
const { onClose, planner } = setup('Clothing, Socks, 120, Backpack, checked')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'packing.importAction:1' }))
|
|
|
|
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
|
expect(spy).toHaveBeenCalledWith(7, [
|
|
{ name: 'Socks', category: 'Clothing', weight_grams: '120', bag: 'Backpack', checked: true },
|
|
])
|
|
expect(planner.toast.success).toHaveBeenCalledWith('packing.importSuccess:1')
|
|
expect(useTripStore.getState().packingItems.map(i => i.id)).toEqual([1, 50])
|
|
})
|
|
|
|
it('FE-MOB-PACKIMP-007: clears the textarea after a successful import', async () => {
|
|
vi.spyOn(packingApi, 'bulkImport').mockResolvedValue({ items: [], count: 0 })
|
|
setup('Socks')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'packing.importAction:1' }))
|
|
|
|
await waitFor(() => expect(screen.getByPlaceholderText('packing.importPlaceholder')).toHaveValue(''))
|
|
})
|
|
|
|
it('FE-MOB-PACKIMP-008: reports a failed import and keeps the sheet open', async () => {
|
|
vi.spyOn(packingApi, 'bulkImport').mockRejectedValue(new Error('boom'))
|
|
const { onClose, planner } = setup('Socks')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'packing.importAction:1' }))
|
|
|
|
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('packing.importError'))
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
expect(screen.getByPlaceholderText('packing.importPlaceholder')).toHaveValue('Socks')
|
|
})
|
|
|
|
it('FE-MOB-PACKIMP-009: opens the file picker from the CSV button', () => {
|
|
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {})
|
|
setup()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'packing.importCsv' }))
|
|
|
|
expect(click).toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-PACKIMP-010: fills the textarea from a picked file', async () => {
|
|
const { view } = setup()
|
|
const input = view.container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement
|
|
const file = new File(['Clothing, Socks\nClothing, Shirt'], 'list.csv', { type: 'text/csv' })
|
|
|
|
fireEvent.change(input, { target: { files: [file] } })
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByPlaceholderText('packing.importPlaceholder')).toHaveValue('Clothing, Socks\nClothing, Shirt'),
|
|
)
|
|
expect(screen.getByRole('button', { name: 'packing.importAction:2' })).toBeEnabled()
|
|
})
|
|
|
|
it('FE-MOB-PACKIMP-011: ignores a change event without a file', () => {
|
|
const { view } = setup()
|
|
const input = view.container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement
|
|
|
|
fireEvent.change(input, { target: { files: [] } })
|
|
|
|
expect(screen.getByPlaceholderText('packing.importPlaceholder')).toHaveValue('')
|
|
})
|
|
|
|
it('FE-MOB-PACKIMP-012: closes on cancel without importing', () => {
|
|
const spy = vi.spyOn(packingApi, 'bulkImport')
|
|
const { onClose } = setup('Socks')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
|
|
|
expect(onClose).toHaveBeenCalled()
|
|
expect(spy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-PACKIMP-013: explains the Markdown form and lets the file picker take .md (#875)', () => {
|
|
setup()
|
|
expect(screen.getByText('packing.importHintMarkdown')).toBeInTheDocument()
|
|
expect(document.querySelector('input[type="file"]')).toHaveAttribute('accept', '.csv,.txt,.md,.markdown,text/markdown')
|
|
})
|
|
|
|
it('FE-MOB-PACKIMP-014: posts a pasted Markdown checklist with categories, quantities and checkmarks', async () => {
|
|
const spy = vi.spyOn(packingApi, 'bulkImport').mockResolvedValue({ items: [], count: 2 })
|
|
setup('## Clothing\n- [x] 3x Socks (40 g)\n- [ ] Rain jacket')
|
|
fireEvent.click(screen.getByRole('button', { name: 'packing.importAction:2' }))
|
|
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1))
|
|
expect(spy).toHaveBeenCalledWith(7, [
|
|
expect.objectContaining({ name: 'Socks', category: 'Clothing', quantity: 3, weight_grams: '40', checked: true }),
|
|
expect.objectContaining({ name: 'Rain jacket', category: 'Clothing', checked: false }),
|
|
])
|
|
})
|
|
})
|