mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Merge branch 'dev' into feat/comaps-integration
# Conflicts: # client/src/components/Planner/DayPlanSidebar.tsx
This commit is contained in:
@@ -68,6 +68,38 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// Dependabot cannot be pointed at dev. `target-branch` in dependabot.yml
|
||||
// applies to version updates only; security updates always open against
|
||||
// the default branch, and security updates are all this repository gets
|
||||
// (there is no dependabot.yml). Labelling them meant close-stale-wrong-branch
|
||||
// shut them 24 hours later, which is how 13 of them died. Retarget instead:
|
||||
// a bot cannot click "Edit" the way the comment below asks a human to.
|
||||
if (context.payload.pull_request.user.login === 'dependabot[bot]') {
|
||||
try {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
base: 'dev',
|
||||
});
|
||||
if (labels.includes('wrong-base-branch')) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
}
|
||||
console.log('Retargeted the Dependabot PR onto dev.');
|
||||
return;
|
||||
} catch (err) {
|
||||
// Retargeting can fail when the branch was cut from main and dev has
|
||||
// moved on. Fall through to the normal path so a human sees it rather
|
||||
// than the check passing on a PR that is still aimed at main.
|
||||
console.log(`Could not retarget the Dependabot PR: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Base is main — check if this user is a maintainer
|
||||
let permission = 'none';
|
||||
try {
|
||||
|
||||
@@ -114,6 +114,7 @@ RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/
|
||||
chown -h node:node /app/server/uploads /app/server/data
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_USE_ENV_PROXY=1
|
||||
ENV PORT=3000
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { test, expect, devices } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// Phone regression guard for #1809: the dashboard must scroll the DOCUMENT.
|
||||
//
|
||||
// iOS Safari minimises its address bar only in reaction to the root scroller
|
||||
// moving. V4 blocked that twice over: html was globally overflow:hidden and the
|
||||
// mobile shell added a scroll container of its own, so the gesture never
|
||||
// reached the viewport and the bar kept ~1/6th of the screen forever. The bar
|
||||
// itself is not observable from page context, but the invariant it hangs off is:
|
||||
// document.scrollingElement has to move.
|
||||
//
|
||||
// Needs WebKit (`npx playwright install webkit`). playwright.config.ts has no
|
||||
// webkit project, the engine comes from this file-level device (defaultBrowserType
|
||||
// 'webkit'), same as e2e/ipad-scroll-1432.spec.ts. WebKit is the point here, not
|
||||
// a nicety: viewport propagation and the toolbar behaviour are Safari's.
|
||||
//
|
||||
// The file sorts last in the e2e run, so the trips it seeds cannot show up in
|
||||
// another spec's dashboard assertions.
|
||||
test.use({ ...devices['iPhone 14'] })
|
||||
|
||||
function readScroll() {
|
||||
const el = document.scrollingElement as HTMLElement
|
||||
return {
|
||||
top: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
viewport: window.innerHeight,
|
||||
htmlOverflow: getComputedStyle(document.documentElement).overflowY,
|
||||
}
|
||||
}
|
||||
|
||||
test('#1809 iPhone: flow screens scroll the document, full-screen ones do not', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
// The context has to be the one from the report: phone width, coarse pointer.
|
||||
// If either is wrong, nothing below proves anything.
|
||||
const env = await page.evaluate(() => ({
|
||||
width: window.innerWidth,
|
||||
coarse: window.matchMedia('(pointer: coarse)').matches,
|
||||
}))
|
||||
expect(env.width, 'iPhone sits below the 768px phone breakpoint').toBeLessThan(768)
|
||||
expect(env.coarse, 'iPhone reports a coarse primary pointer').toBe(true)
|
||||
|
||||
// Enough trips for the dashboard to overflow a phone screen.
|
||||
const stamp = Date.now()
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
const res = await page.request.post('/api/trips', {
|
||||
data: { title: `URL bar ${stamp} ${i}`, start_date: '2099-06-01', end_date: '2099-06-10' },
|
||||
})
|
||||
expect(res.ok(), `seed trip ${i}`).toBeTruthy()
|
||||
}
|
||||
await page.reload()
|
||||
await dismissSystemNotices(page)
|
||||
await expect(page.getByText(`URL bar ${stamp} 1`).first()).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// 1. The root scroller is unlocked and the page is longer than the viewport.
|
||||
const before = await page.evaluate(readScroll)
|
||||
expect(before.htmlOverflow, 'html must not lock the viewport scroller').not.toBe('hidden')
|
||||
expect(before.scrollHeight, 'dashboard is longer than one viewport').toBeGreaterThan(before.viewport)
|
||||
expect(before.top).toBe(0)
|
||||
|
||||
// 2. Scrolling moves the document, the signal Safari retracts its bar for.
|
||||
await page.evaluate(() => window.scrollBy(0, 400))
|
||||
const scrolled = await page.evaluate(readScroll)
|
||||
expect(scrolled.top, 'the document itself scrolled').toBeGreaterThan(0)
|
||||
|
||||
// 3. The brand tile scrolls the page back up (it used to walk up to the shell
|
||||
// container, which no longer exists).
|
||||
await page.getByRole('button', { name: 'TREK' }).click()
|
||||
await expect.poll(() => page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(0)
|
||||
|
||||
// 4. The shared body scroll lock: with the document as the scroller, an open
|
||||
// sheet must freeze the page behind it and give the position back.
|
||||
await page.evaluate(() => window.scrollBy(0, 300))
|
||||
const locked = await page.evaluate(readScroll)
|
||||
expect(locked.top).toBeGreaterThan(0)
|
||||
|
||||
await page.getByRole('button', { name: 'New Trip' }).click()
|
||||
await expect(page.getByRole('dialog')).toBeVisible()
|
||||
await page.evaluate(() => window.scrollBy(0, 300))
|
||||
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(locked.top)
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0)
|
||||
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(locked.top)
|
||||
await page.evaluate(() => window.scrollBy(0, 200))
|
||||
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBeGreaterThan(locked.top)
|
||||
|
||||
// 5. Counter-probe: the map screens keep their own full-viewport layout. They
|
||||
// deliberately do NOT scroll the document (and so keep the address bar), and
|
||||
// the map must not collapse to zero height now that the shell has none.
|
||||
await page.goto('/atlas')
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
const atlas = await page.evaluate(() => {
|
||||
const map = document.querySelector('.leaflet-container') as HTMLElement
|
||||
window.scrollBy(0, 400)
|
||||
return {
|
||||
top: document.scrollingElement!.scrollTop,
|
||||
mapHeight: map.getBoundingClientRect().height,
|
||||
}
|
||||
})
|
||||
expect(atlas.top, 'the atlas does not move the document').toBe(0)
|
||||
expect(atlas.mapHeight, 'the atlas map keeps a real height').toBeGreaterThan(200)
|
||||
})
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
CollectionInviteActionRequest,
|
||||
CollectionInviteCancelRequest,
|
||||
CollectionStatus,
|
||||
CollectionSetStatusManyResponse,
|
||||
Collection,
|
||||
CollectionPlace,
|
||||
CollectionLabel,
|
||||
@@ -77,6 +78,10 @@ export const collectionsApi = {
|
||||
postMultipart(`${base}/places/${pid}/image`, formData),
|
||||
setStatus: (pid: number, status: CollectionStatus): Promise<CollectionPlace> =>
|
||||
ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data),
|
||||
setStatusMany: (ids: number[], status: CollectionStatus): Promise<CollectionSetStatusManyResponse> =>
|
||||
ax.post(`${base}/places/status-many`, { ids, status }).then((r: AxiosResponse) => r.data),
|
||||
setStatusFromTrip: (tripId: number, placeIds: number[], status: CollectionStatus): Promise<CollectionSetStatusManyResponse> =>
|
||||
ax.post(`${base}/places/status-from-trip`, { trip_id: tripId, place_ids: placeIds, status }).then((r: AxiosResponse) => r.data),
|
||||
ratePlace: (pid: number, rating: number | null): Promise<CollectionPlace> =>
|
||||
rating === null
|
||||
? ax.delete(`${base}/places/${pid}/rating`).then((r: AxiosResponse) => r.data)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, writeTicketItems } from './CostsPanel.helpers'
|
||||
import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
|
||||
describe('splitCents', () => {
|
||||
it('splits evenly when it divides cleanly', () => {
|
||||
@@ -132,3 +132,47 @@ describe('readUserNote', () => {
|
||||
expect(readUserNote(null)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Receipt splits have to reconcile (#1382) ─────────────────────────────────
|
||||
|
||||
describe('calculateTicketShares', () => {
|
||||
const line = (price: string, parts: number[]): TicketItem =>
|
||||
({ id: price + parts.join(), name: 'Line', price, participants: new Set(parts) })
|
||||
const shareSum = (shares: Record<number, number>) =>
|
||||
Object.values(shares).reduce((a, v) => a + Math.round(v * 100), 0)
|
||||
|
||||
it('splits each line among the people on it', () => {
|
||||
const { shares, total } = calculateTicketShares([line('10', [1, 2]), line('6', [2])])
|
||||
expect(shares).toEqual({ 1: 5, 2: 11 })
|
||||
expect(total).toBe(16)
|
||||
})
|
||||
|
||||
it('spreads a line nobody was ticked for across everyone on the receipt', () => {
|
||||
// The service charge used to count toward the total and toward nobody's share,
|
||||
// so the expense carried a permanent difference no settle-up could clear.
|
||||
const { shares, total } = calculateTicketShares([
|
||||
line('10', [1, 2]),
|
||||
line('5', []),
|
||||
line('x', [2]),
|
||||
])
|
||||
expect(shares).toEqual({ 1: 7.5, 2: 7.5 })
|
||||
expect(total).toBe(15)
|
||||
expect(shareSum(shares)).toBe(Math.round(total * 100))
|
||||
})
|
||||
|
||||
it('hands out the remainder cents so the shares add back up to the total', () => {
|
||||
const { shares, total } = calculateTicketShares([line('10', [1, 2, 3]), line('0.01', [])])
|
||||
expect(shareSum(shares)).toBe(Math.round(total * 100))
|
||||
expect(total).toBe(10.01)
|
||||
})
|
||||
|
||||
it('splits nothing when the receipt has no participants anywhere', () => {
|
||||
const { shares, total } = calculateTicketShares([line('10', []), line('5', [])])
|
||||
expect(shares).toEqual({})
|
||||
expect(total).toBe(15)
|
||||
})
|
||||
|
||||
it('is empty for an empty receipt', () => {
|
||||
expect(calculateTicketShares([])).toEqual({ shares: {}, total: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -135,24 +135,36 @@ export function readUserNote(item: { note?: string | null } | null | undefined):
|
||||
return note && !note.startsWith('TICKETJSON:') ? note : ''
|
||||
}
|
||||
|
||||
/** Per-person totals for a receipt split line by line, plus the receipt total. */
|
||||
/**
|
||||
* Per-person totals for a receipt split line by line, plus the receipt total.
|
||||
*
|
||||
* Every cent on the receipt lands on somebody: a line nobody was ticked for is
|
||||
* carried by everyone who appears on the receipt, so the shares add back up to
|
||||
* the total. They used to count toward the total only, which left the expense
|
||||
* with member debits that could never cancel the payer's credit — a permanent
|
||||
* difference the settle-up view showed as debt but had no flow to clear (#1382).
|
||||
* A receipt with no participants at all splits nothing; it is not saveable
|
||||
* anyway, since every line needs someone on it.
|
||||
*/
|
||||
export function calculateTicketShares(items: TicketItem[]): { shares: Record<number, number>; total: number } {
|
||||
const shares: Record<number, number> = {}
|
||||
let totalCents = 0
|
||||
|
||||
const everyone = [...new Set(items.flatMap(i => [...i.participants]))].sort((a, b) => a - b)
|
||||
|
||||
for (const item of items) {
|
||||
const priceNum = parseFloat(item.price) || 0
|
||||
const priceCents = Math.round(priceNum * 100)
|
||||
totalCents += priceCents
|
||||
|
||||
const partIds = [...item.participants]
|
||||
const n = partIds.length
|
||||
const sortedPartIds = item.participants.size > 0
|
||||
? [...item.participants].sort((a, b) => a - b)
|
||||
: everyone
|
||||
const n = sortedPartIds.length
|
||||
if (n === 0) continue
|
||||
|
||||
const baseCents = Math.floor(priceCents / n)
|
||||
const remainder = priceCents % n
|
||||
|
||||
const sortedPartIds = [...partIds].sort((a, b) => a - b)
|
||||
const remainder = priceCents - baseCents * n
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const id = sortedPartIds[i]
|
||||
|
||||
@@ -1393,6 +1393,26 @@ describe('CostsPanel — expense modal', () => {
|
||||
expect(posted).toMatchObject({ name: 'Hotel Astoria', category: 'accommodation', total_price: 240, reservation_id: 12 })
|
||||
expect(onSaved).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-038b: a prefill from a place links the expense to that place (#1298)', async () => {
|
||||
const user = userEvent.setup()
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: dinner() })
|
||||
}))
|
||||
render(
|
||||
<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
|
||||
prefill={{ name: 'Louvre', category: 'activities', amount: 34, placeId: 7 }}
|
||||
onClose={() => {}} onSaved={vi.fn()} />
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Add expense' }))
|
||||
|
||||
await waitFor(() => expect(posted).toBeTruthy());
|
||||
expect(posted).toMatchObject({ name: 'Louvre', category: 'activities', total_price: 34, place_id: 7 })
|
||||
expect(posted).not.toHaveProperty('reservation_id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CostsPanel — remaining paths', () => {
|
||||
@@ -1742,13 +1762,15 @@ describe('CostsPanel — split maths', () => {
|
||||
expect(splitEqualShares(10, [{ user_id: 1 }, { user_id: 2 }, { user_id: 3 }], 1)).toEqual({ 1: 3.33, 2: 3.34, 3: 3.33 })
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-041: ticket items nobody is assigned to still count toward the total', () => {
|
||||
it('FE-W5COSTS-041: a receipt line nobody is assigned to is carried by everyone on the receipt', () => {
|
||||
const items: TicketItem[] = [
|
||||
{ id: 'a', name: 'Apples', price: '10', participants: new Set([1, 2]) },
|
||||
{ id: 'b', name: 'Service', price: '5', participants: new Set() },
|
||||
{ id: 'c', name: 'Cake', price: 'x', participants: new Set([2]) },
|
||||
]
|
||||
|
||||
expect(calculateTicketShares(items)).toEqual({ shares: { 1: 5, 2: 5 }, total: 15 })
|
||||
// The service line used to count toward the total without landing on anyone,
|
||||
// so the shares stayed a permanent 5.00 short of it (#1382).
|
||||
expect(calculateTicketShares(items)).toEqual({ shares: { 1: 7.5, 2: 7.5 }, total: 15 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1000,6 +1000,8 @@ export interface ExpensePrefill {
|
||||
category?: string
|
||||
amount?: number
|
||||
reservationId?: number
|
||||
/** Set when the expense is being created from a place (#1298). */
|
||||
placeId?: number
|
||||
}
|
||||
|
||||
export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: {
|
||||
@@ -1232,7 +1234,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
.map(id => ({ user_id: id, amount: parseFloat(payerAmounts[id]) || 0 }))
|
||||
.filter(p => p.amount > 0)
|
||||
: payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : []
|
||||
const memberList = [...participants].map(id => ({
|
||||
// A receipt line can name somebody who is not ticked as a participant. Sending
|
||||
// only the ticked set would drop their share, leaving the member sum short of
|
||||
// total_price and handing the settlement a difference it can never clear (#1382).
|
||||
const memberIds = splitMode === 'ticket'
|
||||
? [...new Set([...participants, ...Object.keys(ticketInfo.shares).map(Number)])].sort((a, b) => a - b)
|
||||
: [...participants]
|
||||
const memberList = memberIds.map(id => ({
|
||||
user_id: id,
|
||||
amount: splitMode === 'custom'
|
||||
? (parseFloat(customAmounts[id]) || 0)
|
||||
@@ -1246,12 +1254,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
currency,
|
||||
payers: payerList,
|
||||
members: memberList,
|
||||
member_ids: [...participants],
|
||||
member_ids: memberIds,
|
||||
expense_date: day || null,
|
||||
total_price: totalNum,
|
||||
note: note.trim() || null,
|
||||
ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null,
|
||||
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
||||
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
|
||||
}
|
||||
try {
|
||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||
|
||||
@@ -13,7 +13,10 @@ export function ChatMessages(props: any) {
|
||||
<>
|
||||
{/* Messages */}
|
||||
{messages.length === 0 ? (
|
||||
<EmptyState scene="chat" title={t('collab.chat.empty')} />
|
||||
// flex-1, like the message list it stands in for: without it the empty
|
||||
// state was only as tall as its own content and the composer rode up to
|
||||
// sit right under the mascot, with the rest of the panel left blank.
|
||||
<EmptyState scene="chat" title={t('collab.chat.empty')} className="min-h-0 flex-1" />
|
||||
) : (
|
||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-ADDPLACECOL-001 to FE-COMP-ADDPLACECOL-024
|
||||
// FE-COMP-ADDPLACECOL-001 to FE-COMP-ADDPLACECOL-028
|
||||
import React from 'react'
|
||||
import type { Mock } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
@@ -163,13 +163,27 @@ describe('AddPlaceToCollectionModal', () => {
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Places API disabled', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-009: a search response without places yields no dropdown', async () => {
|
||||
it('FE-COMP-ADDPLACECOL-009: a search that finds nothing says so instead of staying silent', async () => {
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({ source: 'nominatim' })))
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Search/ })).not.toBeDisabled())
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
|
||||
|
||||
expect(await screen.findByText('No places found')).toBeInTheDocument()
|
||||
// Typing the next query clears it again.
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissaten' } })
|
||||
expect(screen.queryByText('No places found')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-009b: the empty search panel can be dismissed', async () => {
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'nominatim' })))
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
|
||||
await screen.findByText('No places found')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(screen.queryByText('No places found')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-010: saving posts the manually typed place with nulls for what is empty', async () => {
|
||||
@@ -360,4 +374,49 @@ describe('AddPlaceToCollectionModal', () => {
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
expect(savedBodies).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-025: the caret goes back to the search field after an add', async () => {
|
||||
setup()
|
||||
const nameField = screen.getByPlaceholderText('Name')
|
||||
typeName('Manual spot')
|
||||
// Where the focus sits in the browser once the add button disables itself.
|
||||
nameField.focus()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
|
||||
|
||||
await waitFor(() => expect(nameField).toHaveValue(''))
|
||||
expect(screen.getByPlaceholderText('Search for a place…')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-026: the search still runs after a place was added', async () => {
|
||||
mockSearch([mapsResult])
|
||||
setup()
|
||||
typeName('Manual spot')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('Name')).toHaveValue(''))
|
||||
|
||||
const input = screen.getByPlaceholderText('Search for a place…')
|
||||
expect(input).toHaveValue('')
|
||||
fireEvent.change(input, { target: { value: 'kissa' } })
|
||||
expect(screen.getByRole('button', { name: /Search/ })).not.toBeDisabled()
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(await screen.findByText('Kissa Sakaiki')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-027: the search row is pinned to the top of the scrolling body', () => {
|
||||
setup()
|
||||
// The dialog stays open after an add, so the row has to survive a scrolled form.
|
||||
expect(screen.getByPlaceholderText('Search for a place…').closest('.sticky')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-028: a failed save leaves the caret where the user had it', async () => {
|
||||
server.use(http.post('/api/addons/collections/places', () => HttpResponse.json({ error: 'List is full' }, { status: 400 })))
|
||||
setup()
|
||||
const nameField = screen.getByPlaceholderText('Name')
|
||||
typeName('Manual spot')
|
||||
nameField.focus()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('List is full', 'error', undefined))
|
||||
expect(nameField).toHaveFocus()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,9 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<MapsPlace[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
// A search that came back empty used to render nothing at all, which reads as
|
||||
// "the dialog is dead". Say so instead (#1921).
|
||||
const [noResults, setNoResults] = useState(false)
|
||||
// The picked location (address/coords/ids) plus the editable fields.
|
||||
const [picked, setPicked] = useState<MapsPlace | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
@@ -56,16 +59,20 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
const [status, setStatus] = useState<CollectionStatus>('idea')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const descRef = useRef<HTMLTextAreaElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const reset = () => { setQuery(''); setResults([]); setPicked(null); setName(''); setAddress(''); setLat(''); setLng(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') }
|
||||
const reset = () => { setQuery(''); setResults([]); setNoResults(false); setPicked(null); setName(''); setAddress(''); setLat(''); setLng(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') }
|
||||
useEffect(() => { if (!isOpen) reset() }, [isOpen])
|
||||
|
||||
const search = async () => {
|
||||
if (!query.trim()) return
|
||||
setSearching(true)
|
||||
setNoResults(false)
|
||||
try {
|
||||
const res = await mapsApi.search(query, language)
|
||||
setResults((res.places as MapsPlace[]) || [])
|
||||
const places = (res.places as MapsPlace[]) || []
|
||||
setResults(places)
|
||||
setNoResults(places.length === 0)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
} finally {
|
||||
@@ -73,6 +80,8 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
}
|
||||
}
|
||||
|
||||
const dismissResults = () => { setResults([]); setNoResults(false) }
|
||||
|
||||
const pick = (r: MapsPlace) => {
|
||||
setPicked(r)
|
||||
setName(str(r.name) ?? '')
|
||||
@@ -80,7 +89,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
const la = num(r.lat); const lo = num(r.lng)
|
||||
setLat(la != null ? String(la) : '')
|
||||
setLng(lo != null ? String(lo) : '')
|
||||
setResults([]); setQuery(str(r.name) ?? query)
|
||||
setResults([]); setNoResults(false); setQuery(str(r.name) ?? query)
|
||||
}
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
|
||||
@@ -112,6 +121,11 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
if (res.duplicate) toast.info(t('collections.duplicateWarning'))
|
||||
else { toast.success(t('collections.addedToList', { name: collectionName })); onAdded() }
|
||||
reset()
|
||||
// The dialog stays open for the next place, so hand the caret back to the
|
||||
// search field. The add button the user just clicked goes disabled with the
|
||||
// cleared name, which drops the focus to <body> and leaves the dialog dead
|
||||
// to the keyboard (#1921).
|
||||
searchRef.current?.focus()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
@@ -142,42 +156,51 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Search — picking a result fills the location below */}
|
||||
<div className="relative">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); search() } }}
|
||||
placeholder={t('collections.addPlaceSearch')}
|
||||
className="w-full pl-9 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={search} disabled={!query.trim() || searching} className="px-4 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-2">
|
||||
{searching ? <Loader2 size={15} className="animate-spin" /> : <Search size={15} />}
|
||||
{t('common.search')}
|
||||
</button>
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<div className="absolute z-20 left-0 right-0 mt-1.5 max-h-[280px] overflow-y-auto rounded-xl border border-edge bg-surface-card shadow-lg p-1.5 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-content-faint">{t('common.search')}</span>
|
||||
<button type="button" onClick={() => setResults([])} className="p-1 rounded-md text-content-faint hover:text-content hover:bg-surface-hover" aria-label={t('common.close')}><X size={13} /></button>
|
||||
{/* Search — picking a result fills the location below. Pinned to the top of
|
||||
the scrolling dialog body: the dialog stays open after an add, and the
|
||||
search row used to sit above the viewport once the form was scrolled,
|
||||
so it looked like the button had vanished (#1921). The negative margins
|
||||
let its background cover the body padding while it is stuck. */}
|
||||
<div className="sticky top-0 z-20 -mx-6 -mt-6 -mb-4 px-6 pt-6 pb-4 bg-surface-card">
|
||||
<div className="relative">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
|
||||
<input
|
||||
autoFocus
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setNoResults(false) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); search() } }}
|
||||
placeholder={t('collections.addPlaceSearch')}
|
||||
className="w-full pl-9 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
{results.map((r, i) => (
|
||||
<button key={i} type="button" onClick={() => pick(r)} className="flex items-center gap-3 px-2.5 py-2 rounded-lg text-left hover:bg-surface-hover transition-colors">
|
||||
<div className="w-8 h-8 min-w-[32px] rounded-lg bg-surface-secondary flex items-center justify-center text-content-faint shrink-0"><MapPin size={15} /></div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[13px] font-semibold text-content truncate">{str(r.name)}</span>
|
||||
{str(r.address) && <span className="text-[11.5px] text-content-faint truncate">{str(r.address)}</span>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={search} disabled={!query.trim() || searching} className="px-4 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-2">
|
||||
{searching ? <Loader2 size={15} className="animate-spin" /> : <Search size={15} />}
|
||||
{t('common.search')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(results.length > 0 || noResults) && (
|
||||
<div className="absolute z-20 left-0 right-0 mt-1.5 max-h-[280px] overflow-y-auto rounded-xl border border-edge bg-surface-card shadow-lg p-1.5 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-content-faint">{t('common.search')}</span>
|
||||
<button type="button" onClick={dismissResults} className="p-1 rounded-md text-content-faint hover:text-content hover:bg-surface-hover" aria-label={t('common.close')}><X size={13} /></button>
|
||||
</div>
|
||||
{noResults ? (
|
||||
<div className="px-2.5 py-3 text-center text-[12.5px] text-content-faint">{t('planner.noPlacesFound')}</div>
|
||||
) : results.map((r, i) => (
|
||||
<button key={i} type="button" onClick={() => pick(r)} className="flex items-center gap-3 px-2.5 py-2 rounded-lg text-left hover:bg-surface-hover transition-colors">
|
||||
<div className="w-8 h-8 min-w-[32px] rounded-lg bg-surface-secondary flex items-center justify-center text-content-faint shrink-0"><MapPin size={15} /></div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[13px] font-semibold text-content truncate">{str(r.name)}</span>
|
||||
{str(r.address) && <span className="text-[11.5px] text-content-faint truncate">{str(r.address)}</span>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-035
|
||||
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-043
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
@@ -335,6 +335,8 @@ describe('CollectionPlaceDetail', () => {
|
||||
links: [{ label: undefined, url: 'https://x.com' }],
|
||||
category_id: 2,
|
||||
label_ids: [1, 2],
|
||||
// Untouched in this case, but the address rides along on every save (#1870).
|
||||
address: 'Somewhere',
|
||||
lat: 52.5,
|
||||
lng: 13.4,
|
||||
});
|
||||
@@ -547,3 +549,56 @@ describe('CollectionPlaceDetail — open in maps', () => {
|
||||
expect(screen.queryByRole('button', { name: /navigation|google maps/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Editable address (#1870) ─────────────────────────────────────────────────
|
||||
|
||||
describe('CollectionPlaceDetail: editable address', () => {
|
||||
it('FE-COMP-COLDETAIL-040: edit mode offers an address field seeded from the place', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDetail({ canEdit: true });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
|
||||
expect(screen.getByText('Address')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('Somewhere');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-041: a corrected address is trimmed into the patch', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: true });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
|
||||
const addressInput = screen.getByPlaceholderText('Street, City, Country');
|
||||
await user.clear(addressInput);
|
||||
await user.type(addressInput, ' Via Nuova 1 ');
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
|
||||
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({ address: 'Via Nuova 1' }));
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-042: an emptied address is sent as null', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: true });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
|
||||
await user.clear(screen.getByPlaceholderText('Street, City, Country'));
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
|
||||
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({ address: null }));
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-043: Cancel restores the stored address', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: true });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Street, City, Country'), ' 2');
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(props.onSave).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Somewhere')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('Somewhere');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ interface CollectionPlaceDetailProps {
|
||||
anchorRect?: { left: number; width: number } | null
|
||||
onClose: () => void
|
||||
onSetStatus: (status: CollectionStatus) => void
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null; lat?: number | null; lng?: number | null }) => Promise<void>
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null; lat?: number | null; lng?: number | null; address?: string | null }) => Promise<void>
|
||||
/** Upload a custom cover image (#1136); enables the cover change/remove controls. */
|
||||
onUploadImage?: (file: File) => Promise<void>
|
||||
onCopyToTrip: () => void
|
||||
@@ -83,6 +83,7 @@ export default function CollectionPlaceDetail({
|
||||
const [description, setDescription] = useState(place.description ?? '')
|
||||
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
|
||||
const [labelIds, setLabelIds] = useState<number[]>(place.label_ids ?? [])
|
||||
const [address, setAddress] = useState(place.address ?? '')
|
||||
const [lat, setLat] = useState(place.lat != null ? String(place.lat) : '')
|
||||
const [lng, setLng] = useState(place.lng != null ? String(place.lng) : '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -99,6 +100,7 @@ export default function CollectionPlaceDetail({
|
||||
setDescription(place.description ?? '')
|
||||
setLinks(place.links ?? [])
|
||||
setLabelIds(place.label_ids ?? [])
|
||||
setAddress(place.address ?? '')
|
||||
setLat(place.lat != null ? String(place.lat) : '')
|
||||
setLng(place.lng != null ? String(place.lng) : '')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -147,7 +149,7 @@ export default function CollectionPlaceDetail({
|
||||
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id])
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []); setLat(place.lat != null ? String(place.lat) : ''); setLng(place.lng != null ? String(place.lng) : '') }
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []); setAddress(place.address ?? ''); setLat(place.lat != null ? String(place.lat) : ''); setLng(place.lng != null ? String(place.lng) : '') }
|
||||
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
||||
@@ -161,7 +163,7 @@ export default function CollectionPlaceDetail({
|
||||
const lngNum = lng.trim() ? Number(lng) : NaN
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds, lat: Number.isFinite(latNum) ? latNum : null, lng: Number.isFinite(lngNum) ? lngNum : null })
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds, address: address.trim() || null, lat: Number.isFinite(latNum) ? latNum : null, lng: Number.isFinite(lngNum) ? lngNum : null })
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
@@ -280,6 +282,11 @@ export default function CollectionPlaceDetail({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Address (#1870): free text, the coordinates stay separate */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label"><MapPin size={12} /> {t('places.formAddress')}</div>
|
||||
<input value={address} onChange={e => setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className="col-detail-input" />
|
||||
</div>
|
||||
{/* Coordinates */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label"><MapPin size={12} /> {t('collections.coordinates')}</div>
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('MSaveToCollectionSheet', () => {
|
||||
it('FE-COMP-MSAVESHEET-006: lists already holding the place are marked saved', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }],
|
||||
});
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
@@ -167,7 +167,7 @@ describe('MSaveToCollectionSheet', () => {
|
||||
it('FE-COMP-MSAVESHEET-010: tapping a saved list removes that place instead', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }],
|
||||
});
|
||||
const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({});
|
||||
openFor();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Bookmark, BookmarkCheck, Check, Loader2, Plus, X } from 'lucide-react'
|
||||
import { Bookmark, BookmarkCheck, Check, CheckCircle2, Loader2, Plus, X } from 'lucide-react'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import MIconBtn from '../../mobile/components/MIconBtn'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -8,7 +8,8 @@ import { useTranslation } from '../../i18n'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import type { Collection, CollectionMembership } from '@trek/shared'
|
||||
import { STATUS_META, nextStatus } from '../../pages/collections/collectionsModel'
|
||||
import type { Collection, CollectionMembership, CollectionStatus } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Mobile counterpart of SaveToCollectionModal — the same store-driven list
|
||||
@@ -71,12 +72,44 @@ export default function MSaveToCollectionSheet() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [target])
|
||||
|
||||
const savedByCollection = new Map<number, number>()
|
||||
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l.place_id)
|
||||
const savedByCollection = new Map<number, CollectionMembership['lists'][number]>()
|
||||
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l)
|
||||
|
||||
/** Lists holding this place that the viewer may edit and that are not visited yet. */
|
||||
const unvisited = (membership?.lists ?? []).filter(l => l.can_edit && l.status !== 'visited')
|
||||
|
||||
const handleStatus = async (entry: CollectionMembership['lists'][number], next: CollectionStatus) => {
|
||||
if (busyId != null) return
|
||||
setBusyId(entry.collection_id)
|
||||
try {
|
||||
await collectionsApi.setStatus(entry.place_id, next)
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVisitedEverywhere = async () => {
|
||||
if (busyId != null || unvisited.length === 0) return
|
||||
setBusyId(-1)
|
||||
try {
|
||||
const { updated } = await collectionsApi.setStatusMany(unvisited.map(l => l.place_id), 'visited')
|
||||
toast.success(t('collections.markedVisited', { count: updated }))
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (list: Collection) => {
|
||||
if (busyId != null || !target) return
|
||||
const savedPlaceId = savedByCollection.get(list.id)
|
||||
const savedPlaceId = savedByCollection.get(list.id)?.place_id
|
||||
setBusyId(list.id)
|
||||
try {
|
||||
if (savedPlaceId != null) {
|
||||
@@ -156,8 +189,11 @@ export default function MSaveToCollectionSheet() {
|
||||
</div>
|
||||
) : (
|
||||
lists.map(list => {
|
||||
const saved = savedByCollection.has(list.id)
|
||||
const entry = savedByCollection.get(list.id)
|
||||
const saved = !!entry
|
||||
const busy = busyId === list.id
|
||||
const statusMeta = entry ? STATUS_META[entry.status] : null
|
||||
const StatusIcon = statusMeta?.icon
|
||||
return (
|
||||
<button
|
||||
key={list.id}
|
||||
@@ -178,9 +214,27 @@ export default function MSaveToCollectionSheet() {
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{list.name}</span>
|
||||
<span className="mt-px block font-geist text-[0.65625rem] text-m-muted">
|
||||
{t('collections.placeCount', { count: list.place_count ?? 0 })}
|
||||
</span>
|
||||
{/* Saved lists show their own status here instead of the place
|
||||
count — that number matters when picking a list, the status
|
||||
matters once the place is in it (#1469). */}
|
||||
{entry && statusMeta && StatusIcon ? (
|
||||
<span
|
||||
role={entry.can_edit ? 'button' : undefined}
|
||||
tabIndex={entry.can_edit ? 0 : undefined}
|
||||
aria-label={t(statusMeta.labelKey)}
|
||||
onClick={entry.can_edit ? e => { e.preventDefault(); e.stopPropagation(); void handleStatus(entry, nextStatus(entry.status)) } : undefined}
|
||||
onKeyDown={entry.can_edit ? e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); void handleStatus(entry, nextStatus(entry.status)) } } : undefined}
|
||||
className="mt-[3px] inline-flex items-center gap-1 rounded-full bg-[color:var(--m-inner)] px-2 py-[3px] font-geist text-[0.65625rem] font-semibold"
|
||||
style={{ color: statusMeta.color }}
|
||||
>
|
||||
<StatusIcon size={11} strokeWidth={2.4} />
|
||||
{t(statusMeta.labelKey)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mt-px block font-geist text-[0.65625rem] text-m-muted">
|
||||
{t('collections.placeCount', { count: list.place_count ?? 0 })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{list.is_owner === false && (
|
||||
<span className="flex-none font-geist text-[0.5625rem] font-bold uppercase tracking-[.05em] text-m-faint">
|
||||
@@ -200,6 +254,22 @@ export default function MSaveToCollectionSheet() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unvisited.length > 0 && (
|
||||
<div className="flex-none px-[14px] pb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVisitedEverywhere}
|
||||
disabled={busyId != null}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-[10px] text-[0.78125rem] font-semibold text-m-ink disabled:opacity-60"
|
||||
>
|
||||
{busyId === -1
|
||||
? <Loader2 size={14} className="animate-spin" />
|
||||
: <CheckCircle2 size={14} strokeWidth={2.2} />}
|
||||
{unvisited.length > 1 ? t('collections.markVisitedAll') : t('collections.markVisited')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lists.length > 0 && (
|
||||
<div className="flex flex-none items-center justify-between gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] py-3">
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-SAVETOCOL-001 to FE-COMP-SAVETOCOL-016
|
||||
// FE-COMP-SAVETOCOL-001 to FE-COMP-SAVETOCOL-020
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
|
||||
@@ -120,7 +120,7 @@ describe('SaveToCollectionModal', () => {
|
||||
it('FE-COMP-SAVETOCOL-006: lists already holding the place are marked saved', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }],
|
||||
});
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
@@ -194,7 +194,7 @@ describe('SaveToCollectionModal', () => {
|
||||
it('FE-COMP-SAVETOCOL-011: picking a saved list removes that place instead', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }],
|
||||
});
|
||||
const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({});
|
||||
const save = vi.spyOn(collectionsApi, 'savePlace');
|
||||
@@ -263,6 +263,65 @@ describe('SaveToCollectionModal', () => {
|
||||
expect(screen.queryByText('Favorites')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Per-list status + "visited everywhere" (#1469) ────────────────────────
|
||||
|
||||
const savedIn = (over: Partial<CollectionMembership['lists'][number]> = {}): CollectionMembership => ({
|
||||
saved: true,
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true, ...over }],
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-017: a saved list shows its own status and one tap cycles it', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(savedIn());
|
||||
const setStatus = vi.spyOn(collectionsApi, 'setStatus').mockResolvedValue({} as never);
|
||||
const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({});
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
const badge = await screen.findByRole('button', { name: 'Want to go' });
|
||||
fireEvent.click(badge);
|
||||
|
||||
// want → visited, and the row toggle must NOT have fired underneath it.
|
||||
await waitFor(() => expect(setStatus).toHaveBeenCalledWith(900, 'visited'));
|
||||
expect(del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-018: the header action marks every list holding the place visited', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [
|
||||
{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true },
|
||||
{ collection_id: 2, name: 'Wishlist', place_id: 901, status: 'idea', can_edit: true },
|
||||
],
|
||||
});
|
||||
const many = vi.spyOn(collectionsApi, 'setStatusMany').mockResolvedValue({ updated: 2 });
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Visited everywhere' }));
|
||||
await waitFor(() => expect(many).toHaveBeenCalledWith([900, 901], 'visited'));
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-019: the action disappears once every list is visited', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(savedIn({ status: 'visited' }));
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
await screen.findByText('Favorites');
|
||||
expect(screen.queryByRole('button', { name: /Visited everywhere|Mark visited/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-020: a read-only list shows its status but does not offer to change it', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(savedIn({ can_edit: false }));
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
await screen.findByText('Favorites');
|
||||
expect(screen.queryByRole('button', { name: 'Want to go' })).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle('Want to go')).toBeInTheDocument();
|
||||
// Nothing to offer: the only list holding it is one the viewer cannot edit.
|
||||
expect(screen.queryByRole('button', { name: /Visited everywhere|Mark visited/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-014: the footer navigates to the collections page or just closes', async () => {
|
||||
openFor();
|
||||
const { unmount } = render(<SaveToCollectionModal />);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Bookmark, BookmarkCheck, Check, Loader2, Plus } from 'lucide-react'
|
||||
import { Bookmark, BookmarkCheck, Check, CheckCircle2, Loader2, Plus } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import type { Collection, CollectionMembership } from '@trek/shared'
|
||||
import type { Collection, CollectionMembership, CollectionStatus } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Globally-mounted list picker for the "Save to Collection" entry points
|
||||
@@ -70,12 +71,44 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
|
||||
|
||||
if (!target) return null
|
||||
|
||||
const savedByCollection = new Map<number, number>()
|
||||
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l.place_id)
|
||||
const savedByCollection = new Map<number, CollectionMembership['lists'][number]>()
|
||||
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l)
|
||||
|
||||
/** Lists holding this place that the viewer may edit and that are not visited yet. */
|
||||
const unvisited = (membership?.lists ?? []).filter(l => l.can_edit && l.status !== 'visited')
|
||||
|
||||
const handleStatus = async (entry: CollectionMembership['lists'][number], next: CollectionStatus) => {
|
||||
if (busyId != null) return
|
||||
setBusyId(entry.collection_id)
|
||||
try {
|
||||
await collectionsApi.setStatus(entry.place_id, next)
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVisitedEverywhere = async () => {
|
||||
if (busyId != null || unvisited.length === 0) return
|
||||
setBusyId(-1)
|
||||
try {
|
||||
const { updated } = await collectionsApi.setStatusMany(unvisited.map(l => l.place_id), 'visited')
|
||||
toast.success(t('collections.markedVisited', { count: updated }))
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (list: Collection) => {
|
||||
if (busyId != null) return
|
||||
const savedPlaceId = savedByCollection.get(list.id)
|
||||
const savedPlaceId = savedByCollection.get(list.id)?.place_id
|
||||
setBusyId(list.id)
|
||||
try {
|
||||
if (savedPlaceId != null) {
|
||||
@@ -140,7 +173,23 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-[13px] font-semibold text-content truncate">{target.name}</p>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="flex-1 min-w-0 text-[13px] font-semibold text-content truncate">{target.name}</p>
|
||||
{/* One tap for "I have been here", however many lists hold it (#1469). */}
|
||||
{unvisited.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVisitedEverywhere}
|
||||
disabled={busyId != null}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-edge bg-surface-card text-[11px] font-semibold text-content-secondary hover:bg-surface-hover disabled:opacity-60"
|
||||
>
|
||||
{busyId === -1
|
||||
? <Loader2 size={12} className="animate-spin" />
|
||||
: <CheckCircle2 size={12} strokeWidth={2.2} />}
|
||||
{unvisited.length > 1 ? t('collections.markVisitedAll') : t('collections.markVisited')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-content-faint">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
@@ -162,7 +211,8 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
|
||||
{lists.map(list => {
|
||||
const saved = savedByCollection.has(list.id)
|
||||
const entry = savedByCollection.get(list.id)
|
||||
const saved = !!entry
|
||||
const busy = busyId === list.id
|
||||
return (
|
||||
<button
|
||||
@@ -170,13 +220,24 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
|
||||
type="button"
|
||||
onClick={() => handleToggle(list)}
|
||||
disabled={busyId != null}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-colors disabled:opacity-60 ${saved ? 'border-accent bg-accent-subtle' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
|
||||
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl border text-left transition-colors disabled:opacity-60 ${saved ? 'border-accent bg-accent-subtle' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: list.color || 'var(--accent)' }} />
|
||||
<span className="flex-1 min-w-0 text-[13px] font-medium text-content truncate">{list.name}</span>
|
||||
{list.is_owner === false && (
|
||||
<span className="text-[10px] uppercase font-semibold text-content-faint">{t('collections.shared')}</span>
|
||||
)}
|
||||
{/* Per-list status, so one place can be an idea in one list and
|
||||
visited in another. A role=button span, safe to nest here. */}
|
||||
{entry && (
|
||||
<StatusBadge
|
||||
status={entry.status}
|
||||
showLabel={false}
|
||||
size={12}
|
||||
onChange={entry.can_edit ? next => { void handleStatus(entry, next) } : undefined}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<span className={`w-5 h-5 rounded-md flex items-center justify-center shrink-0 ${saved ? 'bg-accent text-accent-text' : 'border border-edge text-transparent'}`}>
|
||||
{busy ? <Loader2 size={13} className="animate-spin text-content-faint" /> : saved ? <BookmarkCheck size={13} /> : <Check size={13} />}
|
||||
</span>
|
||||
|
||||
@@ -899,7 +899,10 @@ export const MapView = memo(function MapView({
|
||||
onClick={cycleTrackingMode}
|
||||
bottomOffset={locationButtonBottom as unknown as number}
|
||||
/>}
|
||||
<div style={{ position: 'absolute', left: leftWidth + 12, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
|
||||
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
|
||||
smaller gap its shadow ran into the sidebar edge and the two read as one
|
||||
surface. */}
|
||||
<div style={{ position: 'absolute', left: leftWidth + 20, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
|
||||
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006
|
||||
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-012
|
||||
//
|
||||
// JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)`
|
||||
// that renders a PDF preview in an srcdoc iframe overlay (Safari-safe pattern).
|
||||
// Tests verify the overlay DOM structure and HTML content.
|
||||
// JourneyBookPDF.tsx exports an async function
|
||||
// `downloadJourneyBookPDF(journey, { t, locale })` that renders a PDF preview in
|
||||
// an srcdoc iframe overlay (Safari-safe pattern). Tests verify the overlay DOM
|
||||
// structure, the HTML content and that the book follows the app language.
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
// Mock `marked` so we don't need the real markdown parser
|
||||
vi.mock('marked', () => ({
|
||||
@@ -87,6 +88,18 @@ function getIframe(): HTMLIFrameElement | null {
|
||||
return getOverlay()?.querySelector('iframe') ?? null;
|
||||
}
|
||||
|
||||
// Echoes the key (plus any params) so the assertions read as "this string comes
|
||||
// from i18n" instead of pinning English copy.
|
||||
function t(key: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return key;
|
||||
return `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})`;
|
||||
}
|
||||
|
||||
const longDate = (d: string, locale: string) =>
|
||||
new Date(d + 'T00:00:00').toLocaleDateString(locale, {
|
||||
weekday: 'long', month: 'long', day: 'numeric', year: 'numeric',
|
||||
});
|
||||
|
||||
// ── Setup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
afterEach(() => {
|
||||
@@ -142,9 +155,10 @@ describe('downloadJourneyBookPDF', () => {
|
||||
expect(getOverlay()).not.toBeNull();
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Iceland Ring Road');
|
||||
// No entry pages, but cover and closing page are still present
|
||||
expect(html).toContain('Journey Book');
|
||||
expect(html).toContain('The End');
|
||||
// No entry pages, but cover and closing page are still present. Without a
|
||||
// translator the chrome falls back to the raw keys instead of throwing.
|
||||
expect(html).toContain('journey.pdf.journeyBook');
|
||||
expect(html).toContain('journey.pdf.theEnd');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-007: sanitises HTML injected via an entry story and keeps the iframe script-free', async () => {
|
||||
@@ -162,4 +176,72 @@ describe('downloadJourneyBookPDF', () => {
|
||||
expect(html).toContain('Hello');
|
||||
expect(html).toContain('world');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-008: renders cover, day header and closing page through i18n', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney(), { t, locale: 'de-DE' });
|
||||
const html = getIframe()!.srcdoc;
|
||||
|
||||
expect(html).toContain('<html lang="de">');
|
||||
expect(html).toContain('journey.pdf.journeyBook');
|
||||
expect(html).toContain('journey.stats.days');
|
||||
expect(html).toContain('journey.stats.entries');
|
||||
expect(html).toContain('journey.stats.photos');
|
||||
expect(html).toContain('journey.pdf.madeWith');
|
||||
expect(html).toContain('journey.pdf.theEnd');
|
||||
// Day header is the parameterised key plus a date in the picked locale —
|
||||
// the hardcoded 'en' formatting would not produce this string.
|
||||
expect(html).toContain('journey.detail.day(number=1)');
|
||||
expect(html).toContain(longDate('2026-07-01', 'de-DE'));
|
||||
// Pros/cons headings too.
|
||||
expect(html).toContain('journey.verdict.lovedIt');
|
||||
expect(html).toContain('journey.verdict.couldBeBetter');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-009: translates the preview bar above the book', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney(), { t, locale: 'de-DE' });
|
||||
const overlay = getOverlay()!;
|
||||
|
||||
// cover + one entry page + closing page
|
||||
expect(overlay.textContent).toContain('3 journey.pdf.pages');
|
||||
expect(overlay.querySelector('#journey-pdf-save')!.textContent).toBe('journey.pdf.saveAsPdf');
|
||||
expect(overlay.querySelector('#journey-pdf-close')!.textContent).toBe('common.close');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-010: prints mood and weather next to the entry', async () => {
|
||||
const journey = buildJourney();
|
||||
journey.entries[0].mood = 'good';
|
||||
journey.entries[0].weather = 'rainy';
|
||||
await downloadJourneyBookPDF(journey, { t, locale: 'en-US' });
|
||||
const html = getIframe()!.srcdoc;
|
||||
|
||||
expect(html).toContain('journey.mood.good');
|
||||
expect(html).toContain('journey.weather.rainy');
|
||||
// Icons come along as inline SVG inside the chips.
|
||||
expect(html).toContain('class="entry-chips"');
|
||||
expect(html).toContain('<svg');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-011: skips chips for values the app does not know', async () => {
|
||||
// The fixture carries mood 'excited', which is not one of the four moods.
|
||||
await downloadJourneyBookPDF(buildJourney(), { t, locale: 'en-US' });
|
||||
const html = getIframe()!.srcdoc;
|
||||
// Scoped to the chip row: the page elsewhere is free to carry whatever the
|
||||
// fixture happens to leave undefined, and that is not what this asserts.
|
||||
const chips = html.match(/<div class="entry-chips">[\s\S]*?<\/div>/)?.[0] ?? '';
|
||||
|
||||
expect(chips).not.toBe('');
|
||||
expect(chips).not.toContain('undefined');
|
||||
expect(chips).not.toContain('journey.mood.');
|
||||
// The known weather value still gets its chip.
|
||||
expect(chips).toContain('journey.weather.sunny');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-012: leaves out the chip row when an entry has neither', async () => {
|
||||
const journey = buildJourney();
|
||||
journey.entries[0].mood = null;
|
||||
journey.entries[0].weather = null;
|
||||
await downloadJourneyBookPDF(journey, { t, locale: 'en-US' });
|
||||
|
||||
expect(getIframe()!.srcdoc).not.toContain('class="entry-chips"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
// Journey Photo Book PDF — Polarsteps-inspired, magazine-density
|
||||
import { createElement } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { marked } from 'marked'
|
||||
import { sanitizeRichTextHtml } from '@trek/shared'
|
||||
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
function esc(str: string | null | undefined): string {
|
||||
if (!str) return ''
|
||||
@@ -25,13 +31,9 @@ function pSrc(p: JourneyPhoto): string {
|
||||
return abs(`/api/photos/${p.photo_id}/original`)
|
||||
}
|
||||
|
||||
function fmtDate(d: string): string {
|
||||
function fmtDate(d: string, locale: string): string {
|
||||
const date = new Date(d + 'T00:00:00')
|
||||
return date.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function fmtShort(d: string): string {
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||
return date.toLocaleDateString(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
|
||||
@@ -44,7 +46,7 @@ function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
|
||||
return groups
|
||||
}
|
||||
|
||||
function renderProscons(entry: JourneyEntry): string {
|
||||
function renderProscons(entry: JourneyEntry, tr: Translate): string {
|
||||
const pc = entry.pros_cons
|
||||
if (!pc) return ''
|
||||
const pros = pc.pros?.filter(p => p.trim()) || []
|
||||
@@ -52,11 +54,31 @@ function renderProscons(entry: JourneyEntry): string {
|
||||
if (pros.length === 0 && cons.length === 0) return ''
|
||||
|
||||
return `<div class="verdict-wrap"><div class="verdict-row">
|
||||
${pros.length > 0 ? `<div class="verdict-card pros"><div class="verdict-label">Loved it</div><ul>${pros.map(p => `<li>${esc(p)}</li>`).join('')}</ul></div>` : ''}
|
||||
${cons.length > 0 ? `<div class="verdict-card cons"><div class="verdict-label">Could be better</div><ul>${cons.map(c => `<li>${esc(c)}</li>`).join('')}</ul></div>` : ''}
|
||||
${pros.length > 0 ? `<div class="verdict-card pros"><div class="verdict-label">${esc(tr('journey.verdict.lovedIt'))}</div><ul>${pros.map(p => `<li>${esc(p)}</li>`).join('')}</ul></div>` : ''}
|
||||
${cons.length > 0 ? `<div class="verdict-card cons"><div class="verdict-label">${esc(tr('journey.verdict.couldBeBetter'))}</div><ul>${cons.map(c => `<li>${esc(c)}</li>`).join('')}</ul></div>` : ''}
|
||||
</div></div>`
|
||||
}
|
||||
|
||||
function chip(icon: LucideIcon, label: string, bg: string, color: string): string {
|
||||
const svg = renderIconMarkup(createElement(icon, { size: 12, strokeWidth: 2, color }))
|
||||
return `<span class="chip" style="background:${bg};color:${color}">${svg}${esc(label)}</span>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Mood and weather were on screen but never in the book (#1848). Unknown values
|
||||
* (older entries, a provider-set category we don't render) drop out silently,
|
||||
* the same way MoodChip/WeatherChip return null for them.
|
||||
*/
|
||||
function renderMoodWeather(entry: JourneyEntry, tr: Translate): string {
|
||||
const chips: string[] = []
|
||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : undefined
|
||||
if (mood) chips.push(chip(mood.icon, tr(mood.label), mood.bg, mood.text))
|
||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : undefined
|
||||
if (weather) chips.push(chip(weather.icon, tr(weather.label), '#f4f4f5', '#3f3f46'))
|
||||
if (chips.length === 0) return ''
|
||||
return `<div class="entry-chips">${chips.join('')}</div>`
|
||||
}
|
||||
|
||||
function renderPhotoBlock(photos: JourneyPhoto[]): string {
|
||||
if (photos.length === 0) return ''
|
||||
if (photos.length === 1) {
|
||||
@@ -75,7 +97,16 @@ function renderPhotoBlock(photos: JourneyPhoto[]): string {
|
||||
</div>`
|
||||
}
|
||||
|
||||
export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
/**
|
||||
* `opts` stays optional so the signature is additive, but every caller passes
|
||||
* the page's `t`/`locale` — without them the book falls back to raw keys (#1848).
|
||||
*/
|
||||
export async function downloadJourneyBookPDF(
|
||||
journey: JourneyDetail,
|
||||
opts: { t?: Translate; locale?: string } = {},
|
||||
) {
|
||||
const tr: Translate = opts.t || (key => key)
|
||||
const loc = opts.locale || 'en'
|
||||
const entries = (journey.entries || []).filter(e => e.type !== 'skeleton')
|
||||
const allPhotos = entries.flatMap(e => e.photos || [])
|
||||
const coverUrl = journey.cover_image ? abs(`/uploads/${journey.cover_image}`) : (allPhotos[0] ? pSrc(allPhotos[0]) : '')
|
||||
@@ -96,14 +127,14 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
|
||||
// Day header (inline, only on first entry of day)
|
||||
const dayHeaderHtml = isFirstOfDay
|
||||
? `<div class="day-header">Day ${di + 1} · ${fmtDate(date)}</div>`
|
||||
? `<div class="day-header">${esc(tr('journey.detail.day', { number: di + 1 }))} · ${esc(fmtDate(date, loc))}</div>`
|
||||
: ''
|
||||
|
||||
// Photo block
|
||||
const photoHtml = renderPhotoBlock(photos)
|
||||
|
||||
// Pros/cons
|
||||
const prosconsHtml = renderProscons(entry)
|
||||
const prosconsHtml = renderProscons(entry, tr)
|
||||
|
||||
// Story (markdown)
|
||||
const storyHtml = entry.story ? `<div class="entry-story">${md(entry.story)}</div>` : ''
|
||||
@@ -114,6 +145,7 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
${photoHtml}
|
||||
<div class="entry-content">
|
||||
${meta ? `<div class="entry-meta">${esc(meta)}</div>` : ''}
|
||||
${renderMoodWeather(entry, tr)}
|
||||
${entry.title ? `<h2 class="entry-title">${esc(entry.title)}</h2>` : ''}
|
||||
${storyHtml}
|
||||
${prosconsHtml}
|
||||
@@ -126,11 +158,17 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
const totalPages = pageNum + 1 // +1 for closing page
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="${esc(loc.split('-')[0])}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<!-- The page is A4 landscape and laid out in pt, so it is about 1123px wide. Without
|
||||
this, a phone lays the preview out at its default 980px and the document spills
|
||||
out of the frame, which reads as being zoomed far in. Declaring the real width
|
||||
makes the browser scale the whole page to fit instead. Printing is unaffected,
|
||||
it goes by @page. -->
|
||||
<meta name="viewport" content="width=1123">
|
||||
<base href="${window.location.origin}/">
|
||||
<title>${esc(journey.title)} — Journey Book</title>
|
||||
<title>${esc(journey.title)} — ${esc(tr('journey.pdf.journeyBook'))}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -207,6 +245,9 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
/* Entry content */
|
||||
.entry-content { flex: 1; }
|
||||
.entry-meta { font-size: 10pt; letter-spacing: 0.04em; text-transform: uppercase; color: #71717a; font-weight: 500; margin-bottom: 6pt; }
|
||||
.entry-chips { display: flex; flex-wrap: wrap; gap: 5pt; margin-bottom: 8pt; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 3pt; padding: 2.5pt 7pt; border-radius: 999pt; font-size: 8.5pt; font-weight: 600; }
|
||||
.chip svg { width: 9pt; height: 9pt; }
|
||||
h2.entry-title { font-size: 28pt; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; margin: 0 0 10pt; color: #0a0a0f; }
|
||||
.entry-story { font-size: 11pt; line-height: 1.65; color: #3f3f46; }
|
||||
.entry-story p { margin: 0 0 8pt; }
|
||||
@@ -262,16 +303,16 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
<div class="cover-dim"></div>
|
||||
<div class="cover-mesh"></div>
|
||||
<div class="cover-content">
|
||||
<div class="cover-label">Journey Book</div>
|
||||
<div class="cover-label">${esc(tr('journey.pdf.journeyBook'))}</div>
|
||||
<h1>${esc(journey.title)}</h1>
|
||||
${journey.subtitle ? `<div class="sub">${esc(journey.subtitle)}</div>` : ''}
|
||||
<div class="cover-stats">
|
||||
<div><div class="cover-stat-val">${dates.length}</div><div class="cover-stat-label">Days</div></div>
|
||||
<div><div class="cover-stat-val">${entries.length}</div><div class="cover-stat-label">Entries</div></div>
|
||||
<div><div class="cover-stat-val">${allPhotos.length}</div><div class="cover-stat-label">Photos</div></div>
|
||||
<div><div class="cover-stat-val">${dates.length}</div><div class="cover-stat-label">${esc(tr('journey.stats.days'))}</div></div>
|
||||
<div><div class="cover-stat-val">${entries.length}</div><div class="cover-stat-label">${esc(tr('journey.stats.entries'))}</div></div>
|
||||
<div><div class="cover-stat-val">${allPhotos.length}</div><div class="cover-stat-label">${esc(tr('journey.stats.photos'))}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cover-footer">Made with TREK</div>
|
||||
<div class="cover-footer">${esc(tr('journey.pdf.madeWith'))}</div>
|
||||
</div>
|
||||
|
||||
<!-- Entry Pages -->
|
||||
@@ -280,8 +321,8 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
<!-- Closing Page -->
|
||||
<div class="closing-page">
|
||||
<div>
|
||||
<div class="closing-title">The End</div>
|
||||
<div class="closing-sub">Made with TREK · ${new Date().getFullYear()}</div>
|
||||
<div class="closing-title">${esc(tr('journey.pdf.theEnd'))}</div>
|
||||
<div class="closing-sub">${esc(tr('journey.pdf.madeWith'))} · ${new Date().getFullYear()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -296,16 +337,39 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
|
||||
|
||||
// The chrome needs a stylesheet rather than inline styles: on a phone the title
|
||||
// and the two labels do not fit on one row, and translated labels are longer
|
||||
// than the English ones. Below 640px the card goes full-bleed, the decorative
|
||||
// title steps aside and the two actions share the width (#1848).
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
#journey-pdf-overlay .jp-card { width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35); }
|
||||
#journey-pdf-overlay .jp-head { display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a; }
|
||||
#journey-pdf-overlay .jp-title { min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em; }
|
||||
#journey-pdf-overlay .jp-actions { display:flex;align-items:center;gap:8px;flex:none; }
|
||||
#journey-pdf-overlay .jp-btn { min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap; }
|
||||
#journey-pdf-overlay .jp-save { border:none;background:#fff;color:#0f172a; }
|
||||
#journey-pdf-overlay .jp-close { border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7); }
|
||||
@media (max-width: 640px) {
|
||||
#journey-pdf-overlay { padding:0; }
|
||||
#journey-pdf-overlay .jp-card { height:100dvh;max-width:none;border-radius:0; }
|
||||
#journey-pdf-overlay .jp-head { padding:8px 10px; }
|
||||
#journey-pdf-overlay .jp-title { display:none; }
|
||||
#journey-pdf-overlay .jp-actions { flex:1; }
|
||||
#journey-pdf-overlay .jp-btn { flex:1;padding:10px 12px;font-size:13px; }
|
||||
}
|
||||
`
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.style.cssText = 'width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35);'
|
||||
card.className = 'jp-card'
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a;'
|
||||
header.className = 'jp-head'
|
||||
header.innerHTML = `
|
||||
<span style="font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em">${esc(journey.title)} · ${totalPages} pages</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<button id="journey-pdf-save" style="min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:none;background:#fff;color:#0f172a;">Save as PDF</button>
|
||||
<button id="journey-pdf-close" style="min-height:44px;padding:10px 16px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7);">Close</button>
|
||||
<span class="jp-title">${esc(journey.title)} · ${totalPages} ${esc(tr('journey.pdf.pages'))}</span>
|
||||
<div class="jp-actions">
|
||||
<button id="journey-pdf-save" class="jp-btn jp-save">${esc(tr('journey.pdf.saveAsPdf'))}</button>
|
||||
<button id="journey-pdf-close" class="jp-btn jp-close">${esc(tr('common.close'))}</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -318,6 +382,7 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
|
||||
card.appendChild(header)
|
||||
card.appendChild(iframe)
|
||||
overlay.appendChild(style)
|
||||
overlay.appendChild(card)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
|
||||
@@ -7,13 +7,21 @@ import { catMeta } from '../Budget/costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
|
||||
/**
|
||||
* The Costs block inside a booking modal. Replaces the old inline price + budget
|
||||
* category fields: when no expense is linked yet it offers a "create expense"
|
||||
* button (the modal saves the booking first, then opens the full Costs editor);
|
||||
* once linked it shows the expense with edit / remove actions.
|
||||
* The Costs block inside a booking modal — and, since #1298, inside the place
|
||||
* form, which links its expense the same way. Replaces the old inline price +
|
||||
* budget category fields: when no expense is linked yet it offers a "create
|
||||
* expense" button (the modal saves its own record first, then opens the full
|
||||
* Costs editor); once linked it shows the expense with edit / remove actions.
|
||||
*
|
||||
* Exactly one of reservationId / placeId is set — they are the two sides of the
|
||||
* same link, and the block behaves identically on both.
|
||||
*/
|
||||
export function BookingCostsSection({ reservationId, pendingExpense, onCreate, onEdit, onRemove }: {
|
||||
export function BookingCostsSection({ reservationId, placeId = null, hintKey = 'reservations.createExpenseHint', pendingExpense, onCreate, onEdit, onRemove }: {
|
||||
reservationId: number | null
|
||||
/** Set instead of reservationId when the block sits in the place form (#1298). */
|
||||
placeId?: number | null
|
||||
/** What gets saved before the editor opens — "the booking" or "the place". */
|
||||
hintKey?: string
|
||||
/** A cost parsed from an import that will be linked on save — previewed before the booking exists. */
|
||||
pendingExpense?: { total_price: number; currency?: string | null; category: string } | null
|
||||
onCreate: () => void
|
||||
@@ -25,7 +33,11 @@ export function BookingCostsSection({ reservationId, pendingExpense, onCreate, o
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
const linked = reservationId ? budgetItems.find(i => i.reservation_id === reservationId) : null
|
||||
const linked = reservationId
|
||||
? budgetItems.find(i => i.reservation_id === reservationId)
|
||||
: placeId
|
||||
? budgetItems.find(i => i.place_id === placeId)
|
||||
: null
|
||||
|
||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||
|
||||
@@ -40,7 +52,7 @@ export function BookingCostsSection({ reservationId, pendingExpense, onCreate, o
|
||||
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t(meta.labelKey)}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('reservations.createExpenseHint')}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(hintKey)}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, pendingExpense.currency || base, locale)}</span>
|
||||
</div>
|
||||
@@ -76,7 +88,7 @@ export function BookingCostsSection({ reservationId, pendingExpense, onCreate, o
|
||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={15} /> {t('reservations.createExpense')}
|
||||
</button>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 6 }}>{t('reservations.createExpenseHint')}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 6 }}>{t(hintKey)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { BudgetItem } from '../../types'
|
||||
|
||||
/**
|
||||
* A request from a booking modal to open the Costs expense editor — either to
|
||||
* edit the already-linked expense, or to create a new one prefilled from the
|
||||
* booking (the modal saves the booking first so `reservationId` is known).
|
||||
* A request from a booking modal — or from the place form (#1298) — to open the
|
||||
* Costs expense editor: either to edit the already-linked expense, or to create
|
||||
* a new one prefilled from the record. The modal saves itself first, so the
|
||||
* `reservationId` / `placeId` it names already exists.
|
||||
*/
|
||||
export interface BookingExpenseRequest {
|
||||
editItem?: BudgetItem
|
||||
prefill?: { reservationId?: number; name?: string; category?: string; amount?: number }
|
||||
prefill?: { reservationId?: number; placeId?: number; name?: string; category?: string; amount?: number }
|
||||
}
|
||||
|
||||
@@ -184,11 +184,17 @@ beforeEach(() => {
|
||||
// otherwise leak into the ones after them.
|
||||
vi.mocked(calculateRouteWithLegs).mockImplementation(waypoints => Promise.resolve({
|
||||
coordinates: [], distance: 0, duration: 0,
|
||||
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({
|
||||
mid: [0, 0] as [number, number], from: [0, 0] as [number, number], to: [0, 0] as [number, number],
|
||||
distance: 2000, duration: 600, distanceText: '2 km', durationText: '10 min',
|
||||
drivingText: '10 min', walkingText: '25 min',
|
||||
})),
|
||||
// Each leg carries its endpoint coordinates (mirrors the real RouteCalculator), so
|
||||
// connector-driven features that read seg.from/seg.to see faithful [lat, lng] pairs.
|
||||
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, (_, i) => {
|
||||
const a = waypoints[i], b = waypoints[i + 1]
|
||||
return {
|
||||
mid: [(a.lat + b.lat) / 2, (a.lng + b.lng) / 2] as [number, number],
|
||||
from: [a.lat, a.lng] as [number, number], to: [b.lat, b.lng] as [number, number],
|
||||
distance: 2000, duration: 600, distanceText: '2 km', durationText: '10 min',
|
||||
drivingText: '10 min', walkingText: '25 min',
|
||||
}
|
||||
}),
|
||||
}))
|
||||
vi.mocked(generateGoogleMapsUrl).mockReturnValue('https://maps.google.com/...')
|
||||
vi.mocked(generateCoMapsUrl).mockReturnValue('https://comaps.at/...')
|
||||
@@ -606,16 +612,17 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── PDF export ──────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-025: PDF export button is present', () => {
|
||||
it('FE-PLANNER-DAYPLAN-025: the export button is present', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
expect(screen.getByText('PDF')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-026: clicking PDF button calls downloadTripPDF', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-026: the PDF row in the export dialog calls downloadTripPDF', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { downloadTripPDF } = await import('../PDF/TripPDF')
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
await user.click(screen.getByText('PDF'))
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
await user.click(await screen.findByText('PDF'))
|
||||
await waitFor(() => {
|
||||
expect(downloadTripPDF).toHaveBeenCalled()
|
||||
})
|
||||
@@ -896,11 +903,14 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// Day-title renaming moved to DayDetailPanel (#1065) — covered there.
|
||||
|
||||
// ── ICS export button ────────────────────────────────────────────────────
|
||||
// ── ICS export ───────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-043: ICS export button is present', () => {
|
||||
it('FE-PLANNER-DAYPLAN-043: the calendar export sits in the export dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
expect(screen.getByText('ICS')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
expect(await screen.findByText('Calendar')).toBeInTheDocument()
|
||||
expect(screen.getByText('Download .ics')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── getMergedItems: transport merged with assignments ──────────────────
|
||||
@@ -1116,13 +1126,12 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── PDF hover tooltip ─────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-054: hovering PDF button shows tooltip', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-054: hovering the export button shows its tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
const pdfBtn = screen.getByText('PDF').closest('button')!
|
||||
await user.hover(pdfBtn)
|
||||
await user.hover(screen.getByRole('button', { name: 'Export' }))
|
||||
await waitFor(() => {
|
||||
// Tooltip text appears (from t('dayplan.pdfTooltip'))
|
||||
// Tooltip text appears (from t('dayplan.exportIntro'))
|
||||
const tooltips = document.querySelectorAll('[style*="pointer-events: none"]')
|
||||
expect(tooltips.length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -1190,10 +1199,9 @@ describe('DayPlanSidebar', () => {
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
// The ICS button now opens a hover menu (Download / Subscribe) instead of
|
||||
// downloading on direct click.
|
||||
await user.hover(screen.getByText('ICS').closest('button')!)
|
||||
await user.click(await screen.findByText('Download ICS'))
|
||||
// The three export buttons collapsed into one that opens the export dialog.
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
await user.click(await screen.findByText('Download .ics'))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
|
||||
fetchSpy.mockRestore()
|
||||
createObjURL.mockRestore()
|
||||
@@ -1827,8 +1835,8 @@ describe('DayPlanSidebar', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place], assignments: { '10': [assignment] },
|
||||
})} />)
|
||||
const pdfBtn = screen.getByRole('button', { name: /pdf/i })
|
||||
await user.click(pdfBtn)
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
await user.click(await screen.findByText('PDF'))
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dayNotes: expect.arrayContaining([expect.objectContaining({ text: 'PDF Note' })]) })
|
||||
))
|
||||
@@ -1912,16 +1920,17 @@ describe('DayPlanSidebar', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── ICS hover tooltip ─────────────────────────────────────────────────────
|
||||
// ── Export dialog ─────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-090: hovering ICS button shows the download/subscribe menu', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-090: the export button opens the dialog with every format', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||
const icsBtn = screen.getByText('ICS').closest('button')!
|
||||
await user.hover(icsBtn)
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
expect(screen.getByText('PDF')).toBeInTheDocument()
|
||||
expect(screen.getByText('Download .ics')).toBeInTheDocument()
|
||||
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
|
||||
expect(screen.getByText('Whole trip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3462,6 +3471,100 @@ describe('DayPlanSidebar', () => {
|
||||
expect(vi.mocked(assignmentsApi.updateTransport)).toHaveBeenLastCalledWith(1, 11, null)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-171b: a stop-to-stop connector offers public transport pre-filled from the leg', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onPlanTransitLeg = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assignments = {
|
||||
'10': [
|
||||
buildAssignment({ id: 11, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', place_time: '10:30', lat: 48.86, lng: 2.34 }) }),
|
||||
buildAssignment({ id: 12, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'Orsay', lat: 48.87, lng: 2.33 }) }),
|
||||
],
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true, onPlanTransitLeg })} />)
|
||||
await user.click(await screen.findByTitle('Change travel mode'))
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
|
||||
// Origin/destination + this stop's departure time, resolved back from the leg coords.
|
||||
expect(onPlanTransitLeg).toHaveBeenCalledWith({
|
||||
dayId: 10,
|
||||
from: { name: 'Louvre', lat: 48.86, lng: 2.34 },
|
||||
to: { name: 'Orsay', lat: 48.87, lng: 2.33 },
|
||||
time: '10:30',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-171c: the public-transport entry is absent without the onPlanTransitLeg handler', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assignments = {
|
||||
'10': [
|
||||
buildAssignment({ id: 11, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) }),
|
||||
buildAssignment({ id: 12, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }),
|
||||
],
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true })} />)
|
||||
await user.click(await screen.findByTitle('Change travel mode'))
|
||||
expect(contextMenu().queryByRole('button', { name: 'Public transit' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-171d: a hotel bookend connector offers public transport from the hotel', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onPlanTransitLeg = vi.fn()
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
|
||||
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
|
||||
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
|
||||
]
|
||||
const accommodations: Accommodation[] = [{
|
||||
id: 1, trip_id: 1, start_day_id: 10, end_day_id: 12,
|
||||
place_lat: 48.85, place_lng: 2.35, place_name: 'Hotel Lutetia',
|
||||
}]
|
||||
const assignments = {
|
||||
'11': [
|
||||
buildAssignment({ id: 11, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', lat: 48.86, lng: 2.34 }) }),
|
||||
buildAssignment({ id: 12, day_id: 11, order_index: 1, place: buildPlace({ id: 2, name: 'Orsay', lat: 48.87, lng: 2.33 }) }),
|
||||
],
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, accommodations, selectedDayId: 11, routeShown: true, onPlanTransitLeg })} />)
|
||||
// The morning bookend (hotel -> first stop) is the first connector in the list.
|
||||
const connectors = await screen.findAllByTitle('Change travel mode')
|
||||
await user.click(connectors[0])
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
|
||||
expect(onPlanTransitLeg).toHaveBeenCalledWith(expect.objectContaining({
|
||||
dayId: 11,
|
||||
from: { name: 'Hotel Lutetia', lat: 48.85, lng: 2.35 },
|
||||
to: { name: 'Louvre', lat: 48.86, lng: 2.34 },
|
||||
// The hotel has no place_time, so the departure time falls back to null (the
|
||||
// panel then uses its own 09:00 default).
|
||||
time: null,
|
||||
}))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-171e: a revisited stop seeds THIS day\'s departure time, not another day\'s', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onPlanTransitLeg = vi.fn()
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
|
||||
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
|
||||
]
|
||||
// The SAME located POI (identical coords) is visited on both days at different
|
||||
// times. Day 10 has it alone (no leg); day 11 pairs it with Rodin (one leg).
|
||||
const assignments = {
|
||||
'10': [
|
||||
buildAssignment({ id: 11, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', place_time: '09:00', lat: 48.86, lng: 2.34 }) }),
|
||||
],
|
||||
'11': [
|
||||
buildAssignment({ id: 21, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', place_time: '16:30', lat: 48.86, lng: 2.34 }) }),
|
||||
buildAssignment({ id: 22, day_id: 11, order_index: 1, place: buildPlace({ id: 3, name: 'Rodin', lat: 48.855, lng: 2.315 }) }),
|
||||
],
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, selectedDayId: 11, routeShown: true, onPlanTransitLeg })} />)
|
||||
await user.click(await screen.findByTitle('Change travel mode'))
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
|
||||
// Day 11's own 16:30, not day 10's 09:00 (a trip-wide coord index would leak it).
|
||||
expect(onPlanTransitLeg).toHaveBeenCalledWith(expect.objectContaining({ dayId: 11, time: '16:30' }))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-172: a failing per-segment save is reported and the days are refetched', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { assignmentsApi } = await import('../../api/client')
|
||||
|
||||
@@ -5,6 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react'
|
||||
import { type PickedPlace } from './TransitSearchPanel'
|
||||
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
|
||||
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, type NamedWaypoint } from '../Map/RouteCalculator'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
@@ -99,6 +100,12 @@ interface DayPlanSidebarProps {
|
||||
onAddTransport?: (dayId: number) => void
|
||||
/** Opens the public-transit route search for a day (#1065). */
|
||||
onPlanTransit?: (dayId: number) => void
|
||||
/**
|
||||
* Opens the public-transit search pre-filled for a single leg (#1281 follow-up):
|
||||
* the leg's origin and destination endpoints plus the origin's departure time,
|
||||
* so "public transport" becomes an option on any connector, not just the day header.
|
||||
*/
|
||||
onPlanTransitLeg?: (leg: { dayId: number; from: PickedPlace; to: PickedPlace; time: string | null }) => void
|
||||
/** Opens the journey view for a saved transit entry (#1065). */
|
||||
onOpenTransit?: (reservation: Reservation) => void
|
||||
onEditTransport?: (reservation: Reservation) => void
|
||||
@@ -148,6 +155,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onRouteRefresh,
|
||||
onAddTransport,
|
||||
onPlanTransit,
|
||||
onPlanTransitLeg,
|
||||
onOpenTransit,
|
||||
onEditTransport,
|
||||
onEditReservation,
|
||||
@@ -203,7 +211,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const [lockedIds, setLockedIds] = useState(new Set())
|
||||
const [lockHoverId, setLockHoverId] = useState(null)
|
||||
const [undoHover, setUndoHover] = useState(false)
|
||||
const [icsHover, setIcsHover] = useState(false)
|
||||
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
|
||||
// Transit rows fold their itinerary out inline (#1065).
|
||||
const [expandedTransitIds, setExpandedTransitIds] = useState<Set<number>>(new Set())
|
||||
@@ -977,6 +984,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onRouteRefresh,
|
||||
onAddTransport,
|
||||
onPlanTransit,
|
||||
onPlanTransitLeg,
|
||||
onOpenTransit,
|
||||
onEditTransport,
|
||||
expandedTransitIds,
|
||||
@@ -1025,8 +1033,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
setLockHoverId,
|
||||
undoHover,
|
||||
setUndoHover,
|
||||
icsHover,
|
||||
setIcsHover,
|
||||
hoveredAssignmentId,
|
||||
setHoveredAssignmentId,
|
||||
dropTargetKey,
|
||||
@@ -1151,6 +1157,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onRouteRefresh,
|
||||
onAddTransport,
|
||||
onPlanTransit,
|
||||
onPlanTransitLeg,
|
||||
onOpenTransit,
|
||||
onEditTransport,
|
||||
expandedTransitIds,
|
||||
@@ -1199,8 +1206,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
setLockHoverId,
|
||||
undoHover,
|
||||
setUndoHover,
|
||||
icsHover,
|
||||
setIcsHover,
|
||||
hoveredAssignmentId,
|
||||
setHoveredAssignmentId,
|
||||
dropTargetKey,
|
||||
@@ -1284,11 +1289,71 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
})
|
||||
}
|
||||
|
||||
// Open the mode menu at the clicked connector: every route profile plus a
|
||||
// "use day default" entry that clears the per-leg override.
|
||||
const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number) => {
|
||||
// A connector's endpoints carry only coordinates (RouteSegment.from/to, both
|
||||
// [lat, lng]); resolve them back to the NAMES the transit search shows by indexing
|
||||
// every located place, hotel and booking endpoint on the trip. Name is a property
|
||||
// of a coordinate, so a trip-wide index is fine; a connector only renders when both
|
||||
// ends are located, so a coordinate that misses the index is rare (nameless pick).
|
||||
const transitNameIndex = useMemo(() => {
|
||||
const m = new Map<string, string>()
|
||||
const put = (lat?: number | null, lng?: number | null, name?: string | null) => {
|
||||
if (lat == null || lng == null || !name) return
|
||||
const k = `${lat},${lng}`
|
||||
if (!m.has(k)) m.set(k, name)
|
||||
}
|
||||
for (const list of Object.values(assignments)) {
|
||||
for (const a of list) put(a.place?.lat, a.place?.lng, a.place?.name)
|
||||
}
|
||||
for (const acc of accommodations) put(acc.place_lat, acc.place_lng, acc.place_name)
|
||||
for (const r of reservations) {
|
||||
for (const ep of (r.endpoints || [])) put(ep.lat, ep.lng, ep.name)
|
||||
}
|
||||
return m
|
||||
}, [assignments, accommodations, reservations])
|
||||
|
||||
// The leg's departure time must be resolved WITHIN its day: the same located POI can
|
||||
// be revisited on another day at a different time (a supported pattern), so a
|
||||
// trip-wide coordinate index would return the wrong day's time. Scope to this day's
|
||||
// assignments (and the reservations that touch it) so a revisit keeps its own time.
|
||||
const originDepartureTime = (originKey: string, dayId: number): string | null => {
|
||||
for (const a of assignments[String(dayId)] ?? []) {
|
||||
if (a.place?.lat != null && a.place?.lng != null && `${a.place.lat},${a.place.lng}` === originKey) return a.place.place_time ?? null
|
||||
}
|
||||
for (const r of reservations) {
|
||||
if (r.day_id !== dayId && r.end_day_id !== dayId) continue
|
||||
for (const ep of (r.endpoints || [])) {
|
||||
if (ep.lat != null && ep.lng != null && `${ep.lat},${ep.lng}` === originKey) return ep.local_time ?? null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Build the "public transport" prefill for a leg from its RouteSegment: MOTIS is
|
||||
// driven off the coordinates, the names come from the index and the departure time
|
||||
// from the origin's own day. Accepts 'H:mm' or 'HH:mm[:ss]', normalised to 'HH:mm'.
|
||||
const buildTransitLeg = (seg: RouteSegment | undefined, dayId: number): { from: PickedPlace; to: PickedPlace; time: string | null } | null => {
|
||||
if (!seg?.from || !seg?.to) return null
|
||||
const pick = (c: [number, number]): PickedPlace => ({ name: transitNameIndex.get(`${c[0]},${c[1]}`) || '', lat: c[0], lng: c[1] })
|
||||
const rawTime = originDepartureTime(`${seg.from[0]},${seg.from[1]}`, dayId)
|
||||
const m = rawTime ? /^(\d{1,2}):(\d{2})/.exec(rawTime) : null
|
||||
return { from: pick(seg.from), to: pick(seg.to), time: m ? `${m[1].padStart(2, '0')}:${m[2]}` : null }
|
||||
}
|
||||
|
||||
// The extra connector-menu entry (#1281 follow-up): search public transit for this
|
||||
// leg instead of drawing a road route. Only when a handler is wired (day has dates).
|
||||
const transitLegMenuItem = (dayId: number, seg?: RouteSegment) => {
|
||||
if (!onPlanTransitLeg) return []
|
||||
const leg = buildTransitLeg(seg, dayId)
|
||||
if (!leg) return []
|
||||
return [{ label: t('transit.title'), icon: TramFront, onClick: () => onPlanTransitLeg({ dayId, from: leg.from, to: leg.to, time: leg.time }) }]
|
||||
}
|
||||
|
||||
// Open the mode menu at the clicked connector: every route profile, the optional
|
||||
// "public transport" entry, plus a "use day default" entry that clears the override.
|
||||
const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => {
|
||||
ctxMenu.open(e, [
|
||||
...routeProfileOptions.map(o => ({ label: o.label, icon: modeIcon(o.key), onClick: () => setLegMode(assignmentId, dayId, o.key) })),
|
||||
...transitLegMenuItem(dayId, seg),
|
||||
{ divider: true },
|
||||
{ label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => setLegMode(assignmentId, dayId, null) },
|
||||
])
|
||||
@@ -1310,9 +1375,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
})
|
||||
}
|
||||
|
||||
const openIncomingLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number) => {
|
||||
const openIncomingLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => {
|
||||
ctxMenu.open(e, [
|
||||
...routeProfileOptions.map(o => ({ label: o.label, icon: modeIcon(o.key), onClick: () => setIncomingLegMode(assignmentId, dayId, o.key) })),
|
||||
...transitLegMenuItem(dayId, seg),
|
||||
{ divider: true },
|
||||
{ label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => setIncomingLegMode(assignmentId, dayId, null) },
|
||||
])
|
||||
@@ -1334,8 +1400,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
t={t}
|
||||
locale={locale}
|
||||
toast={toast}
|
||||
icsHover={icsHover}
|
||||
setIcsHover={setIcsHover}
|
||||
expandedDays={expandedDays}
|
||||
setExpandedDays={setExpandedDays}
|
||||
onUndo={onUndo}
|
||||
@@ -1664,7 +1728,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const targetId = hotelLegs[day.id]?.top?.targetId
|
||||
const connector = <HotelRouteConnector seg={hotelLegs[day.id]!.top!.seg} name={hotelLegs[day.id]!.top!.name} profile={routeProfile} placement="top" />
|
||||
return canEditDays && targetId != null ? (
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, targetId, day.id)} style={{ cursor: 'pointer' }}>
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.top!.seg)} style={{ cursor: 'pointer' }}>
|
||||
{connector}
|
||||
</div>
|
||||
) : connector
|
||||
@@ -1906,33 +1970,38 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const confirmed = res.status === 'confirmed'
|
||||
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
|
||||
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
|
||||
// The status, the time and the flight/train number used to sit in one
|
||||
// pill strung together on a middle dot, which read as one run-on
|
||||
// sentence. They are separate chips now: same tint so they still
|
||||
// belong together, own outline so the eye can take them one at a time.
|
||||
const RI = RES_ICONS[res.type] || Ticket
|
||||
const tint = confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'
|
||||
const chip: React.CSSProperties = {
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '1px 6px', borderRadius: 5,
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
const { time: st } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: et } = splitReservationDateTime(res.reservation_end_time)
|
||||
const timeLabel = st || et
|
||||
? `${st ? formatTime(st, locale, timeFormat) : ''}${et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}`
|
||||
: ''
|
||||
let meta: any = {}
|
||||
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
|
||||
const carrierLabel = meta
|
||||
? (meta.airline && meta.flight_number ? `${meta.airline} ${meta.flight_number}` : meta.flight_number || meta.train_number || '')
|
||||
: ''
|
||||
return (
|
||||
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<div className={confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
}}>
|
||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||
// No wrapping: the time belongs to the status it qualifies, so the
|
||||
// chips stay on one line even when the row gets narrow.
|
||||
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, flexWrap: 'nowrap' }}>
|
||||
<div className={tint} style={chip}>
|
||||
<RI size={8} />
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
{(() => {
|
||||
const { time: st } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: et } = splitReservationDateTime(res.reservation_end_time)
|
||||
if (!st && !et) return null
|
||||
return (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{st ? formatTime(st, locale, timeFormat) : ''}
|
||||
{et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
{(() => {
|
||||
let meta: any = {}
|
||||
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
|
||||
if (!meta) return null
|
||||
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
|
||||
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
|
||||
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
{timeLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{timeLabel}</span>}
|
||||
{carrierLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{carrierLabel}</span>}
|
||||
{hasEndpoints && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -2034,7 +2103,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
{daySchedule.byAssignment[day.id]?.[assignment.id]?.map(si => <PluginDayScheduleRow key={`${si.pluginId}:${si.id}`} item={si} />)}
|
||||
{routeLegs[day.id]?.[assignment.id] && (canEditDays ? (
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, assignment.id, day.id)} style={{ cursor: 'pointer' }}>
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, assignment.id, day.id, routeLegs[day.id]![assignment.id])} style={{ cursor: 'pointer' }}>
|
||||
<RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />
|
||||
</div>
|
||||
) : (
|
||||
@@ -2273,7 +2342,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const nextPlaceId = merged.slice(idx + 1).find(i => i.type === 'place' && i.data.place?.lat && i.data.place?.lng)?.data.id
|
||||
const connector = <RouteConnector seg={routeLegs[day.id]![res.id]} profile={routeProfile} />
|
||||
return canEditDays && nextPlaceId != null ? (
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, Number(nextPlaceId), day.id)} style={{ cursor: 'pointer' }}>
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id])} style={{ cursor: 'pointer' }}>
|
||||
{connector}
|
||||
</div>
|
||||
) : connector
|
||||
@@ -2407,7 +2476,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const targetId = hotelLegs[day.id]?.bottom?.targetId
|
||||
const connector = <HotelRouteConnector seg={hotelLegs[day.id]!.bottom!.seg} name={hotelLegs[day.id]!.bottom!.name} profile={routeProfile} placement="bottom" />
|
||||
return canEditDays && targetId != null ? (
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, targetId, day.id)} style={{ cursor: 'pointer' }}>
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.bottom!.seg)} style={{ cursor: 'pointer' }}>
|
||||
{connector}
|
||||
</div>
|
||||
) : connector
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-022
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-023
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { buildDay, buildDayNote, buildReservation, buildTrip } from '../../../tests/helpers/factories'
|
||||
import { buildDay, buildReservation, buildTrip } from '../../../tests/helpers/factories'
|
||||
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
||||
import type { Reservation } from '../../types'
|
||||
|
||||
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }))
|
||||
|
||||
// The subscribe dialog fetches its feed token on mount; it is exercised in its
|
||||
// own test, here we only care that opening the menu entry mounts it.
|
||||
// own test, and TripExportModal covers the entry that opens it.
|
||||
vi.mock('./IcsSubscribeModal', () => ({
|
||||
IcsSubscribeModal: ({ title, onClose }: { title: string; onClose: () => void }) => (
|
||||
<div data-testid="ics-subscribe-modal">
|
||||
@@ -46,8 +46,6 @@ function makeProps(overrides: Partial<React.ComponentProps<typeof DayPlanSidebar
|
||||
t,
|
||||
locale: 'en-US',
|
||||
toast: makeToast(),
|
||||
icsHover: false,
|
||||
setIcsHover: vi.fn((_v: boolean) => {}),
|
||||
expandedDays: new Set<number>(),
|
||||
setExpandedDays: vi.fn((_v: Set<number>) => {}),
|
||||
canUndo: false,
|
||||
@@ -72,37 +70,42 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('DayPlanSidebarToolbar', () => {
|
||||
// ── PDF export ────────────────────────────────────────────────────────────
|
||||
// ── Export ─────────────────────────────────────────────────────
|
||||
// PDF, ICS and GPX each used to be their own button with its own hover menu.
|
||||
// The mechanics now live in TripExportModal and are tested there; what is left
|
||||
// here is the single button that opens it.
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-001: the PDF button exports the trip with the day notes flattened', async () => {
|
||||
const user = userEvent.setup()
|
||||
const days = [buildDay({ id: 10, title: 'Day 1' })]
|
||||
const dayNotes = { '10': [buildDayNote({ id: 1, text: 'Bring cash' })] }
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ days, dayNotes })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
||||
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
|
||||
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
|
||||
})
|
||||
it('FE-PLANNER-DPTOOLBAR-001: the toolbar carries one export button, not three', () => {
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.getByRole('button', { name: 'dayplan.export' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('ICS')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('GPX')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('dayplan.pdf')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-002: a failing PDF export surfaces the error via the toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(downloadTripPDF).mockRejectedValueOnce(new Error('font missing'))
|
||||
const toast = makeToast()
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing'))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-003: hovering the PDF button shows the export tooltip', async () => {
|
||||
it('FE-PLANNER-DPTOOLBAR-002: the export button opens the dialog and closes again', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument()
|
||||
await user.hover(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(screen.getByText('dayplan.pdfTooltip')).toBeInTheDocument())
|
||||
await user.unhover(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument())
|
||||
const btn = screen.getByRole('button', { name: 'dayplan.export' })
|
||||
expect(btn).toHaveAttribute('aria-expanded', 'false')
|
||||
await user.click(btn)
|
||||
expect(btn).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.exportCalendar')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
||||
await waitFor(() => expect(screen.queryByText('dayplan.exportDocument')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-003: hovering the export button shows its tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.queryByText('dayplan.exportIntro')).not.toBeInTheDocument()
|
||||
const btn = screen.getByRole('button', { name: 'dayplan.export' })
|
||||
await user.hover(btn)
|
||||
await waitFor(() => expect(screen.getByText('dayplan.exportIntro')).toBeInTheDocument())
|
||||
await user.unhover(btn)
|
||||
await waitFor(() => expect(screen.queryByText('dayplan.exportIntro')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
// The button sits at the right edge of the leftmost pane. Its own tooltip was
|
||||
@@ -111,8 +114,8 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
it('FE-PLANNER-DPTOOLBAR-004: the export tooltip stays inside the window', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
await user.hover(screen.getByText('dayplan.pdf'))
|
||||
const tip = await screen.findByText('dayplan.pdfTooltip')
|
||||
await user.hover(screen.getByRole('button', { name: 'dayplan.export' }))
|
||||
const tip = await screen.findByText('dayplan.exportIntro')
|
||||
expect(tip.closest('[role="tooltip"]')).not.toBeNull()
|
||||
expect(tip.parentElement).toBe(document.body)
|
||||
expect(tip.style.position).toBe('fixed')
|
||||
@@ -120,94 +123,12 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
await waitFor(() => expect(parseFloat(tip.style.left)).toBeGreaterThanOrEqual(0))
|
||||
})
|
||||
|
||||
// ── ICS menu ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-005: hovering ICS opens the download/subscribe menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setIcsHover = vi.fn((_v: boolean) => {})
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ setIcsHover })} />)
|
||||
expect(screen.queryByText('Download ICS')).not.toBeInTheDocument()
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
|
||||
expect(setIcsHover).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-005b: without share_manage the menu offers the download but not the subscription', async () => {
|
||||
// The subscription mints a link that reads the trip without an account, so
|
||||
// it needs share_manage; the one-off download is a file this member may
|
||||
// already read. Leaving the entry visible would only produce a dialog whose
|
||||
// enable button the server refuses.
|
||||
it('FE-PLANNER-DPTOOLBAR-005: the dialog inherits the share permission from the toolbar', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ canManageShare: false })} />)
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Subscribe to calendar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-006: leaving the ICS area closes the menu after the grace period', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setIcsHover = vi.fn((_v: boolean) => {})
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ setIcsHover })} />)
|
||||
const icsButton = screen.getByText('ICS')
|
||||
await user.hover(icsButton)
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
await user.unhover(icsButton)
|
||||
await waitFor(() => expect(screen.queryByText('Download ICS')).not.toBeInTheDocument())
|
||||
expect(setIcsHover).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-007: re-entering the ICS area cancels the pending close', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
const icsButton = screen.getByText('ICS')
|
||||
await user.hover(icsButton)
|
||||
await user.unhover(icsButton)
|
||||
await user.hover(icsButton)
|
||||
// The close is scheduled 120ms out; coming back in has to cancel it.
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
expect(screen.getByText('Download ICS')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-008: "Download ICS" fetches the export and hands it to a download link', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
|
||||
} as unknown as Response)
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
await user.click(screen.getByText('Download ICS'))
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', { credentials: 'include' }))
|
||||
await waitFor(() => expect(clickSpy).toHaveBeenCalled())
|
||||
expect(revokeObjURL).toHaveBeenCalledWith('blob:mock')
|
||||
// The menu closes itself once the download starts.
|
||||
expect(screen.queryByText('Download ICS')).not.toBeInTheDocument()
|
||||
fetchSpy.mockRestore(); createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-009: a rejected ICS export shows the failure toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false } as unknown as Response)
|
||||
const toast = makeToast()
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
await user.click(screen.getByText('Download ICS'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('planner.icsExportFailed'))
|
||||
fetchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-010: "Subscribe to calendar" opens the subscribe dialog and closes again', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
await user.click(screen.getByText('Subscribe to calendar'))
|
||||
expect(screen.getByTestId('ics-subscribe-modal')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('close-subscribe'))
|
||||
expect(screen.queryByTestId('ics-subscribe-modal')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'dayplan.export' }))
|
||||
expect(screen.getByText('mobileTrip.icsDownload')).toBeInTheDocument()
|
||||
expect(screen.queryByText('mobileTrip.icsSubscribe')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Expand / collapse all ────────────────────────────────────────────────
|
||||
@@ -350,105 +271,6 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
expect(routeBtn.style.background).toBe('var(--bg-hover)')
|
||||
await user.unhover(routeBtn)
|
||||
expect(routeBtn.style.background).toBe('transparent')
|
||||
|
||||
await user.hover(screen.getByText('ICS'))
|
||||
const download = screen.getByText('Download ICS')
|
||||
const subscribe = screen.getByText('Subscribe to calendar')
|
||||
await user.hover(download)
|
||||
expect(download.style.background).toContain('var(--bg-hover')
|
||||
// Sliding down to the next entry has to clear the one we left.
|
||||
await user.hover(subscribe)
|
||||
expect(download.style.background).toBe('transparent')
|
||||
expect(subscribe.style.background).toContain('var(--bg-hover')
|
||||
await user.unhover(subscribe)
|
||||
expect(subscribe.style.background).toBe('transparent')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-024: an aborted PDF export without an Error still reaches the toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(downloadTripPDF).mockRejectedValueOnce('boom')
|
||||
const toast = makeToast()
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom'))
|
||||
})
|
||||
|
||||
// ── GPX export (#1442) ────────────────────────────────────────────────────
|
||||
describe('GPX menu', () => {
|
||||
let clickedHref: string | null
|
||||
|
||||
beforeEach(() => {
|
||||
clickedHref = null
|
||||
// jsdom has neither of these, and an anchor click would navigate.
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:gpx')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
clickedHref = this.href
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const okResponse = () => ({ ok: true, status: 200, blob: async () => new Blob(['<gpx/>']) }) as unknown as Response
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-025: the menu offers the three scopes on hover', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.queryByText('dayplan.gpxAll')).not.toBeInTheDocument()
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
expect(screen.getByText('dayplan.gpxAll')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.gpxPlaces')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.gpxDays')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-026: each scope asks the server for exactly its own selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = vi.fn(async () => okResponse())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
|
||||
for (const [label, expected] of [
|
||||
['dayplan.gpxAll', '/api/trips/1/places/export.gpx'],
|
||||
['dayplan.gpxPlaces', '/api/trips/1/places/export.gpx?dayRoutes=false'],
|
||||
['dayplan.gpxDays', '/api/trips/1/places/export.gpx?waypoints=false&tracks=false'],
|
||||
] as const) {
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText(label))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expected, { credentials: 'include' }))
|
||||
fetchMock.mockClear()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-027: a download names the file after the trip', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => okResponse()))
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(clickedHref).toBe('blob:gpx'))
|
||||
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-028: an empty trip says so instead of reporting a failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toast = makeToast()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response))
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty'))
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
expect(clickedHref).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-029: a real failure toasts the error', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toast = makeToast()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus, MapPin, Route as RouteIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, Download, Undo2, ArrowUpDown, Route as RouteIcon } from 'lucide-react'
|
||||
import { DayReorderPopup } from './DayReorderPopup'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { IcsSubscribeModal } from './IcsSubscribeModal'
|
||||
import { TripExportModal } from './TripExportModal'
|
||||
import { isRoutableReservation } from '../../utils/reservationRoutes'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
|
||||
/**
|
||||
* What a GPX download can carry. Worded by what someone wants on their device
|
||||
* rather than by the GPX element it maps to: "everything", "just the places" for
|
||||
* an offline map, "just the days" for following a plan. An omitted flag defaults
|
||||
* to true server-side, so the first entry needs no query at all.
|
||||
*/
|
||||
const GPX_SCOPES = [
|
||||
{ key: 'all', query: '', labelKey: 'dayplan.gpxAll', icon: FileDown },
|
||||
{ key: 'places', query: '?dayRoutes=false', labelKey: 'dayplan.gpxPlaces', icon: MapPin },
|
||||
{ key: 'days', query: '?waypoints=false&tracks=false', labelKey: 'dayplan.gpxDays', icon: RouteIcon },
|
||||
] as const
|
||||
|
||||
interface DayPlanSidebarToolbarProps {
|
||||
tripId: number
|
||||
trip: Trip
|
||||
@@ -33,8 +21,6 @@ interface DayPlanSidebarToolbarProps {
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
locale: string
|
||||
toast: ReturnType<typeof useToast>
|
||||
icsHover: boolean
|
||||
setIcsHover: (v: boolean) => void
|
||||
expandedDays: Set<number>
|
||||
setExpandedDays: (next: Set<number>) => void
|
||||
onUndo?: () => void
|
||||
@@ -44,11 +30,9 @@ interface DayPlanSidebarToolbarProps {
|
||||
lastActionLabel: string | null
|
||||
canEditDays?: boolean
|
||||
/**
|
||||
* Gates "Subscribe to calendar" only. The one-off "Download ICS" below it
|
||||
* stays open to every member: it is a file they already have the right to
|
||||
* read, while the subscription mints a link that works without an account.
|
||||
* Defaults to true so a caller that has not wired the permission through
|
||||
* keeps today's menu rather than silently losing an entry.
|
||||
* Gates "Subscribe to calendar" in the export dialog only. Defaults to true so
|
||||
* a caller that has not wired the permission through keeps today's entries
|
||||
* rather than silently losing one.
|
||||
*/
|
||||
canManageShare?: boolean
|
||||
onReorderDays?: (orderedIds: number[]) => void
|
||||
@@ -58,230 +42,53 @@ interface DayPlanSidebarToolbarProps {
|
||||
export function DayPlanSidebarToolbar({
|
||||
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||
allConnectionsShown = false, onToggleAllConnections,
|
||||
t, locale, toast, setIcsHover,
|
||||
t, locale, toast,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
canEditDays, canManageShare = true, onReorderDays, onAddDay,
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
const icsMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [icsMenuVisible, setIcsMenuVisible] = useState(false)
|
||||
const gpxMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [gpxMenuVisible, setGpxMenuVisible] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
|
||||
const showIcsMenu = () => {
|
||||
if (icsMenuTimeoutRef.current) clearTimeout(icsMenuTimeoutRef.current)
|
||||
setIcsMenuVisible(true)
|
||||
setIcsHover(true)
|
||||
}
|
||||
const hideIcsMenu = () => {
|
||||
icsMenuTimeoutRef.current = setTimeout(() => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
}, 120)
|
||||
}
|
||||
|
||||
const showGpxMenu = () => {
|
||||
if (gpxMenuTimeoutRef.current) clearTimeout(gpxMenuTimeoutRef.current)
|
||||
setGpxMenuVisible(true)
|
||||
}
|
||||
const hideGpxMenu = () => {
|
||||
gpxMenuTimeoutRef.current = setTimeout(() => setGpxMenuVisible(false), 120)
|
||||
}
|
||||
|
||||
const downloadGpx = async (query: string) => {
|
||||
setGpxMenuVisible(false)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/places/export.gpx${query}`, { credentials: 'include' })
|
||||
// 404 here means the selection is empty, which is worth its own message:
|
||||
// "nothing happened" and "the download broke" look identical otherwise.
|
||||
if (res.status === 404) { toast.info(t('dayplan.gpxEmpty')); return }
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.gpx`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error(t('dayplan.gpxFailed')) }
|
||||
}
|
||||
|
||||
const menuItemStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 7,
|
||||
width: '100%', padding: '7px 12px', border: 'none',
|
||||
background: 'transparent', cursor: 'pointer',
|
||||
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
|
||||
color: 'var(--text-primary)', textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
}
|
||||
return (
|
||||
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<Tooltip label={t('dayplan.pdfTooltip')} placement="bottom">
|
||||
{/* One export button instead of three: PDF, ICS and GPX each carried
|
||||
their own hover menu, and on a narrower sidebar the row ran out of
|
||||
width and pushed them off the edge. The dialog holds every option. */}
|
||||
<Tooltip label={t('dayplan.exportIntro')} placement="bottom">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
|
||||
notes.map(n => ({ ...n, day_id: Number(dayId) }))
|
||||
)
|
||||
try {
|
||||
// Loaded on click: the PDF builder is ~226 kB and hangs off the
|
||||
// days sidebar, so every trip used to pay for it whether or not
|
||||
// anyone exported. A missing chunk lands in the catch below and
|
||||
// shows the same error the export already had.
|
||||
const { downloadTripPDF } = await import('../PDF/TripPDF')
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
|
||||
} catch (e) {
|
||||
console.error('PDF error:', e)
|
||||
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => setExportOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={exportOpen}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0,
|
||||
padding: '5px 10px', borderRadius: 8, border: 'none',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
{t('dayplan.pdf')}
|
||||
<Download size={13} strokeWidth={2} />
|
||||
{t('dayplan.export')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
style={{ position: 'relative', flexShrink: 0 }}
|
||||
onMouseEnter={showIcsMenu}
|
||||
onMouseLeave={hideIcsMenu}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-muted)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
ICS
|
||||
</button>
|
||||
{icsMenuVisible && (
|
||||
<div
|
||||
onMouseEnter={showIcsMenu}
|
||||
onMouseLeave={hideIcsMenu}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', left: '50%', transform: 'translateX(-50%)',
|
||||
zIndex: 200, minWidth: 160,
|
||||
background: 'var(--bg-card, white)',
|
||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.ics`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error(t('planner.icsExportFailed')) }
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<FileDown size={12} strokeWidth={2} />
|
||||
Download ICS
|
||||
</button>
|
||||
{canManageShare && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIcsMenuVisible(false)
|
||||
setIcsHover(false)
|
||||
setSubscribeOpen(true)
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<CalendarPlus size={12} strokeWidth={2} />
|
||||
Subscribe to calendar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* GPX — the counterpart to the GPX import in the places sidebar, sitting
|
||||
with the other exports rather than with the importer. Same hover-menu
|
||||
shape as ICS beside it. */}
|
||||
<div
|
||||
style={{ position: 'relative', flexShrink: 0 }}
|
||||
onMouseEnter={showGpxMenu}
|
||||
onMouseLeave={hideGpxMenu}
|
||||
>
|
||||
<button
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={gpxMenuVisible}
|
||||
aria-label={t('dayplan.gpxTooltip')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-muted)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
GPX
|
||||
</button>
|
||||
{gpxMenuVisible && (
|
||||
<div
|
||||
role="menu"
|
||||
onMouseEnter={showGpxMenu}
|
||||
onMouseLeave={hideGpxMenu}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', left: '50%', transform: 'translateX(-50%)',
|
||||
zIndex: 200, minWidth: 190,
|
||||
background: 'var(--bg-card, white)',
|
||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{GPX_SCOPES.map(scope => (
|
||||
<button
|
||||
key={scope.key}
|
||||
role="menuitem"
|
||||
onClick={() => downloadGpx(scope.query)}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<scope.icon size={12} strokeWidth={2} />
|
||||
{t(scope.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{subscribeOpen && canManageShare && (
|
||||
<IcsSubscribeModal
|
||||
endpoint={`/api/trips/${tripId}/feed`}
|
||||
title="Subscribe to calendar"
|
||||
description="This link stays in sync with your trip automatically. Calendar apps re-fetch it every hour."
|
||||
onClose={() => setSubscribeOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
<TripExportModal
|
||||
isOpen={exportOpen}
|
||||
onClose={() => setExportOpen(false)}
|
||||
tripId={tripId}
|
||||
trip={trip}
|
||||
days={days}
|
||||
places={places}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
dayNotes={dayNotes}
|
||||
t={t}
|
||||
locale={locale}
|
||||
toast={toast}
|
||||
canManageShare={canManageShare}
|
||||
/>
|
||||
{(() => {
|
||||
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
|
||||
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
|
||||
|
||||
@@ -90,7 +90,7 @@ export function DayPlanSidebarTransportDetailModal({
|
||||
<div className={res.status === 'confirmed' ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{
|
||||
padding: '3px 8px', borderRadius: 6, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
}}>
|
||||
{(res.status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')).replace(/\s*·\s*$/, '')}
|
||||
{res.status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-062
|
||||
// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -072
|
||||
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -1467,4 +1467,94 @@ describe('PlaceFormModal remaining branches', () => {
|
||||
expect(websiteInput().value).toBe('https://stored.example');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Linked expense (#1298) — the same block bookings have ──────────────────
|
||||
|
||||
describe('Costs section', () => {
|
||||
const withBudget = () => seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-068: the block only appears while the Budget addon is on', () => {
|
||||
const { unmount } = render(<PlaceFormModal {...defaultProps} />);
|
||||
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
withBudget();
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-069: creating an expense saves the place first, then opens the editor', async () => {
|
||||
withBudget();
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 42 });
|
||||
const onOpenExpense = vi.fn();
|
||||
render(<PlaceFormModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
||||
|
||||
await waitFor(() => expect(onOpenExpense).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalled();
|
||||
expect(onSave.mock.invocationCallOrder[0]).toBeLessThan(onOpenExpense.mock.invocationCallOrder[0]);
|
||||
expect(onOpenExpense).toHaveBeenCalledWith({
|
||||
prefill: { placeId: 42, name: 'Louvre', category: 'activities' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-070: a save that yields no id opens no editor', async () => {
|
||||
withBudget();
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const onOpenExpense = vi.fn();
|
||||
render(<PlaceFormModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onOpenExpense).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-070b: a nameless click drops the intent instead of arming the next save', async () => {
|
||||
withBudget();
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 42 });
|
||||
const onOpenExpense = vi.fn();
|
||||
render(<PlaceFormModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
||||
|
||||
// No name yet — the save is refused and the intent must not survive it.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
||||
await waitFor(() => expect(onSave).not.toHaveBeenCalled());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: 'Louvre' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onOpenExpense).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-071: an already-linked expense is shown with its amount instead of the button', () => {
|
||||
withBudget();
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
budgetItems: [{ id: 8, trip_id: 1, name: 'Louvre tickets', total_price: 34, category: 'activities', place_id: 7 }],
|
||||
});
|
||||
render(<PlaceFormModal {...defaultProps} place={{ id: 7, name: 'Louvre' } as never} />);
|
||||
|
||||
expect(screen.getByText('Louvre tickets')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-072: an expense linked to another place is not claimed', () => {
|
||||
withBudget();
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
budgetItems: [{ id: 8, trip_id: 1, name: 'Orsay tickets', total_price: 16, category: 'activities', place_id: 99 }],
|
||||
});
|
||||
render(<PlaceFormModal {...defaultProps} place={{ id: 7, name: 'Louvre' } as never} />);
|
||||
|
||||
expect(screen.queryByText('Orsay tickets')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,9 @@ import { useTranslation } from '../../i18n'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { DEFAULT_FORM, isGoogleMapsUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import type { Place, Category, Assignment } from '../../types'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import type { Place, Category, Assignment, BudgetItem } from '../../types'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
|
||||
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
|
||||
@@ -31,7 +33,7 @@ export interface PlaceSubmitData extends Omit<PlaceFormData, 'lat' | 'lng' | 'ca
|
||||
interface PlaceFormModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<void> | void
|
||||
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
|
||||
place: Place | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
|
||||
tripId: number
|
||||
@@ -43,6 +45,9 @@ interface PlaceFormModalProps {
|
||||
* picker column when the Collections addon is enabled. Sourced from the trip
|
||||
* planner's matchMedia('(max-width:767px)'). */
|
||||
isMobile?: boolean
|
||||
/** Opens the Costs editor for this place's linked expense (#1298) — the same
|
||||
* seam the booking and transport modals use. */
|
||||
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +83,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const {
|
||||
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
||||
onCategoryCreated, assignmentId, dayAssignments = [], isMobile = false,
|
||||
onOpenExpense,
|
||||
} = props
|
||||
const [form, setForm] = useState(DEFAULT_FORM)
|
||||
const [mapsSearch, setMapsSearch] = useState('')
|
||||
@@ -108,6 +114,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
const canUploadFiles = can('file_upload', tripObj)
|
||||
const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections'))
|
||||
const isBudgetEnabled = useAddonStore((s) => s.isEnabled('budget'))
|
||||
const deleteBudgetItem = useTripStore((s) => s.deleteBudgetItem)
|
||||
// Set right before submit when the user clicked create/edit expense — the
|
||||
// place has to exist before an expense can point at it (see ReservationModal).
|
||||
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (place) {
|
||||
@@ -430,9 +441,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
|
||||
const hasTimeError = place && form.place_time && form.end_time && form.place_time.length >= 5 && form.end_time.length >= 5 && form.end_time <= form.place_time
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
const handleSubmit = async (e?: { preventDefault?: () => void }) => {
|
||||
e?.preventDefault?.()
|
||||
if (!form.name.trim()) {
|
||||
// Nothing was saved, so an expense intent from a previous click is stale —
|
||||
// otherwise the next plain Save would open a Costs editor out of nowhere.
|
||||
expenseIntentRef.current = null
|
||||
toast.error(t('places.nameRequired'))
|
||||
return
|
||||
}
|
||||
@@ -449,13 +463,23 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await onSave({
|
||||
const saved = await onSave({
|
||||
...form,
|
||||
lat: form.lat ? parseFloat(form.lat) : null,
|
||||
lng: form.lng ? parseFloat(form.lng) : null,
|
||||
category_id: form.category_id || null,
|
||||
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||
})
|
||||
// Open the Costs editor for the saved place when the user asked to
|
||||
// create/edit its linked expense — gated on an id, so a create that the
|
||||
// server refused never opens an editor pointing at nothing (#1298).
|
||||
const intent = expenseIntentRef.current
|
||||
expenseIntentRef.current = null
|
||||
const savedId = (saved && 'id' in saved ? saved.id : null) ?? place?.id ?? null
|
||||
if (intent && onOpenExpense && savedId) {
|
||||
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
|
||||
else onOpenExpense({ prefill: { placeId: savedId, name: form.name.trim(), category: 'activities' } })
|
||||
}
|
||||
onClose()
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('places.saveError'))
|
||||
@@ -464,6 +488,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; void handleSubmit() }
|
||||
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; void handleSubmit() }
|
||||
const handleRemoveExpense = async (item: BudgetItem) => {
|
||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -527,6 +557,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
handleSubmit,
|
||||
duplicateWarning,
|
||||
detailsSelection,
|
||||
isBudgetEnabled,
|
||||
handleCreateExpense,
|
||||
handleEditExpense,
|
||||
handleRemoveExpense,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,6 +627,10 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
handleSubmit,
|
||||
duplicateWarning,
|
||||
detailsSelection,
|
||||
isBudgetEnabled,
|
||||
handleCreateExpense,
|
||||
handleEditExpense,
|
||||
handleRemoveExpense,
|
||||
} = S
|
||||
// Desktop + Collections addon → the saved-place picker on the right. Mobile
|
||||
// always keeps the original single-column form untouched.
|
||||
@@ -917,6 +955,19 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Costs — create / view the expense linked to this place (#1298).
|
||||
Same block, same flow as a booking: save first, then the editor. */}
|
||||
{isBudgetEnabled && (
|
||||
<BookingCostsSection
|
||||
placeId={place?.id ?? null}
|
||||
reservationId={null}
|
||||
hintKey="places.createExpenseHint"
|
||||
onCreate={handleCreateExpense}
|
||||
onEdit={handleEditExpense}
|
||||
onRemove={handleRemoveExpense}
|
||||
/>
|
||||
)}
|
||||
|
||||
</form>
|
||||
{twoColumn && (
|
||||
<CollectionPicker bias={locationBias} onSelect={handleSelectMapsResult} t={t} />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Check, Tag, Trash2, Bookmark } from 'lucide-react'
|
||||
import { Check, CheckCircle2, Loader2, Tag, Trash2, Bookmark } from 'lucide-react'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
|
||||
export function PlacesSelectionBar(S: SidebarState) {
|
||||
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces, setCategoryPickerOpen, collectionsEnabled, setSaveToListOpen } = S
|
||||
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces, setCategoryPickerOpen, collectionsEnabled, setSaveToListOpen, markSelectionVisited, markVisitedBusy } = S
|
||||
return (
|
||||
<div style={{
|
||||
margin: '6px 16px', padding: '5px 8px 5px 10px', borderRadius: 8,
|
||||
@@ -68,6 +68,25 @@ export function PlacesSelectionBar(S: SidebarState) {
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{collectionsEnabled && (
|
||||
<Tooltip label={t('collections.markVisitedSelection')} placement="bottom">
|
||||
<button
|
||||
onClick={() => { if (selectedIds.size === 0) return; void markSelectionVisited() }}
|
||||
disabled={selectedIds.size === 0 || markVisitedBusy}
|
||||
aria-label={t('collections.markVisitedSelection')}
|
||||
className={selectedIds.size > 0 ? 'bg-transparent text-content-muted' : 'bg-transparent text-content-faint'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 24, height: 24, borderRadius: 6, border: 'none',
|
||||
cursor: selectedIds.size > 0 ? 'pointer' : 'default', padding: 0,
|
||||
}}
|
||||
onMouseEnter={e => { if (selectedIds.size > 0) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
{markVisitedBusy ? <Loader2 size={13} className="animate-spin" /> : <CheckCircle2 size={13} strokeWidth={2} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t('places.deleteSelected')} placement="bottom">
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -177,6 +177,20 @@ describe('TransitSearchPanel', () => {
|
||||
expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ arriveBy: true, time: '2025-06-01T08:00:00.000Z' })
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-008b: initialTime seeds the departure time', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
|
||||
render(<TransitSearchPanel {...makeProps({
|
||||
initialFrom: { name: 'Fernsehturm', lat: 52.5208, lng: 13.4094 },
|
||||
initialTo: { name: 'London Victoria', lat: 51.5074, lng: -0.1278 },
|
||||
initialTime: '11:00',
|
||||
})} />)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled())
|
||||
// 11:00 in Berlin (UTC+2) anchors depart-by to the origin zone → 09:00Z.
|
||||
expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ time: '2025-06-01T09:00:00.000Z' })
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-009: arrive-by lists the latest-arriving itinerary first', async () => {
|
||||
const user = userEvent.setup()
|
||||
// MOTIS returns arrive-by results ascending, deadline-adjacent last (#1479).
|
||||
|
||||
@@ -318,9 +318,11 @@ interface TransitSearchPanelProps {
|
||||
/** Pre-seed from/to — used by "change route" on an existing journey. */
|
||||
initialFrom?: PickedPlace | null
|
||||
initialTo?: PickedPlace | null
|
||||
/** Pre-seed the departure time ('HH:mm') — used when planning a specific leg. */
|
||||
initialTime?: string | null
|
||||
}
|
||||
|
||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null }: TransitSearchPanelProps) {
|
||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
@@ -328,7 +330,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
|
||||
const [from, setFrom] = useState<PickedPlace | null>(initialFrom)
|
||||
const [to, setTo] = useState<PickedPlace | null>(initialTo)
|
||||
const [time, setTime] = useState('09:00')
|
||||
const [time, setTime] = useState(initialTime || '09:00')
|
||||
const [arriveBy, setArriveBy] = useState(false)
|
||||
const [activeModes, setActiveModes] = useState<Set<string>>(() => new Set(MODE_GROUPS.map(m => m.key)))
|
||||
const [pref, setPref] = useState<'best' | 'transfers' | 'walking'>('best')
|
||||
|
||||
@@ -160,8 +160,8 @@ interface TransportModalProps {
|
||||
initialAutomated?: boolean
|
||||
/** Transit search needs real dates to depart on, so the Automated mode is hidden on a dateless trip. */
|
||||
tripHasDates?: boolean
|
||||
/** Pre-seed the transit search — used by "change route" on an existing journey. */
|
||||
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null } | null
|
||||
/** Pre-seed the transit search — used by "change route" and by per-leg planning. */
|
||||
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null; time?: string | null } | null
|
||||
/** Trip members + guests, for the traveler picker (#1517). */
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
@@ -701,6 +701,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
onAdd={(payload) => onSave(payload as Record<string, any> & { title: string })}
|
||||
initialFrom={transitPrefill?.from ?? null}
|
||||
initialTo={transitPrefill?.to ?? null}
|
||||
initialTime={transitPrefill?.time ?? null}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// FE-PLANNER-EXPORTMODAL-001 to FE-PLANNER-EXPORTMODAL-014
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { buildDay, buildDayNote, buildTrip } from '../../../tests/helpers/factories'
|
||||
import { TripExportModal } from './TripExportModal'
|
||||
|
||||
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }))
|
||||
|
||||
// The subscribe dialog fetches its feed token on mount; it is exercised in its
|
||||
// own test, here we only care that the entry mounts it.
|
||||
vi.mock('./IcsSubscribeModal', () => ({
|
||||
IcsSubscribeModal: ({ title, onClose }: { title: string; onClose: () => void }) => (
|
||||
<div data-testid="ics-subscribe-modal">
|
||||
{title}
|
||||
<button onClick={onClose}>close-subscribe</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}|${Object.values(params).join('|')}` : key
|
||||
|
||||
const trip = buildTrip({ id: 1, title: 'Roadtrip' })
|
||||
|
||||
function makeToast() {
|
||||
return {
|
||||
success: vi.fn((_m: string) => {}),
|
||||
error: vi.fn((_m: string) => {}),
|
||||
warning: vi.fn((_m: string) => {}),
|
||||
info: vi.fn((_m: string) => {}),
|
||||
}
|
||||
}
|
||||
|
||||
function makeProps(overrides: Partial<React.ComponentProps<typeof TripExportModal>> = {}) {
|
||||
return {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
tripId: 1,
|
||||
trip,
|
||||
days: [],
|
||||
places: [],
|
||||
categories: [],
|
||||
assignments: {},
|
||||
reservations: [],
|
||||
dayNotes: {},
|
||||
t,
|
||||
locale: 'en-US',
|
||||
toast: makeToast(),
|
||||
...overrides,
|
||||
} as React.ComponentProps<typeof TripExportModal>
|
||||
}
|
||||
|
||||
let clickedHref: string | null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clickedHref = null
|
||||
// jsdom has neither of these, and an anchor click would navigate.
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
clickedHref = this.href
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const okResponse = (body = '<gpx/>') =>
|
||||
({ ok: true, status: 200, blob: async () => new Blob([body]) }) as unknown as Response
|
||||
|
||||
describe('TripExportModal', () => {
|
||||
it('FE-PLANNER-EXPORTMODAL-001: closed, it renders nothing', () => {
|
||||
render(<TripExportModal {...makeProps({ isOpen: false })} />)
|
||||
expect(screen.queryByText('dayplan.export')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-002: open, every export sits in its own section', () => {
|
||||
render(<TripExportModal {...makeProps()} />)
|
||||
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.exportCalendar')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.exportMaps · GPX')).toBeInTheDocument()
|
||||
for (const label of ['dayplan.pdf', 'mobileTrip.icsDownload', 'mobileTrip.icsSubscribe',
|
||||
'dayplan.gpxAll', 'dayplan.gpxPlaces', 'dayplan.gpxDays']) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
// ── PDF ───────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-003: the PDF row exports the trip with the day notes flattened', async () => {
|
||||
const user = userEvent.setup()
|
||||
const days = [buildDay({ id: 10, title: 'Day 1' })]
|
||||
const dayNotes = { '10': [buildDayNote({ id: 1, text: 'Bring cash' })] }
|
||||
render(<TripExportModal {...makeProps({ days, dayNotes })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
||||
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
|
||||
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-004: a finished PDF export closes the dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(<TripExportModal {...makeProps({ onClose })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-005: a failing PDF export toasts the error and keeps the dialog open', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(downloadTripPDF).mockRejectedValueOnce(new Error('font missing'))
|
||||
const toast = makeToast()
|
||||
const onClose = vi.fn()
|
||||
render(<TripExportModal {...makeProps({ toast, onClose })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-006: an aborted PDF export without an Error still reaches the toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(downloadTripPDF).mockRejectedValueOnce('boom')
|
||||
const toast = makeToast()
|
||||
render(<TripExportModal {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom'))
|
||||
})
|
||||
|
||||
// ── Calendar ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-007: the ICS row fetches the export and hands it to a download link', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = vi.fn(async () => okResponse('BEGIN:VCALENDAR'))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
render(<TripExportModal {...makeProps()} />)
|
||||
await user.click(screen.getByText('mobileTrip.icsDownload'))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/trips/1/export.ics', { credentials: 'include' }))
|
||||
await waitFor(() => expect(clickedHref).toBe('blob:mock'))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-008: a rejected ICS export shows the failure toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
|
||||
const toast = makeToast()
|
||||
render(<TripExportModal {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('mobileTrip.icsDownload'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('planner.icsExportFailed'))
|
||||
expect(clickedHref).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-009: the subscribe row opens the subscribe dialog and closes again', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TripExportModal {...makeProps()} />)
|
||||
await user.click(screen.getByText('mobileTrip.icsSubscribe'))
|
||||
expect(screen.getByTestId('ics-subscribe-modal')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('close-subscribe'))
|
||||
expect(screen.queryByTestId('ics-subscribe-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The subscription mints a link that reads the trip without an account, so it
|
||||
// needs share_manage; the one-off download is a file this member may already
|
||||
// read. Leaving the entry visible would only produce a dialog whose enable
|
||||
// button the server refuses.
|
||||
it('FE-PLANNER-EXPORTMODAL-010: without share_manage the download stays and the subscription goes', () => {
|
||||
render(<TripExportModal {...makeProps({ canManageShare: false })} />)
|
||||
expect(screen.getByText('mobileTrip.icsDownload')).toBeInTheDocument()
|
||||
expect(screen.queryByText('mobileTrip.icsSubscribe')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── GPX (#1442) ───────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-011: each scope asks the server for exactly its own selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = vi.fn(async () => okResponse())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
for (const [label, expected] of [
|
||||
['dayplan.gpxAll', '/api/trips/1/places/export.gpx'],
|
||||
['dayplan.gpxPlaces', '/api/trips/1/places/export.gpx?dayRoutes=false'],
|
||||
['dayplan.gpxDays', '/api/trips/1/places/export.gpx?waypoints=false&tracks=false'],
|
||||
] as const) {
|
||||
const view = render(<TripExportModal {...makeProps()} />)
|
||||
await user.click(screen.getByText(label))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expected, { credentials: 'include' }))
|
||||
fetchMock.mockClear()
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-012: a download names the file after the trip', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => okResponse()))
|
||||
render(<TripExportModal {...makeProps()} />)
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(clickedHref).toBe('blob:mock'))
|
||||
await waitFor(() => expect(globalThis.URL.revokeObjectURL).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-013: an empty trip says so instead of reporting a failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toast = makeToast()
|
||||
const onClose = vi.fn()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response))
|
||||
render(<TripExportModal {...makeProps({ toast, onClose })} />)
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty'))
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
expect(clickedHref).toBeNull()
|
||||
// Nothing was downloaded, so the dialog stays put.
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-014: a real failure toasts the error', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toast = makeToast()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
|
||||
render(<TripExportModal {...makeProps({ toast })} />)
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { CalendarDays, CalendarPlus, ChevronRight, Download, FileText, Loader2, MapPin, Route as RouteIcon } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { IcsSubscribeModal } from './IcsSubscribeModal'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
|
||||
/**
|
||||
* What a GPX download can carry. Worded by what someone wants on their device
|
||||
* rather than by the GPX element it maps to: "everything", "just the places" for
|
||||
* an offline map, "just the days" for following a plan. An omitted flag defaults
|
||||
* to true server-side, so the first entry needs no query at all.
|
||||
*/
|
||||
const GPX_SCOPES = [
|
||||
{ key: 'all', query: '', labelKey: 'dayplan.gpxAll', icon: Download },
|
||||
{ key: 'places', query: '?dayRoutes=false', labelKey: 'dayplan.gpxPlaces', icon: MapPin },
|
||||
{ key: 'days', query: '?waypoints=false&tracks=false', labelKey: 'dayplan.gpxDays', icon: RouteIcon },
|
||||
] as const
|
||||
|
||||
interface TripExportModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
tripId: number
|
||||
trip: Trip
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
categories: Category[]
|
||||
assignments: AssignmentsMap
|
||||
reservations: Reservation[]
|
||||
dayNotes: Record<string, DayNote[]>
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
locale: string
|
||||
toast: ReturnType<typeof useToast>
|
||||
/**
|
||||
* Gates "Subscribe to calendar" only. The one-off ICS download above it stays
|
||||
* open to every member: it is a file they already have the right to read,
|
||||
* while the subscription mints a link that works without an account.
|
||||
*/
|
||||
canManageShare?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Every way a trip leaves TREK, in one dialog: the day plan as a PDF, the
|
||||
* bookings as a calendar (a one-off .ics or a live subscription) and the map
|
||||
* data as GPX in the three scopes a device actually wants.
|
||||
*
|
||||
* It replaces three separate toolbar buttons, two of which opened hover menus of
|
||||
* their own. On a narrow sidebar that row simply ran out of width and the
|
||||
* exports dropped off the edge; one button cannot.
|
||||
*/
|
||||
export function TripExportModal({
|
||||
isOpen, onClose, tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||
t, locale, toast, canManageShare = true,
|
||||
}: TripExportModalProps) {
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
// Which row is working, so the dialog can say so instead of looking inert
|
||||
// while a 226 kB PDF builder is fetched and a document is rendered.
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const fileBase = trip?.title || 'trip'
|
||||
|
||||
// Shared tail of every download: Firefox and Safari cancel the download when
|
||||
// the object URL is revoked before they picked the blob up, hence the delay.
|
||||
const saveBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100)
|
||||
}
|
||||
|
||||
const exportPdf = async () => {
|
||||
if (busy) return
|
||||
setBusy('pdf')
|
||||
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
|
||||
notes.map(n => ({ ...n, day_id: Number(dayId) })),
|
||||
)
|
||||
try {
|
||||
// Loaded on click: the PDF builder is ~226 kB and hangs off the days
|
||||
// sidebar, so every trip used to pay for it whether or not anyone
|
||||
// exported. A missing chunk lands in the catch and shows the same error
|
||||
// the export already had.
|
||||
const { downloadTripPDF } = await import('../PDF/TripPDF')
|
||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
|
||||
onClose()
|
||||
} catch (e) {
|
||||
console.error('PDF error:', e)
|
||||
toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadIcs = async () => {
|
||||
if (busy) return
|
||||
setBusy('ics')
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error()
|
||||
saveBlob(await res.blob(), `${fileBase}.ics`)
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error(t('planner.icsExportFailed'))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadGpx = async (key: string, query: string) => {
|
||||
if (busy) return
|
||||
setBusy(`gpx:${key}`)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/places/export.gpx${query}`, { credentials: 'include' })
|
||||
// 404 here means the selection is empty, which is worth its own message:
|
||||
// "nothing happened" and "the download broke" look identical otherwise.
|
||||
if (res.status === 404) { toast.info(t('dayplan.gpxEmpty')); return }
|
||||
if (!res.ok) throw new Error()
|
||||
saveBlob(await res.blob(), `${fileBase}.gpx`)
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error(t('dayplan.gpxFailed'))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
title={
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-accent-subtle text-accent-on">
|
||||
<Download size={16} strokeWidth={2} />
|
||||
</span>
|
||||
{t('dayplan.export')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<p className="-mt-1 mb-5 text-caption text-content-muted">{t('dayplan.exportIntro')}</p>
|
||||
|
||||
<Section label={t('dayplan.exportDocument')}>
|
||||
<ExportRow
|
||||
icon={FileText}
|
||||
title={t('dayplan.pdf')}
|
||||
sub={t('dayplan.pdfTooltip')}
|
||||
busy={busy === 'pdf'}
|
||||
disabled={busy != null}
|
||||
onClick={exportPdf}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section label={t('dayplan.exportCalendar')}>
|
||||
<ExportRow
|
||||
icon={CalendarDays}
|
||||
title={t('mobileTrip.icsDownload')}
|
||||
sub={`${fileBase}.ics`}
|
||||
busy={busy === 'ics'}
|
||||
disabled={busy != null}
|
||||
onClick={downloadIcs}
|
||||
/>
|
||||
{canManageShare && (
|
||||
<ExportRow
|
||||
icon={CalendarPlus}
|
||||
title={t('mobileTrip.icsSubscribe')}
|
||||
sub={t('mobileTrip.icsSubscribeSub')}
|
||||
disabled={busy != null}
|
||||
onClick={() => setSubscribeOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* GPX — the counterpart to the GPX import in the places sidebar. The
|
||||
format name carries more than a translated heading would, so it is
|
||||
spelled out beside the plain-language one. */}
|
||||
<Section label={`${t('dayplan.exportMaps')} · GPX`}>
|
||||
{GPX_SCOPES.map(scope => (
|
||||
<ExportRow
|
||||
key={scope.key}
|
||||
icon={scope.icon}
|
||||
title={t(scope.labelKey)}
|
||||
busy={busy === `gpx:${scope.key}`}
|
||||
disabled={busy != null}
|
||||
onClick={() => downloadGpx(scope.key, scope.query)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
</Modal>
|
||||
|
||||
{subscribeOpen && canManageShare && (
|
||||
<IcsSubscribeModal
|
||||
endpoint={`/api/trips/${tripId}/feed`}
|
||||
title={t('mobileTrip.icsSubscribe')}
|
||||
description={t('mobileTrip.icsSubscribeSub')}
|
||||
onClose={() => setSubscribeOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="mb-4 last:mb-0">
|
||||
<h3 className="mb-1.5 px-1 text-caption font-bold uppercase tracking-[0.07em] text-content-faint">{label}</h3>
|
||||
<div className="divide-y divide-edge-faint overflow-hidden rounded-xl border border-edge-faint bg-surface">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ExportRow({ icon: Icon, title, sub, busy = false, disabled = false, onClick }: {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
sub?: string
|
||||
busy?: boolean
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="group flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50"
|
||||
>
|
||||
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-[10px] bg-surface-tertiary text-content-secondary transition-colors group-enabled:group-hover:bg-accent-subtle group-enabled:group-hover:text-accent-on">
|
||||
{busy ? <Loader2 size={16} strokeWidth={2} className="animate-spin" /> : <Icon size={16} strokeWidth={1.9} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body font-semibold text-content">{title}</span>
|
||||
{sub && <span className="block truncate text-caption text-content-muted">{sub}</span>}
|
||||
</span>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
strokeWidth={2}
|
||||
className="flex-none text-content-faint transition-transform group-enabled:group-hover:translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useContextMenu } from '../shared/ContextMenu'
|
||||
import { placesApi } from '../../api/client'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
@@ -157,8 +158,32 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
|
||||
const [saveToListOpen, setSaveToListOpen] = useState(false)
|
||||
|
||||
const [markVisitedBusy, setMarkVisitedBusy] = useState(false)
|
||||
|
||||
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
|
||||
|
||||
/**
|
||||
* "I have been to these" for the selection, applied wherever the places are
|
||||
* saved in the library (#1469). The server does the matching, so a place saved
|
||||
* under a different name in a list is still found.
|
||||
*/
|
||||
const markSelectionVisited = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0 || markVisitedBusy) return
|
||||
setMarkVisitedBusy(true)
|
||||
try {
|
||||
const { updated, places: matchedPlaces } = await collectionsApi.setStatusFromTrip(props.tripId, ids, 'visited')
|
||||
if (updated === 0) toast.info(t('collections.markVisitedNone'))
|
||||
else toast.success(t('collections.markedVisitedTrip', { count: matchedPlaces ?? 0 }))
|
||||
exitSelectMode()
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setMarkVisitedBusy(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedIds, markVisitedBusy, props.tripId, t])
|
||||
|
||||
// Auto-exit when all selected places have been removed from the store (e.g. after bulk delete)
|
||||
useEffect(() => {
|
||||
if (!selectMode || selectedIds.size === 0) return
|
||||
@@ -275,6 +300,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
||||
categoryPickerOpen, setCategoryPickerOpen,
|
||||
saveToListOpen, setSaveToListOpen, collectionsEnabled, tripId,
|
||||
markSelectionVisited, markVisitedBusy,
|
||||
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
|
||||
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
|
||||
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
// FE-COMP-LLM-001 to FE-COMP-LLM-013
|
||||
// FE-COMP-LLM-001 to FE-COMP-LLM-016
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildSettings } from '../../../tests/helpers/factories';
|
||||
import { buildAdmin, buildSettings, buildUser } from '../../../tests/helpers/factories';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { Settings } from '../../types';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import LlmConnectionSection from './LlmConnectionSection';
|
||||
|
||||
let loadSettings = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
function seedLlm(over: Partial<Settings> = {}, updateSettings = vi.fn().mockResolvedValue(undefined)) {
|
||||
loadSettings = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ language: 'en', ...over }),
|
||||
isLoaded: true,
|
||||
updateSettings,
|
||||
loadSettings,
|
||||
});
|
||||
return updateSettings;
|
||||
}
|
||||
|
||||
function seedRole(role: 'user' | 'admin') {
|
||||
seedStore(useAuthStore, {
|
||||
user: role === 'admin' ? buildAdmin() : buildUser(),
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
function renderSection() {
|
||||
return render(
|
||||
<>
|
||||
@@ -39,31 +52,30 @@ async function pickProvider(user: ReturnType<typeof userEvent.setup>, current: R
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedRole('user');
|
||||
seedLlm();
|
||||
});
|
||||
|
||||
describe('LlmConnectionSection', () => {
|
||||
it('FE-COMP-LLM-001: defaults to the local provider with a base URL and without a key field', () => {
|
||||
it('FE-COMP-LLM-001: defaults to OpenAI with a key field and no endpoint of its own', () => {
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByText('AI parsing')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Local \(Ollama\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('http://localhost:11434')).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('API key')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('API key')).toBeInTheDocument();
|
||||
// The endpoint is instance configuration since #1772, so it has no field here.
|
||||
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('A self-hosted (Ollama) endpoint is set up once for the whole instance in the admin settings. You can still use your own OpenAI or Anthropic key here.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LLM-002: hydrates provider, model, base URL and the multimodal toggle', () => {
|
||||
seedLlm({
|
||||
llm_provider: 'openai',
|
||||
llm_model: 'gpt-4o-mini',
|
||||
llm_base_url: 'https://api.openai.com/v1',
|
||||
llm_multimodal: true,
|
||||
});
|
||||
it('FE-COMP-LLM-002: hydrates provider, model and the multimodal toggle', () => {
|
||||
seedLlm({ llm_provider: 'openai', llm_model: 'gpt-4o-mini', llm_multimodal: true });
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('gpt-4o-mini')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('https://api.openai.com/v1')).toBeInTheDocument();
|
||||
expect(multimodalToggle()).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
@@ -74,7 +86,7 @@ describe('LlmConnectionSection', () => {
|
||||
});
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByRole('button', { name: /Local \(Ollama\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('claude-sonnet')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled();
|
||||
});
|
||||
@@ -84,7 +96,6 @@ describe('LlmConnectionSection', () => {
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByPlaceholderText('••••••••')).toHaveValue('');
|
||||
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LLM-005: without a stored key the field falls back to the plain label placeholder', () => {
|
||||
@@ -94,39 +105,38 @@ describe('LlmConnectionSection', () => {
|
||||
expect(screen.getByPlaceholderText('API key')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-COMP-LLM-006: picking Anthropic hides the base URL and reveals the key field', async () => {
|
||||
it('FE-COMP-LLM-006: the key field stays through a provider change, since both are hosted', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSection();
|
||||
|
||||
await pickProvider(user, /Local \(Ollama\)/, 'Anthropic');
|
||||
await pickProvider(user, /OpenAI/, 'Anthropic');
|
||||
|
||||
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Stored encrypted. Leave blank to keep the current key.')).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LLM-007: picking OpenAI keeps the base URL and adds the key field', async () => {
|
||||
it('FE-COMP-LLM-007: the provider list is exactly the two hosted ones', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSection();
|
||||
|
||||
await pickProvider(user, /Local \(Ollama\)/, 'OpenAI');
|
||||
await user.click(screen.getByRole('button', { name: /OpenAI/ }));
|
||||
|
||||
expect(screen.getByPlaceholderText('http://localhost:11434')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('API key')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Anthropic' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Local \(Ollama\)/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LLM-008: saving trims the inputs and omits the key when none was typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = seedLlm({ llm_model: '', llm_base_url: '' });
|
||||
const updateSettings = seedLlm({ llm_model: '' });
|
||||
renderSection();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('qwen3:8b'), ' qwen3:8b ');
|
||||
await user.type(screen.getByPlaceholderText('http://localhost:11434'), ' http://ollama.local ');
|
||||
await user.type(screen.getByPlaceholderText('qwen3:8b'), ' gpt-4o-mini ');
|
||||
await user.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({
|
||||
llm_provider: 'local',
|
||||
llm_model: 'qwen3:8b',
|
||||
llm_base_url: 'http://ollama.local',
|
||||
llm_provider: 'openai',
|
||||
llm_model: 'gpt-4o-mini',
|
||||
llm_base_url: '',
|
||||
llm_multimodal: false,
|
||||
});
|
||||
await screen.findByText('AI settings saved');
|
||||
@@ -144,14 +154,13 @@ describe('LlmConnectionSection', () => {
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('••••••••')).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('FE-COMP-LLM-010: a non-local provider never persists a base URL', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = seedLlm({ llm_provider: 'anthropic', llm_base_url: 'http://leftover' });
|
||||
it('FE-COMP-LLM-010: a stored local provider falls back to OpenAI without saving anything', () => {
|
||||
const updateSettings = seedLlm({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://192.168.1.5:11434' });
|
||||
renderSection();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ llm_base_url: '' }));
|
||||
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('http://192.168.1.5:11434')).not.toBeInTheDocument();
|
||||
expect(updateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-LLM-011: the multimodal toggle is part of the saved payload', async () => {
|
||||
@@ -193,4 +202,45 @@ describe('LlmConnectionSection', () => {
|
||||
release();
|
||||
await waitFor(() => expect(saveBtn).toBeEnabled());
|
||||
});
|
||||
|
||||
it('FE-COMP-LLM-014: saving clears a leftover base URL instead of resending it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = seedLlm({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://192.168.1.5:11434' });
|
||||
renderSection();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({
|
||||
llm_provider: 'openai',
|
||||
llm_model: 'nuextract',
|
||||
llm_base_url: '',
|
||||
llm_multimodal: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-LLM-015: a refused save pulls the stored settings back in', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedLlm({ llm_provider: 'anthropic' }, vi.fn().mockRejectedValue(new Error('Admin access required')));
|
||||
renderSection();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
|
||||
await screen.findByText('Could not save AI settings');
|
||||
expect(loadSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The point of #1772: an instance has one endpoint, so this surface is the same
|
||||
// for the person who runs it. The admin sets it on the addon, not here.
|
||||
it('FE-COMP-LLM-016: an admin sees the same two providers and no endpoint field', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedRole('admin');
|
||||
seedLlm({ llm_provider: 'local', llm_model: 'qwen3:8b', llm_base_url: 'http://localhost:11434' });
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /OpenAI/ }));
|
||||
expect(screen.queryByRole('button', { name: /Local \(Ollama\)/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Sparkles, Save } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -16,6 +16,14 @@ type Provider = NonNullable<Settings['llm_provider']>
|
||||
* instance-wide model on the addon — the server resolves the admin config first.
|
||||
* The API key is stored encrypted and never prefilled: a blank field keeps the
|
||||
* stored key (mirrors the AirTrail connection layout).
|
||||
*
|
||||
* A free-form endpoint does not live here at all (#1772): the request goes out
|
||||
* from the server, so only whoever runs the instance may name its target, and
|
||||
* an instance has exactly one such target. It is configured once on the addon
|
||||
* in the admin settings, including for the admin's own account. What is left
|
||||
* here are the two hosted providers, which go to a fixed address with the
|
||||
* user's own key. The server enforces this on both the read and the write path,
|
||||
* so this is only the matching surface.
|
||||
*/
|
||||
export default function LlmConnectionSection(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
@@ -23,28 +31,36 @@ export default function LlmConnectionSection(): React.ReactElement {
|
||||
const settings = useSettingsStore(s => s.settings)
|
||||
const isLoaded = useSettingsStore(s => s.isLoaded)
|
||||
const updateSettings = useSettingsStore(s => s.updateSettings)
|
||||
const loadSettings = useSettingsStore(s => s.loadSettings)
|
||||
|
||||
const [provider, setProvider] = useState<Provider>('local')
|
||||
const [provider, setProvider] = useState<Provider>('openai')
|
||||
const [model, setModel] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [multimodal, setMultimodal] = useState(false)
|
||||
const [hasStoredKey, setHasStoredKey] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Hydrate from the loaded settings. llm_api_key arrives masked, so we only use
|
||||
// its presence to drive the placeholder — never the value itself.
|
||||
// its presence to drive the placeholder, never the value itself. A stored
|
||||
// 'local' from before #1772 shows as OpenAI (local state only, nothing is
|
||||
// saved until Save is pressed) so the form never offers a value the server
|
||||
// would refuse.
|
||||
useEffect(() => {
|
||||
if (!isLoaded) return
|
||||
setProvider(settings.llm_provider || 'local')
|
||||
const stored = settings.llm_provider || 'openai'
|
||||
setProvider(stored === 'local' ? 'openai' : stored)
|
||||
setModel(settings.llm_model || '')
|
||||
setBaseUrl(settings.llm_base_url || '')
|
||||
setMultimodal(settings.llm_multimodal === true)
|
||||
setHasStoredKey(!!settings.llm_api_key)
|
||||
}, [isLoaded, settings.llm_provider, settings.llm_model, settings.llm_base_url, settings.llm_multimodal, settings.llm_api_key])
|
||||
}, [isLoaded, settings.llm_provider, settings.llm_model, settings.llm_multimodal, settings.llm_api_key])
|
||||
|
||||
const needsKey = provider !== 'local'
|
||||
const showBaseUrl = provider === 'local' || provider === 'openai'
|
||||
const providerOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'openai', label: t('settings.aiParsing.providerOpenai') },
|
||||
{ value: 'anthropic', label: t('settings.aiParsing.providerAnthropic') },
|
||||
],
|
||||
[t],
|
||||
)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
@@ -52,7 +68,9 @@ export default function LlmConnectionSection(): React.ReactElement {
|
||||
const payload: Partial<Settings> = {
|
||||
llm_provider: provider,
|
||||
llm_model: model.trim(),
|
||||
llm_base_url: showBaseUrl ? baseUrl.trim() : '',
|
||||
// Always cleared: the endpoint is instance configuration now, and this
|
||||
// also drops a value left over from before #1772.
|
||||
llm_base_url: '',
|
||||
llm_multimodal: multimodal,
|
||||
}
|
||||
// Send the key only when the user typed a new one — a blank field means
|
||||
@@ -64,6 +82,11 @@ export default function LlmConnectionSection(): React.ReactElement {
|
||||
if (key) setHasStoredKey(true)
|
||||
toast.success(t('settings.aiParsing.toast.saved'))
|
||||
} catch {
|
||||
// updateSettings patches the store before the request and keeps the patch
|
||||
// when the request fails, so a refused save (the 403 from #1772, or any
|
||||
// other error) would leave the form showing a value the server never
|
||||
// stored. Pull the stored settings back in so what is on screen is real.
|
||||
await loadSettings()
|
||||
toast.error(t('settings.aiParsing.toast.saveError'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
@@ -80,12 +103,9 @@ export default function LlmConnectionSection(): React.ReactElement {
|
||||
<CustomSelect
|
||||
value={provider}
|
||||
onChange={v => setProvider(v as Provider)}
|
||||
options={[
|
||||
{ value: 'local', label: t('settings.aiParsing.providerLocal') },
|
||||
{ value: 'openai', label: t('settings.aiParsing.providerOpenai') },
|
||||
{ value: 'anthropic', label: t('settings.aiParsing.providerAnthropic') },
|
||||
]}
|
||||
options={providerOptions}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.localAdminOnly')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -100,35 +120,20 @@ export default function LlmConnectionSection(): React.ReactElement {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showBaseUrl && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.baseUrl')}</label>
|
||||
<input
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
value={baseUrl}
|
||||
onChange={e => setBaseUrl(e.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.baseUrlHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsKey && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.apiKey')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={hasStoredKey && !apiKey ? '••••••••' : t('settings.aiParsing.apiKey')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.apiKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Both remaining providers are hosted and need a key, so this is no
|
||||
longer conditional (#1772). */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.apiKey')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={hasStoredKey && !apiKey ? '••••••••' : t('settings.aiParsing.apiKey')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.apiKeyHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
|
||||
import { useTranslation, isRtlLanguage } from '../../i18n/index.js';
|
||||
import { runNoticeAction } from './noticeActions.js';
|
||||
import { lockBodyScroll } from '../../utils/bodyScrollLock.js';
|
||||
|
||||
const ReactMarkdown = React.lazy(() =>
|
||||
import('react-markdown').then(m => ({ default: m.default }))
|
||||
@@ -504,15 +505,14 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [visible, idx, notices.length, canPage, language]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Body scroll lock
|
||||
// Body scroll lock. Ref-counted and keyed on a boolean, not on the notice
|
||||
// object: the old version cleared body.overflow on every run of this effect,
|
||||
// which since #1809 would let the page scroll away behind an open sheet.
|
||||
const locksScroll = visible && !!notice;
|
||||
useEffect(() => {
|
||||
if (visible && notice) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [visible, notice]);
|
||||
if (!locksScroll) return;
|
||||
return lockBodyScroll();
|
||||
}, [locksScroll]);
|
||||
|
||||
// Reset center slot scroll to top on navigation (keyboard / pager buttons).
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-VCYSET-001 to FE-COMP-VCYSET-014
|
||||
// FE-COMP-VCYSET-001 to FE-COMP-VCYSET-016
|
||||
// Covers the calendar editor / draft rows and the leave-year block of VacaySettings,
|
||||
// which VacaySettings.test.tsx only reaches at the surface.
|
||||
import React from 'react'
|
||||
@@ -144,7 +144,7 @@ describe('VacaySettings calendars', () => {
|
||||
expect(updateHolidayCalendar).toHaveBeenLastCalledWith(3, { region: 'NL' })
|
||||
|
||||
// The region list is only offered because the holiday data is county-split.
|
||||
await pickLoadedOption('Select region (optional)', 'Nordrhein-Westfalen')
|
||||
await pickLoadedOption('Select region (required)', 'Nordrhein-Westfalen')
|
||||
expect(updateHolidayCalendar).toHaveBeenLastCalledWith(3, { region: 'DE-NW' })
|
||||
})
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('VacaySettings calendars', () => {
|
||||
})
|
||||
const { container } = render(<VacaySettings onClose={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Select region (optional)' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Select region (required)' })).not.toBeInTheDocument()
|
||||
|
||||
const trash = trashButtons(container)[0]
|
||||
fireEvent.mouseEnter(trash)
|
||||
@@ -186,6 +186,9 @@ describe('VacaySettings calendars', () => {
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Label (optional)'), { target: { value: ' Feiertage ' } })
|
||||
await pickLoadedOption('Select country', 'Germany')
|
||||
// Germany has no regional holidays here, but the draft only unlocks once that
|
||||
// answer is in (see FE-COMP-VCYSET-015).
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
|
||||
await waitFor(() => expect(addHolidayCalendar).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -203,10 +206,10 @@ describe('VacaySettings calendars', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add calendar' }))
|
||||
await pickLoadedOption('Select country', 'Germany')
|
||||
|
||||
await screen.findByRole('button', { name: 'Select region (optional)' })
|
||||
await screen.findByRole('button', { name: 'Select region (required)' })
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled()
|
||||
|
||||
await pickLoadedOption('Select region (optional)', 'Bayern')
|
||||
await pickLoadedOption('Select region (required)', 'Bayern')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
|
||||
await waitFor(() => expect(addHolidayCalendar).toHaveBeenCalledWith(expect.objectContaining({ region: 'DE-BY' })))
|
||||
@@ -232,7 +235,7 @@ describe('VacaySettings calendars', () => {
|
||||
expect(screen.queryByRole('button', { name: 'United States' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Netherlands' }))
|
||||
|
||||
await pickLoadedOption('Select region (optional)', 'Northern Region')
|
||||
await pickLoadedOption('Select region (required)', 'Northern Region')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
|
||||
await waitFor(() => expect(addHolidayCalendar).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -321,4 +324,71 @@ describe('VacaySettings calendars', () => {
|
||||
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-COMP-VCYSET-015: the draft stays blocked while the region list is still loading', async () => {
|
||||
let release: (() => void) | undefined
|
||||
server.use(http.get('/api/addons/vacay/holidays/:year/:country', async () => {
|
||||
await new Promise<void>(resolve => { release = resolve })
|
||||
return HttpResponse.json([
|
||||
{ date: '2026-11-01', name: 'All Saints', localName: 'Allerheiligen', global: false, counties: ['DE-BY', 'DE-NW'] },
|
||||
])
|
||||
}))
|
||||
useVacayStore.setState({ plan: buildPlan({ holidays_enabled: true }), addHolidayCalendar: vi.fn(async () => {}) })
|
||||
render(<VacaySettings onClose={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add calendar' }))
|
||||
await pickLoadedOption('Select country', 'Germany')
|
||||
|
||||
// An empty list means "no answer yet", not "this country needs no region" (#1813).
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toHaveAttribute('aria-busy', 'true')
|
||||
|
||||
await waitFor(() => expect(release).toBeDefined())
|
||||
release?.()
|
||||
|
||||
await screen.findByRole('button', { name: 'Select region (required)' })
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toHaveAttribute('aria-busy', 'false')
|
||||
|
||||
await pickLoadedOption('Select region (required)', 'Bayern')
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-COMP-VCYSET-016: switching a calendar country drops the old region list at once', async () => {
|
||||
let releaseNl: (() => void) | undefined
|
||||
server.use(http.get('/api/addons/vacay/holidays/:year/:country', async ({ params }) => {
|
||||
if (params.country === 'NL') await new Promise<void>(resolve => { releaseNl = resolve })
|
||||
return HttpResponse.json([{
|
||||
date: '2026-11-01', name: 'All Saints', localName: 'Allerheiligen', global: false,
|
||||
counties: params.country === 'NL' ? ['NL-NH'] : ['DE-BY', 'DE-NW'],
|
||||
}])
|
||||
}))
|
||||
const updateHolidayCalendar = vi.fn(async (id: number, data: { region?: string }) => {
|
||||
const plan = useVacayStore.getState().plan
|
||||
if (!plan) return
|
||||
useVacayStore.setState({
|
||||
plan: { ...plan, holiday_calendars: (plan.holiday_calendars ?? []).map(c => (c.id === id ? { ...c, ...data } : c)) },
|
||||
})
|
||||
})
|
||||
useVacayStore.setState({
|
||||
plan: buildPlan({
|
||||
holidays_enabled: true,
|
||||
holiday_calendars: [{ id: 3, plan_id: 1, region: 'DE', label: null, color: '#fecaca', sort_order: 0 }],
|
||||
}),
|
||||
updateHolidayCalendar,
|
||||
})
|
||||
render(<VacaySettings onClose={vi.fn()} />)
|
||||
|
||||
await screen.findByRole('button', { name: 'Select region (required)' })
|
||||
await pickLoadedOption('Germany', 'Netherlands')
|
||||
expect(updateHolidayCalendar).toHaveBeenLastCalledWith(3, { region: 'NL' })
|
||||
|
||||
// The Dutch list is still on its way, so the German regions must be gone:
|
||||
// otherwise DE-BY can be saved for the Netherlands (#1813).
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Select region (required)' })).not.toBeInTheDocument())
|
||||
|
||||
await waitFor(() => expect(releaseNl).toBeDefined())
|
||||
releaseNl?.()
|
||||
await screen.findByRole('button', { name: 'Select region (required)' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays, CalendarRange, GraduationCap } from 'lucide-react'
|
||||
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Loader2, Plus, Trash2, CalendarDays, CalendarRange, GraduationCap } from 'lucide-react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { getIntlLanguage, useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -434,6 +434,30 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Region options for a holiday-calendar country.
|
||||
*
|
||||
* The loaded list is tagged with the country it belongs to, so switching country
|
||||
* drops the previous list in the same render instead of leaving it selectable while
|
||||
* the next request is still running (#1813: picking a German region for the
|
||||
* Netherlands was possible that way). `loadingRegions` tells "not answered yet"
|
||||
* apart from "this country has no regions"; on the list alone both are empty.
|
||||
*/
|
||||
function useRegionOptions(country: string, calendarType: 'public_holiday' | 'school_holiday') {
|
||||
const [loaded, setLoaded] = useState<{ country: string; options: { value: string; label: string }[] }>({ country: '', options: [] })
|
||||
|
||||
useEffect(() => {
|
||||
if (!country) return
|
||||
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
|
||||
let stale = false
|
||||
load(country).then(options => { if (!stale) setLoaded({ country, options }) })
|
||||
return () => { stale = true }
|
||||
}, [calendarType, country])
|
||||
|
||||
const ready = loaded.country === country
|
||||
return { regions: ready ? loaded.options : [], loadingRegions: Boolean(country) && !ready }
|
||||
}
|
||||
|
||||
// ── Existing calendar row (inline edit) ──────────────────────────────────────
|
||||
function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
|
||||
cal: VacayHolidayCalendar
|
||||
@@ -446,21 +470,15 @@ function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: {
|
||||
const { t } = useTranslation()
|
||||
const [localColor, setLocalColor] = useState(cal.color)
|
||||
const [localLabel, setLocalLabel] = useState(cal.label || '')
|
||||
const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
|
||||
|
||||
const [baseRegion] = cal.region.split('|')
|
||||
const selectedCountry = baseRegion.split('-')[0]
|
||||
const selectedRegion = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : ''
|
||||
const { regions } = useRegionOptions(selectedCountry, calendarType)
|
||||
|
||||
useEffect(() => { setLocalColor(cal.color) }, [cal.color])
|
||||
useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCountry) { setRegions([]); return }
|
||||
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
|
||||
load(selectedCountry).then(setRegions)
|
||||
}, [calendarType, selectedCountry])
|
||||
|
||||
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
|
||||
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||
|
||||
@@ -533,21 +551,16 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
|
||||
const [region, setRegion] = useState('')
|
||||
const [color, setColor] = useState(defaultColor)
|
||||
const [label, setLabel] = useState('')
|
||||
const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
|
||||
const [loadingRegions, setLoadingRegions] = useState(false)
|
||||
|
||||
const [baseRegion] = region.split('|')
|
||||
const selectedCountry = baseRegion.split('-')[0] || ''
|
||||
const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : ''
|
||||
const { regions, loadingRegions } = useRegionOptions(selectedCountry, calendarType)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCountry) { setRegions([]); return }
|
||||
setLoadingRegions(true)
|
||||
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
|
||||
load(selectedCountry).then(list => { setRegions(list) }).finally(() => setLoadingRegions(false))
|
||||
}, [calendarType, selectedCountry])
|
||||
|
||||
const canAdd = selectedCountry && (regions.length === 0 || selectedRegion !== '')
|
||||
// Adding is blocked while the region list is on its way: an empty list would
|
||||
// otherwise pass as "this country needs no region" and create a calendar that
|
||||
// draws nothing (#1813). The button shows a spinner so the wait is visible.
|
||||
const canAdd = Boolean(selectedCountry) && !loadingRegions && (regions.length === 0 || selectedRegion !== '')
|
||||
|
||||
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
|
||||
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||
@@ -579,7 +592,7 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
|
||||
/>
|
||||
<CustomSelect
|
||||
value={selectedCountry}
|
||||
onChange={v => { setRegion(String(v)); setRegions([]) }}
|
||||
onChange={v => setRegion(String(v))}
|
||||
options={countries}
|
||||
placeholder={t('vacay.selectCountry')}
|
||||
searchable
|
||||
@@ -596,9 +609,11 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo
|
||||
<div className="flex gap-1.5 pt-0.5">
|
||||
<button
|
||||
disabled={!canAdd}
|
||||
aria-busy={loadingRegions}
|
||||
onClick={() => onAdd({ region: region || selectedCountry, color, label: label.trim() || null })}
|
||||
className="flex-1 text-xs px-2 py-1.5 rounded-md font-medium transition-colors disabled:opacity-40 bg-content text-surface-card"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-md font-medium transition-colors disabled:opacity-40 bg-content text-surface-card"
|
||||
>
|
||||
{loadingRegions && <Loader2 size={12} className="animate-spin" />}
|
||||
{t('vacay.add')}
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import Modal from './Modal';
|
||||
import { lockBodyScroll, resetBodyScrollLock } from '../../utils/bodyScrollLock';
|
||||
|
||||
describe('Modal', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
onClose.mockClear();
|
||||
resetBodyScrollLock();
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
@@ -80,4 +82,18 @@ describe('Modal', () => {
|
||||
render(<Modal isOpen={true} onClose={onClose} />);
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
});
|
||||
|
||||
// #1809: the document is the scroller on a phone, so closing this modal must
|
||||
// not unlock the page while another overlay is still holding the lock.
|
||||
it('FE-COMP-MODAL-012: unmounting keeps a lock another overlay still holds', () => {
|
||||
const otherOverlay = lockBodyScroll();
|
||||
const { unmount } = render(<Modal isOpen={true} onClose={onClose} />);
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
unmount();
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
otherOverlay();
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { lockBodyScroll } from '../../utils/bodyScrollLock'
|
||||
|
||||
const sizeClasses: Record<string, string> = {
|
||||
sm: 'max-w-sm',
|
||||
@@ -39,16 +40,19 @@ export default function Modal({
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
if (!isOpen) return
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
return () => document.removeEventListener('keydown', handleEsc)
|
||||
}, [isOpen, handleEsc])
|
||||
|
||||
// Separate from the key listener so a new onClose identity does not release
|
||||
// and re-take the lock on every render. The shared lock is ref-counted: this
|
||||
// modal must not clear a lock another overlay is still holding (#1809).
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
return lockBodyScroll()
|
||||
}, [isOpen])
|
||||
|
||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@@ -5,6 +5,30 @@
|
||||
html { height: 100%; overflow: hidden; background-color: var(--bg-primary); }
|
||||
body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow-scrolling: touch; }
|
||||
|
||||
/* Phone (<768px): the document is the scroller, not body (#1809). iOS Safari
|
||||
only minimises its address bar in reaction to the root scroller moving, so a
|
||||
locked html keeps the bar at full height forever. `visible` on both, not
|
||||
`auto`: only then does body's overflow propagate to the viewport, which is
|
||||
what lets the shared body scroll lock stop the page behind an overlay.
|
||||
overscroll-behavior has to sit on html, because the value on body is never
|
||||
propagated to the viewport, and without it a pull at the top of the page
|
||||
reloads the installed PWA. Desktop/tablet keep the rules above. */
|
||||
@media (max-width: 767px) {
|
||||
html { overflow: visible; overscroll-behavior: none; }
|
||||
body { height: auto; overflow: visible; }
|
||||
/* Transparent so the shell's own background layer, which sits behind the page
|
||||
on a negative z-index, is not painted over: a negative layer goes above the
|
||||
canvas but below the background of ordinary blocks, and body is one. The
|
||||
alternative, lifting the layer into the positioned range, would have turned
|
||||
the content wrapper into a stacking context and trapped every fixed overlay
|
||||
inside it (the vacay FAB ended up under the dock that way).
|
||||
|
||||
`html body` and not `body`: a media query adds no specificity, and the
|
||||
themed `body` rule further down this file would otherwise win on source
|
||||
order alone and paint over the layer again. */
|
||||
html body { background-color: transparent; }
|
||||
}
|
||||
|
||||
|
||||
/* Journey desktop feed: hide scrollbar (right column is a sticky map, a
|
||||
visible scrollbar on the left breaks the polarsteps-style reading feel). */
|
||||
|
||||
@@ -18,9 +18,13 @@ interface MobileShellProps {
|
||||
* mobile toast presenter. From 768px up it renders the exact legacy wrapper
|
||||
* unchanged. Both branches share the div > div > children shape so pages keep
|
||||
* their state when the viewport crosses the breakpoint (rotation,
|
||||
* split-screen) instead of remounting. The content area keeps the exact
|
||||
* scroll semantics of the legacy ProtectedRoute wrapper, so not-yet-migrated
|
||||
* pages render unchanged inside.
|
||||
* split-screen) instead of remounting.
|
||||
*
|
||||
* On the phone the shell deliberately owns no scroll container (#1809): the
|
||||
* document is the scroller, because that is the only movement iOS Safari
|
||||
* minimises its address bar for. Screens that want a full-viewport layout
|
||||
* (map screens, the trip planner) set their own height instead of borrowing
|
||||
* one from here.
|
||||
*/
|
||||
export default function MobileShell({ isPhone, children }: MobileShellProps) {
|
||||
// The trip planner (/trips/:id) is a full-screen takeover that ships its own
|
||||
@@ -39,8 +43,23 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="m-root flex h-dvh min-h-dvh flex-col bg-[color:var(--m-bg)] bg-[image:var(--m-scr)] text-m-ink">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
|
||||
// min-h-svh, not min-h-dvh: dvh grows while Safari's toolbar retracts, so a
|
||||
// dvh page length would chase the animation it just triggered.
|
||||
<div className="m-root flex min-h-svh flex-col text-m-ink">
|
||||
{/* Background and screen gradient live on their own viewport-sized layer.
|
||||
The shell root is as tall as the document now, and --m-scr is a radial
|
||||
gradient measured against its element box, so on it the light cone would
|
||||
be stretched over the whole scroll length.
|
||||
|
||||
It stays on a NEGATIVE z-index, and the content wrapper stays unpositioned
|
||||
on purpose. Lifting the layer into the positioned range means the content
|
||||
has to be positioned too, which makes it a stacking context and clamps
|
||||
every fixed overlay a screen renders inside it: the vacay view/edit FAB
|
||||
(z-50) ended up underneath the dock (z-40) that way. For the negative
|
||||
layer to be visible, body must not paint over it, which index.css handles
|
||||
for this breakpoint. */}
|
||||
<div aria-hidden className="pointer-events-none fixed inset-0 -z-10 bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]" />
|
||||
<div className="flex-1">{children}</div>
|
||||
{!inTripPlanner && <ErrorBoundary boundaryId="chrome:m-bottom-nav" fallback={null}><MBottomNav /></ErrorBoundary>}
|
||||
<ErrorBoundary boundaryId="chrome:m-toast" fallback={null}><MToastHost /></ErrorBoundary>
|
||||
<div id="m-sheet-root" />
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-007
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router')>('react-router');
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import MBottomNav from './MBottomNav';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
|
||||
|
||||
const withCollections = () =>
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled: true }],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
mockNavigate.mockClear();
|
||||
sessionStorage.clear();
|
||||
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||
});
|
||||
|
||||
describe('MBottomNav', () => {
|
||||
it('FE-COMP-MBOTTOMNAV-001: the dock "+" creates a trip on an unclaimed route', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MBottomNav />, { initialEntries: ['/dashboard'] });
|
||||
await user.click(screen.getByRole('button', { name: 'New Trip' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/dashboard?create=1');
|
||||
});
|
||||
|
||||
it('FE-COMP-MBOTTOMNAV-002: on the collections overview the "+" adds a place', async () => {
|
||||
const user = userEvent.setup();
|
||||
withCollections();
|
||||
render(<MBottomNav />, { initialEntries: ['/collections'] });
|
||||
await user.click(screen.getByRole('button', { name: 'Add a place' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/collections?create=place');
|
||||
});
|
||||
|
||||
// #1930: picking a list moves the route to /collections/:id, which the exact
|
||||
// match missed — the "+" then created a trip instead of adding a place.
|
||||
it('FE-COMP-MBOTTOMNAV-003: inside a collection the "+" adds a place, not a trip', async () => {
|
||||
const user = userEvent.setup();
|
||||
withCollections();
|
||||
render(<MBottomNav />, { initialEntries: ['/collections/7'] });
|
||||
await user.click(screen.getByRole('button', { name: 'Add a place' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/collections/7?create=place');
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('/dashboard?create=1');
|
||||
});
|
||||
|
||||
it('FE-COMP-MBOTTOMNAV-004: the handoff keeps the list the user is looking at', async () => {
|
||||
const user = userEvent.setup();
|
||||
withCollections();
|
||||
render(<MBottomNav />, { initialEntries: ['/collections/123'] });
|
||||
await user.click(screen.getByRole('button', { name: 'Add a place' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/collections/123?create=place');
|
||||
});
|
||||
|
||||
it('FE-COMP-MBOTTOMNAV-005: on the journey list the "+" starts a journey', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'journey', name: 'Journey', type: 'global', icon: 'compass', enabled: true }],
|
||||
});
|
||||
render(<MBottomNav />, { initialEntries: ['/journey'] });
|
||||
await user.click(screen.getByRole('button', { name: 'New Journey' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/journey?create=1');
|
||||
});
|
||||
|
||||
it('FE-COMP-MBOTTOMNAV-006: inside a journey the "+" adds an entry', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'journey', name: 'Journey', type: 'global', icon: 'compass', enabled: true }],
|
||||
});
|
||||
render(<MBottomNav />, { initialEntries: ['/journey/5'] });
|
||||
await user.click(screen.getByRole('button', { name: 'Add Entry' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/journey/5?create=entry');
|
||||
});
|
||||
|
||||
it('FE-COMP-MBOTTOMNAV-007: /vacay yields the centre slot to the screen (#1811)', () => {
|
||||
render(<MBottomNav />, { initialEntries: ['/vacay'] });
|
||||
expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,8 @@ interface NavItem { to: string; label: string; icon: LucideIcon }
|
||||
// into yet), inside a journey it adds an entry, on the atlas it opens the
|
||||
// country search, on collections it adds a place to the active list —
|
||||
// everywhere else it creates a new trip. Pages pick the intent up from the
|
||||
// query params.
|
||||
// query params. The result is unused on /vacay: that screen draws its own centre
|
||||
// FAB, so the dock yields the slot instead (see screenFabSlot below, #1811).
|
||||
function useCreateAction(): { label: string; run: () => void } {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
@@ -25,6 +26,7 @@ function useCreateAction(): { label: string; run: () => void } {
|
||||
const onJourneyList = useMatch('/journey')
|
||||
const onAtlas = useMatch('/atlas')
|
||||
const onCollections = useMatch('/collections')
|
||||
const inCollection = useMatch('/collections/:id')
|
||||
|
||||
if (inTrip) {
|
||||
// The "+" is context-aware per active tab: Bookings → reservation,
|
||||
@@ -46,8 +48,14 @@ function useCreateAction(): { label: string; run: () => void } {
|
||||
if (onAtlas) {
|
||||
return { label: t('atlas.searchCountry'), run: () => navigate('/atlas?search=1') }
|
||||
}
|
||||
if (onCollections) {
|
||||
return { label: t('collections.addPlace'), run: () => navigate('/collections?create=place') }
|
||||
if (onCollections || inCollection) {
|
||||
// Picking a list moves the route to /collections/:id, so the exact match
|
||||
// alone dropped the "+" through to creating a trip — the one state the
|
||||
// screen is normally used in (#1930). The handoff keeps the id, otherwise
|
||||
// adding would land on "All saved" and the sheet would ask for the list the
|
||||
// user is already looking at.
|
||||
const path = inCollection ? `/collections/${inCollection.params.id}` : '/collections'
|
||||
return { label: t('collections.addPlace'), run: () => navigate(`${path}?create=place`) }
|
||||
}
|
||||
return { label: t('dashboard.newTrip'), run: () => navigate('/dashboard?create=1') }
|
||||
}
|
||||
@@ -87,6 +95,11 @@ export default function MBottomNav() {
|
||||
// action (settings/admin, demo Z. 1372/1429).
|
||||
const logoSlot = location.pathname.startsWith('/settings') || location.pathname.startsWith('/admin')
|
||||
const searchFab = location.pathname.startsWith('/atlas')
|
||||
// /vacay owns the centre slot itself: MVacay draws its year/edit toggle into
|
||||
// exactly this 56px circle. The dock keeps the geometry but stays empty there,
|
||||
// so the generic "+" can never sit underneath as a second, different action:
|
||||
// not while the lazy screen chunk loads, and not while its data loads (#1811).
|
||||
const screenFabSlot = location.pathname.startsWith('/vacay')
|
||||
|
||||
// Split so the raised centre slot sits dead centre; the More slot always
|
||||
// closes the right group.
|
||||
@@ -154,6 +167,10 @@ export default function MBottomNav() {
|
||||
<img src="/icons/icon-dark.svg" alt="" className="block h-6 w-6 opacity-75 dark:hidden" />
|
||||
<img src="/icons/icon-white.svg" alt="" className="hidden h-6 w-6 opacity-75 dark:block" />
|
||||
</span>
|
||||
) : screenFabSlot ? (
|
||||
// Same box as MFab (56px, flex-none, mx-2) so both tab groups keep
|
||||
// sitting symmetrically around the centre in every dock configuration.
|
||||
<span aria-hidden="true" className="mx-2 h-14 w-14 flex-none" />
|
||||
) : (
|
||||
<MFab onClick={create.run} ariaLabel={create.label} className="mx-2">
|
||||
{searchFab ? <Search size={24} strokeWidth={2.4} /> : <Plus size={26} strokeWidth={2.4} />}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { lockBodyScroll } from '../../utils/bodyScrollLock'
|
||||
|
||||
export type MSheetVariant = 'card' | 'bottom' | 'drawer'
|
||||
export type MSheetMaterial = 'glass' | 'bar-glass' | 'opaque'
|
||||
@@ -54,24 +55,6 @@ function sheetRoot(): HTMLElement {
|
||||
return document.getElementById('m-sheet-root') ?? document.body
|
||||
}
|
||||
|
||||
// Body scroll lock shared across stacked sheets: only the first lock saves the
|
||||
// original overflow and only the last unlock restores it.
|
||||
let scrollLocks = 0
|
||||
let savedBodyOverflow = ''
|
||||
|
||||
function lockBodyScroll() {
|
||||
if (scrollLocks === 0) {
|
||||
savedBodyOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
scrollLocks++
|
||||
}
|
||||
|
||||
function unlockBodyScroll() {
|
||||
scrollLocks = Math.max(0, scrollLocks - 1)
|
||||
if (scrollLocks === 0) document.body.style.overflow = savedBodyOverflow
|
||||
}
|
||||
|
||||
/**
|
||||
* The one sheet primitive of the mobile design system. Handles portal,
|
||||
* scrim, ESC/backdrop dismissal, body scroll lock, focus trapping, 280ms
|
||||
@@ -118,8 +101,7 @@ export default function MSheet({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
lockBodyScroll()
|
||||
return unlockBodyScroll
|
||||
return lockBodyScroll()
|
||||
}, [open])
|
||||
|
||||
// Move focus into the dialog on open, hand it back on close.
|
||||
|
||||
@@ -105,7 +105,9 @@ export default function MAdmin() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto px-4 pb-[calc(var(--bottom-nav-h,84px)+16px)] pt-[var(--m-safe-top,12px)]">
|
||||
// Flow screen: scrolls with the document (#1809), so no height and no
|
||||
// scroll container of its own.
|
||||
<div className="px-4 pb-[calc(var(--bottom-nav-h,84px)+16px)] pt-[var(--m-safe-top,12px)]">
|
||||
{/* Header: back · section switcher · create user */}
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<button
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function MAtlas() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div className="relative h-dvh">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-ink" />
|
||||
</div>
|
||||
@@ -98,7 +98,9 @@ export default function MAtlas() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-hidden">
|
||||
// h-dvh, not h-full: the shell stopped providing a definite height (#1809)
|
||||
// and a map on a percentage of an auto-height parent collapses to zero.
|
||||
<div className="relative h-dvh overflow-hidden">
|
||||
<div ref={mapRef} className="absolute inset-0 z-[1] bg-[color:var(--m-mapb)] [&_.leaflet-control-zoom]:hidden" />
|
||||
{/* Region hover tooltip — positioned imperatively by useAtlas. */}
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// FE-COMP-MATLASPOPUP-001 to FE-COMP-MATLASPOPUP-004
|
||||
// FE-COMP-MATLASPOPUP-001 to FE-COMP-MATLASPOPUP-006
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../../tests/helpers/render'
|
||||
import { server } from '../../../../tests/helpers/msw/server'
|
||||
import MAtlasCountryPopup from './MAtlasCountryPopup'
|
||||
import type { AtlasController } from './atlasController'
|
||||
|
||||
@@ -27,6 +29,10 @@ function makeAtlas(overrides: Record<string, unknown> = {}): AtlasController {
|
||||
} as unknown as AtlasController
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast
|
||||
})
|
||||
|
||||
describe('MAtlasCountryPopup', () => {
|
||||
it('FE-COMP-MATLASPOPUP-001: offers the wishlist removal for a country on the bucket list', () => {
|
||||
const atlas = makeAtlas({ bucketList: [bucketItem(42, 'Paris', 'FR')] })
|
||||
@@ -73,4 +79,53 @@ describe('MAtlasCountryPopup', () => {
|
||||
expect(screen.getByText('atlas.markVisited')).toBeInTheDocument()
|
||||
expect(screen.queryByText('atlas.removeFromBucket')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-MATLASPOPUP-005: adding a country that is already wishlisted for that date posts nothing (#1898)', async () => {
|
||||
const post = vi.fn()
|
||||
server.use(http.post('/api/addons/atlas/bucket-list', () => { post(); return HttpResponse.json({ item: { id: 9 } }) }))
|
||||
const addToast = vi.fn()
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast
|
||||
const setBucketList = vi.fn()
|
||||
const setConfirmAction = vi.fn()
|
||||
const atlas = makeAtlas({
|
||||
confirmAction: { code: 'FR', name: 'France', type: 'bucket' },
|
||||
bucketList: [bucketItem(42, 'France', 'FR')],
|
||||
setBucketList,
|
||||
setConfirmAction,
|
||||
})
|
||||
render(<MAtlasCountryPopup atlas={atlas} />)
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.addToBucket'))
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('atlas.bucketDuplicate', 'error', undefined))
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
expect(setBucketList).not.toHaveBeenCalled()
|
||||
// The sheet stays open so a target month can be picked instead.
|
||||
expect(setConfirmAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-MATLASPOPUP-006: the same country for another target date still goes out (#1898)', async () => {
|
||||
let body: unknown = null
|
||||
server.use(http.post('/api/addons/atlas/bucket-list', async ({ request }) => {
|
||||
body = await request.json()
|
||||
return HttpResponse.json({ item: { id: 9 } })
|
||||
}))
|
||||
const setBucketList = vi.fn()
|
||||
const setConfirmAction = vi.fn()
|
||||
const atlas = makeAtlas({
|
||||
confirmAction: { code: 'FR', name: 'France', type: 'bucket' },
|
||||
bucketList: [bucketItem(42, 'France', 'FR')],
|
||||
setBucketList,
|
||||
setConfirmAction,
|
||||
})
|
||||
render(<MAtlasCountryPopup atlas={atlas} />)
|
||||
|
||||
const monthInput = document.querySelector('input[type="month"]') as HTMLInputElement
|
||||
fireEvent.change(monthInput, { target: { value: '2027-05' } })
|
||||
fireEvent.click(screen.getByText('atlas.addToBucket'))
|
||||
|
||||
await waitFor(() => expect(setConfirmAction).toHaveBeenCalledWith(null))
|
||||
expect(body).toEqual({ name: 'France', country_code: 'FR', target_date: '2027-05' })
|
||||
expect(setBucketList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ChevronRight, MapPin, Star, Trash2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import apiClient from '../../../api/client'
|
||||
import { continentForCountry } from '@trek/shared'
|
||||
import { withCountryMarkedVisited } from '../../../pages/atlas/atlasModel'
|
||||
import { findBucketDuplicate, isBucketDuplicateError, withCountryMarkedVisited } from '../../../pages/atlas/atlasModel'
|
||||
import { getApiErrorMessage } from '../../../types'
|
||||
import { useToast } from '../../../components/shared/Toast'
|
||||
import MSheet from '../../components/MSheet'
|
||||
@@ -145,21 +145,33 @@ export default function MAtlasCountryPopup({ atlas }: MAtlasCountryPopupProps) {
|
||||
|
||||
const addBucket = async (): Promise<void> => {
|
||||
if (!confirmAction) return
|
||||
const targetDate = bucketDate || null
|
||||
// #1898: one entry per target date. The sheet stays open on a duplicate so
|
||||
// another month can be picked right away.
|
||||
if (findBucketDuplicate(bucketList, { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate, lat: null, lng: null })) {
|
||||
toast.error(t('atlas.bucketDuplicate'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await apiClient.post('/addons/atlas/bucket-list', {
|
||||
name: confirmAction.name,
|
||||
country_code: confirmAction.code,
|
||||
target_date: bucketDate || null,
|
||||
target_date: targetDate,
|
||||
})
|
||||
setBucketList((prev) => [r.data.item, ...prev])
|
||||
} catch (err) {
|
||||
if (isBucketDuplicateError(err)) {
|
||||
toast.error(t('atlas.bucketDuplicate'))
|
||||
return
|
||||
}
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
setConfirmAction(null)
|
||||
}
|
||||
|
||||
// A country can sit on the bucket list more than once (one entry per place),
|
||||
// so taking it off the wishlist means dropping every entry for that code.
|
||||
// A country can sit on the bucket list more than once (one entry per place and
|
||||
// target date), so taking it off the wishlist means dropping every entry for
|
||||
// that code.
|
||||
const removeBucket = async (): Promise<void> => {
|
||||
if (!confirmAction) return
|
||||
const wishlistItems = bucketList.filter((b) => b.country_code === confirmAction.code)
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [picked, setPicked] = useState<MapsPlace | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [address, setAddress] = useState('')
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [status, setStatus] = useState<CollectionStatus>('idea')
|
||||
const [description, setDescription] = useState('')
|
||||
@@ -50,7 +51,7 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
|
||||
|
||||
useEffect(() => {
|
||||
if (open) return
|
||||
setQuery(''); setResults([]); setPicked(null); setName(''); setCategoryId(null); setStatus('idea'); setDescription('')
|
||||
setQuery(''); setResults([]); setPicked(null); setName(''); setAddress(''); setCategoryId(null); setStatus('idea'); setDescription('')
|
||||
setTargetId(null)
|
||||
}, [open])
|
||||
|
||||
@@ -80,6 +81,7 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
|
||||
const pick = (r: MapsPlace) => {
|
||||
setPicked(r)
|
||||
setName(str(r.name) ?? '')
|
||||
setAddress(str(r.address) ?? '')
|
||||
setResults([])
|
||||
setQuery(str(r.name) ?? query)
|
||||
}
|
||||
@@ -92,7 +94,7 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
|
||||
const res = await collectionsApi.savePlace({
|
||||
collection_id: targetId,
|
||||
name: cleanName,
|
||||
address: (picked && str(picked.address)) ?? null,
|
||||
address: address.trim() || null,
|
||||
lat: (picked && num(picked.lat)) ?? null,
|
||||
lng: (picked && num(picked.lng)) ?? null,
|
||||
google_place_id: (picked && str(picked.google_place_id)) ?? null,
|
||||
@@ -118,8 +120,6 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
|
||||
}
|
||||
}
|
||||
|
||||
const address = picked ? str(picked.address) : undefined
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} material="opaque" ariaLabel={t('collections.addPlace')}>
|
||||
<SheetHeader title={t('collections.addPlace')} onClose={onClose} closeLabel={t('common.close')} />
|
||||
@@ -197,11 +197,11 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
|
||||
|
||||
<Eyebrow className="mb-[6px] mt-4">{t('common.name').toUpperCase()}</Eyebrow>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('common.name')} className={INPUT_CLS} />
|
||||
{address && (
|
||||
<div className="mt-[6px] flex items-center gap-[5px] font-geist text-[0.6875rem] text-m-muted">
|
||||
<MapPin size={11} strokeWidth={2.2} className="flex-none" /> <span className="truncate">{address}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Address (#1870): a picked hit fills it, but it stays editable so a
|
||||
hand-typed place can carry one too (the desktop dialog always could). */}
|
||||
<Eyebrow className="mb-[6px] mt-[14px]">{t('places.formAddress').toUpperCase()}</Eyebrow>
|
||||
<input value={address} onChange={e => setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className={INPUT_CLS} />
|
||||
|
||||
<Eyebrow className="mb-[6px] mt-[14px]">{t('collections.category').toUpperCase()}</Eyebrow>
|
||||
<MCollCategoryPicker categories={categories} value={categoryId} onChange={setCategoryId} t={t} />
|
||||
|
||||
@@ -35,7 +35,7 @@ interface MCollPlaceSheetProps {
|
||||
labels: CollectionLabel[]
|
||||
onClose: () => void
|
||||
onSetStatus: (status: CollectionStatus) => void
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null }) => Promise<void>
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null; address?: string | null }) => Promise<void>
|
||||
onUploadImage?: (file: File) => Promise<void>
|
||||
onCopyToTrip: () => void
|
||||
onRemove: () => void
|
||||
@@ -62,6 +62,7 @@ export default function MCollPlaceSheet({
|
||||
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [address, setAddress] = useState('')
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [description, setDescription] = useState('')
|
||||
const [links, setLinks] = useState<CollectionLink[]>([])
|
||||
@@ -77,6 +78,7 @@ export default function MCollPlaceSheet({
|
||||
seededId.current = held.id
|
||||
setEditing(false)
|
||||
setName(held.name)
|
||||
setAddress(held.address ?? '')
|
||||
setCategoryId(held.category_id ?? null)
|
||||
setDescription(held.description ?? '')
|
||||
setLinks(held.links ?? [])
|
||||
@@ -97,7 +99,7 @@ export default function MCollPlaceSheet({
|
||||
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({ name: name.trim() || held.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds })
|
||||
await onSave({ name: name.trim() || held.name, address: address.trim() || null, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds })
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
@@ -110,6 +112,7 @@ export default function MCollPlaceSheet({
|
||||
if (!held) return
|
||||
setEditing(false)
|
||||
setName(held.name)
|
||||
setAddress(held.address ?? '')
|
||||
setCategoryId(held.category_id ?? null)
|
||||
setDescription(held.description ?? '')
|
||||
setLinks(held.links ?? [])
|
||||
@@ -245,6 +248,9 @@ export default function MCollPlaceSheet({
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-4">{t('common.name').toUpperCase()}</Eyebrow>
|
||||
<input value={name} onChange={e => setName(e.target.value)} className={INPUT_CLS} />
|
||||
{/* Address (#1870): free text, same as the add sheet offers */}
|
||||
<Eyebrow className="mb-[6px] mt-[14px]">{t('places.formAddress').toUpperCase()}</Eyebrow>
|
||||
<input value={address} onChange={e => setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className={INPUT_CLS} />
|
||||
<Eyebrow className="mb-[6px] mt-[14px]">{t('collections.category').toUpperCase()}</Eyebrow>
|
||||
<MCollCategoryPicker categories={categories} value={categoryId} onChange={setCategoryId} t={t} />
|
||||
{labels.length > 0 && (
|
||||
|
||||
@@ -27,8 +27,13 @@ import { CancelPill, PrimaryPill } from './MCollSheetKit'
|
||||
const HEADER_CIRCLE =
|
||||
'flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]'
|
||||
|
||||
// min-w-0 is what lets the truncate on the chip labels engage: a flex item keeps
|
||||
// min-width:auto by default, so the row grew past the panel instead of shrinking
|
||||
// its chips. The labels are short enough to fit in every locale now, but a long
|
||||
// label name in the filter chip, or a bumped-up text size, still has to ellipsise
|
||||
// inside the panel rather than spill out of it.
|
||||
const FILTER_CHIP =
|
||||
'flex flex-1 items-center justify-center gap-[5px] rounded-full py-2 text-[0.75rem] font-semibold'
|
||||
'flex min-w-0 flex-1 items-center justify-center gap-[5px] rounded-full py-2 text-[0.75rem] font-semibold'
|
||||
|
||||
const CHIP_IDLE = 'border border-[color:var(--m-rowbr)] bg-m-sheetop text-m-ink'
|
||||
const CHIP_ON = 'border border-transparent bg-m-act text-m-actfg'
|
||||
@@ -273,7 +278,7 @@ export default function MCollections() {
|
||||
{canManageLabels && (
|
||||
<button type="button" onClick={() => c.setShowLabelManager(true)} className={`${FILTER_CHIP} ${CHIP_IDLE}`}>
|
||||
<Plus size={12} strokeWidth={2.2} className="flex-none" />
|
||||
<span className="truncate">{t('collections.linkLabel')}</span>
|
||||
<span className="truncate">{t('collections.labels.title')}</span>
|
||||
</button>
|
||||
)}
|
||||
{showSelect && (
|
||||
|
||||
@@ -214,8 +214,8 @@ export default function MDashboard(): React.ReactElement {
|
||||
<MGlassBar floating>
|
||||
<button
|
||||
type="button"
|
||||
// Content scrolls in the shell's inner container, not the window.
|
||||
onClick={e => { e.currentTarget.closest('.overflow-y-auto')?.scrollTo({ top: 0, behavior: 'smooth' }) }}
|
||||
// The page itself is the scroller since #1809, no inner container to walk up to.
|
||||
onClick={() => { window.scrollTo({ top: 0, behavior: 'smooth' }) }}
|
||||
aria-label="TREK"
|
||||
className="flex flex-none items-center gap-[7px]"
|
||||
>
|
||||
|
||||
@@ -27,7 +27,10 @@ export default function MJourney() {
|
||||
const rest = list.filter(j => j.id !== hero?.id)
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
// h-dvh, not h-full: the shell stopped providing a definite height (#1809).
|
||||
// The feed below keeps its own scroller, so this screen does not move the
|
||||
// document and Safari's address bar stays put here.
|
||||
<div className="relative h-dvh">
|
||||
{/* Floating header: back + create pill */}
|
||||
<div className="fixed left-4 right-4 top-[var(--m-safe-top,12px)] z-30 flex items-center gap-[10px]">
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronLeft, MapPin, Grid3x3, Upload, MoreHorizontal, Play, Image, Camera } from 'lucide-react'
|
||||
import { ChevronLeft, MapPin, Grid3x3, Upload, MoreHorizontal, Play, Image, Camera, Download, EyeOff, Settings2 } from 'lucide-react'
|
||||
import JourneyMap from '../../../components/Journey/JourneyMapAuto'
|
||||
import type { JourneyMapAutoHandle } from '../../../components/Journey/JourneyMapAuto'
|
||||
import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
|
||||
@@ -18,6 +18,7 @@ import { getApiErrorMessage } from '../../../types'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MDancingTrek from '../../components/MDancingTrek'
|
||||
import MListRow from '../../components/MListRow'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import MJourneyEntryCard from './MJourneyEntryCard'
|
||||
import MJourneyEntrySheet from './MJourneyEntrySheet'
|
||||
import MJourneySettingsSheet from './MJourneySettingsSheet'
|
||||
@@ -29,7 +30,7 @@ import MJourneySettingsSheet from './MJourneySettingsSheet'
|
||||
*/
|
||||
export default function MJourneyDetail() {
|
||||
const {
|
||||
id, navigate, toast, t,
|
||||
id, navigate, toast, t, locale,
|
||||
current, loading,
|
||||
canEditEntries, canEditJourney,
|
||||
view, setView,
|
||||
@@ -37,7 +38,7 @@ export default function MJourneyDetail() {
|
||||
lightbox, setLightbox, deleteTarget, setDeleteTarget,
|
||||
showInvite, setShowInvite,
|
||||
showSettings, setShowSettings,
|
||||
hideSkeletons,
|
||||
hideSkeletons, setHideSkeletons,
|
||||
sidebarMapItems, tracks,
|
||||
loadJourney, updateEntry, deleteEntry, uploadPhotos,
|
||||
} = useJourneyDetail()
|
||||
@@ -129,6 +130,7 @@ export default function MJourneyDetail() {
|
||||
const galleryFileRef = useRef<HTMLInputElement>(null)
|
||||
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
||||
const [showUploadMenu, setShowUploadMenu] = useState(false)
|
||||
const [showActionMenu, setShowActionMenu] = useState(false)
|
||||
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
@@ -192,7 +194,7 @@ export default function MJourneyDetail() {
|
||||
|
||||
if (loading || !current) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex h-dvh items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-ink" />
|
||||
</div>
|
||||
)
|
||||
@@ -201,8 +203,27 @@ export default function MJourneyDetail() {
|
||||
const gallery = current.gallery || []
|
||||
const dark = document.documentElement.classList.contains('dark')
|
||||
|
||||
// The book export and the suggestions switch used to be desktop-only (#1848).
|
||||
// They live in the header overflow sheet here, together with the settings
|
||||
// entry, so the header keeps a single "more" affordance.
|
||||
const exportBook = async () => {
|
||||
setShowActionMenu(false)
|
||||
const pdf = await import('../../../components/PDF/JourneyBookPDF')
|
||||
await pdf.downloadJourneyBookPDF(current, { t, locale })
|
||||
}
|
||||
const toggleSkeletons = async (next: boolean) => {
|
||||
setHideSkeletons(next)
|
||||
try {
|
||||
await journeyApi.updatePreferences(current.id, { hide_skeletons: next })
|
||||
} catch {
|
||||
/* cosmetic preference — the local flip stands until the next load */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-hidden">
|
||||
// h-dvh, not h-full: the shell stopped providing a definite height (#1809)
|
||||
// and a map on a percentage of an auto-height parent collapses to zero.
|
||||
<div className="relative h-dvh overflow-hidden">
|
||||
{/* Integrated map — always mounted, the gallery overlays it */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<JourneyMap
|
||||
@@ -255,7 +276,7 @@ export default function MJourneyDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header: back — segment — upload/settings */}
|
||||
{/* Header: back — segment — upload / overflow menu */}
|
||||
<div className="absolute left-4 right-4 top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -303,16 +324,14 @@ export default function MJourneyDetail() {
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{view === 'timeline' && canEditJourney && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSettings(true)}
|
||||
aria-label={t('journey.settings.title')}
|
||||
className="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
|
||||
>
|
||||
<MoreHorizontal size={17} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowActionMenu(true)}
|
||||
aria-label={t('files.menu')}
|
||||
className="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
|
||||
>
|
||||
<MoreHorizontal size={17} strokeWidth={2} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -356,6 +375,25 @@ export default function MJourneyDetail() {
|
||||
</div>
|
||||
</MSheet>
|
||||
|
||||
{/* Journey actions: book export, suggestions switch, settings */}
|
||||
<MSheet open={showActionMenu} onClose={() => setShowActionMenu(false)} variant="bottom" ariaLabel={t('files.menu')}>
|
||||
<div className="flex flex-col gap-1 p-[10px]">
|
||||
<MListRow icon={Download} label={t('journey.pdf.saveAsPdf')} onClick={exportBook} />
|
||||
<div className="flex items-center gap-[11px] px-[10px] py-[11px]">
|
||||
<EyeOff size={16} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<span className="min-w-0 flex-1 truncate text-[0.84375rem] font-semibold">{t('journey.skeletons.hide')}</span>
|
||||
<MToggle checked={hideSkeletons} onChange={toggleSkeletons} ariaLabel={t('journey.skeletons.hide')} />
|
||||
</div>
|
||||
{canEditJourney && (
|
||||
<MListRow
|
||||
icon={Settings2}
|
||||
label={t('journey.settings.title')}
|
||||
onClick={() => { setShowActionMenu(false); setShowSettings(true) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</MSheet>
|
||||
|
||||
{/* Entry sheet (new / edit / read-only) */}
|
||||
{editingEntry && (
|
||||
<MJourneyEntrySheet
|
||||
@@ -363,18 +401,26 @@ export default function MJourneyDetail() {
|
||||
galleryPhotos={gallery}
|
||||
quickCapture={editingEntry.id === 0}
|
||||
readOnly={!canEditEntries}
|
||||
userId={useAuthStore.getState().user?.id || 0}
|
||||
trips={current.trips}
|
||||
onClose={() => setEditingEntry(null)}
|
||||
onSave={async data => {
|
||||
let entryId = editingEntry.id
|
||||
if (editingEntry.id === 0) {
|
||||
onSave={async (data, existingEntryId) => {
|
||||
// existingEntryId is what the sheet already persisted in an earlier
|
||||
// save attempt — without it a retry would create a second entry.
|
||||
const currentEntryId = existingEntryId ?? editingEntry.id
|
||||
let entryId = currentEntryId
|
||||
if (currentEntryId === 0) {
|
||||
const created = await useJourneyStore.getState().createEntry(current.id, data)
|
||||
entryId = created.id
|
||||
} else {
|
||||
await updateEntry(editingEntry.id, data)
|
||||
await updateEntry(currentEntryId, data)
|
||||
}
|
||||
return entryId
|
||||
}}
|
||||
onUploadPhotos={uploadPhotos}
|
||||
onAddProviderPhotos={async (entryId, group) => {
|
||||
await journeyApi.addProviderPhotos(entryId, group.provider, group.assetIds, undefined, group.passphrase, group.mediaTypes)
|
||||
}}
|
||||
onDelete={editingEntry.id > 0 && canEditEntries
|
||||
? () => { const target = editingEntry; setEditingEntry(null); setDeleteTarget(target) }
|
||||
: undefined}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Camera, Plus, Image, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
@@ -9,9 +9,11 @@ import { getApiErrorMessage } from '../../../types'
|
||||
import { normalizeImageFiles } from '../../../utils/convertHeic'
|
||||
import { getCurrentPositionOnce } from '../../../hooks/useGeolocation'
|
||||
import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
|
||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto } from '../../../store/journeyStore'
|
||||
import { photoUrl, geoOnceErrorKey } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../../store/journeyStore'
|
||||
import { useAddonStore } from '../../../store/addonStore'
|
||||
import { photoUrl, geoOnceErrorKey, isValidGeoPoint } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import JournalBody from '../../../components/Journey/JournalBody'
|
||||
import { ProviderPicker, type ProviderPhotoGroup } from '../../../components/Journey/JourneyDetailPageProviderPicker'
|
||||
import { journeyWeatherCategory, MOBILE_MOODS, MOBILE_WEATHERS } from './mobileJourneyMeta'
|
||||
|
||||
const PRO_COLOR = '#2FA37A'
|
||||
@@ -24,25 +26,31 @@ interface LocationResult {
|
||||
lng: number
|
||||
}
|
||||
|
||||
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
|
||||
|
||||
interface MJourneyEntrySheetProps {
|
||||
entry: JourneyEntry
|
||||
galleryPhotos: GalleryPhoto[]
|
||||
quickCapture?: boolean
|
||||
readOnly?: boolean
|
||||
userId?: number
|
||||
trips?: JourneyTrip[]
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, unknown>) => Promise<number>
|
||||
onSave: (data: Record<string, unknown>, existingEntryId?: number) => Promise<number>
|
||||
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||
onAddProviderPhotos?: (entryId: number, group: PendingProviderGroup) => Promise<void>
|
||||
onDelete?: () => void
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* shJEntry — the journey entry sheet: title, photos (upload / from gallery),
|
||||
* markdown story, pros & cons, date + time, location search, mood (4),
|
||||
* weather (6) and tags. Read-only for viewer contributors.
|
||||
* shJEntry — the journey entry sheet: title, photos (upload / from gallery /
|
||||
* connected provider), markdown story, pros & cons, date + time, location
|
||||
* search, mood (4), weather (6) and tags. Read-only for viewer contributors.
|
||||
*/
|
||||
export default function MJourneyEntrySheet({
|
||||
entry, galleryPhotos, quickCapture = false, readOnly = false, onClose, onSave, onUploadPhotos, onDelete, onDone,
|
||||
entry, galleryPhotos, quickCapture = false, readOnly = false, userId = 0, trips = [],
|
||||
onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDelete, onDone,
|
||||
}: MJourneyEntrySheetProps) {
|
||||
const { t, language } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -67,7 +75,11 @@ export default function MJourneyEntrySheet({
|
||||
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
||||
const [pendingProviderGroups, setPendingProviderGroups] = useState<PendingProviderGroup[]>([])
|
||||
const [showGalleryPick, setShowGalleryPick] = useState(false)
|
||||
const [showExternal, setShowExternal] = useState(false)
|
||||
const [externalProvider, setExternalProvider] = useState<string | null>(null)
|
||||
const [providers, setProviders] = useState<{ id: string; name: string }[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [captureOnly, setCaptureOnly] = useState(quickCapture)
|
||||
const [locating, setLocating] = useState(false)
|
||||
@@ -75,6 +87,55 @@ export default function MJourneyEntrySheet({
|
||||
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const cameraRef = useRef<HTMLInputElement>(null)
|
||||
// A save that creates the entry and then fails on the provider photos keeps
|
||||
// the sheet open; without the id of what was just created, the retry would
|
||||
// create a second entry (#1808).
|
||||
const persistedEntryIdRef = useRef<number | null>(entry.id > 0 ? entry.id : null)
|
||||
|
||||
// The addon list is already in the store — this only probes which of the
|
||||
// photo providers is actually connected for this user.
|
||||
const addons = useAddonStore(s => s.addons)
|
||||
const addonsLoaded = useAddonStore(s => s.loaded)
|
||||
const photoProviders = useMemo(
|
||||
() => addons.filter(a => a.type === 'photo_provider' && a.enabled).map(a => ({ id: a.id, name: a.name })),
|
||||
[addons],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly) return
|
||||
// App.tsx loads the addon list on boot, so this only reacts to it. Kicking off
|
||||
// a second load from here would race the other consumers, and loadAddons
|
||||
// overwrites the list unconditionally when it lands.
|
||||
if (!addonsLoaded) return
|
||||
if (photoProviders.length === 0) { setProviders([]); return }
|
||||
let active = true
|
||||
;(async () => {
|
||||
const connected: { id: string; name: string }[] = []
|
||||
for (const provider of photoProviders) {
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${provider.id}/status`, { credentials: 'include' })
|
||||
if (res.ok && (await res.json()).connected) connected.push(provider)
|
||||
} catch { /* provider stays hidden */ }
|
||||
}
|
||||
if (active) setProviders(connected)
|
||||
})()
|
||||
return () => { active = false }
|
||||
}, [readOnly, addonsLoaded, photoProviders])
|
||||
|
||||
const activeProvider = externalProvider || providers[0]?.id || null
|
||||
const queuedProviderPhotos = pendingProviderGroups.reduce((sum, group) => sum + group.assetIds.length, 0)
|
||||
const providerExistingAssetIds = new Set<string>()
|
||||
if (activeProvider) {
|
||||
photos.forEach(photo => {
|
||||
if (photo.provider === activeProvider && photo.asset_id) providerExistingAssetIds.add(photo.asset_id)
|
||||
})
|
||||
pendingProviderGroups.forEach(group => {
|
||||
if (group.provider === activeProvider) group.assetIds.forEach(assetId => providerExistingAssetIds.add(assetId))
|
||||
})
|
||||
}
|
||||
const contextLocation = isValidGeoPoint({ lat: locationLat ?? NaN, lng: locationLng ?? NaN })
|
||||
? { lat: locationLat!, lng: locationLng!, name: locationName || undefined }
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!quickCapture || readOnly || entry.location_lat != null || entry.location_lng != null) return
|
||||
@@ -120,7 +181,8 @@ export default function MJourneyEntrySheet({
|
||||
cons.filter(c => c.trim()).join('\n') !== (entry.pros_cons?.cons ?? []).join('\n') ||
|
||||
tags.join('\n') !== (entry.tags ?? []).join('\n') ||
|
||||
pendingFiles.length > 0 ||
|
||||
pendingLinkIds.length > 0
|
||||
pendingLinkIds.length > 0 ||
|
||||
pendingProviderGroups.length > 0
|
||||
|
||||
const handleClose = () => {
|
||||
if (!captureOnly && !readOnly && isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
||||
@@ -142,10 +204,12 @@ export default function MJourneyEntrySheet({
|
||||
weather: weather || null,
|
||||
tags: tags.filter(tag => tag.trim()),
|
||||
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
|
||||
type: entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0)
|
||||
type: entry.type === 'skeleton'
|
||||
&& (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0 || pendingProviderGroups.length > 0)
|
||||
? 'entry'
|
||||
: undefined,
|
||||
})
|
||||
}, persistedEntryIdRef.current ?? undefined)
|
||||
if (entryId > 0) persistedEntryIdRef.current = entryId
|
||||
if (pendingFiles.length > 0 && entryId) {
|
||||
const toUpload = pendingFiles
|
||||
setUploadProgress({ done: 0, total: toUpload.length })
|
||||
@@ -168,6 +232,20 @@ export default function MJourneyEntrySheet({
|
||||
try { await journeyApi.linkPhoto(entryId, photoId) } catch { /* linked photo stays in gallery */ }
|
||||
}
|
||||
}
|
||||
if (pendingProviderGroups.length > 0 && entryId && onAddProviderPhotos) {
|
||||
const failed: PendingProviderGroup[] = []
|
||||
for (const group of pendingProviderGroups) {
|
||||
try { await onAddProviderPhotos(entryId, group) } catch { failed.push(group) }
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
// Keep the sheet open with the failed groups queued so the next save
|
||||
// retries them instead of losing the selection.
|
||||
setPendingProviderGroups(failed)
|
||||
toast.error(t('journey.editor.externalPhotosPartialFailed', { failed: String(failed.length), total: String(pendingProviderGroups.length) }))
|
||||
return
|
||||
}
|
||||
setPendingProviderGroups([])
|
||||
}
|
||||
onDone()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
@@ -281,7 +359,7 @@ export default function MJourneyEntrySheet({
|
||||
type="button"
|
||||
onClick={() => (captureOnly ? cameraRef : fileRef).current?.click()}
|
||||
disabled={saving}
|
||||
className="flex flex-1 items-center justify-center gap-[6px] rounded-[14px] border-[1.5px] border-dashed border-[color:var(--m-rowbr)] p-3 text-[0.75rem] font-semibold text-m-muted disabled:opacity-50"
|
||||
className="flex min-w-0 flex-1 items-center justify-center gap-[6px] rounded-[14px] border-[1.5px] border-dashed border-[color:var(--m-rowbr)] p-3 text-center text-[0.75rem] font-semibold text-m-muted disabled:opacity-50"
|
||||
>
|
||||
{uploadProgress ? (
|
||||
<>
|
||||
@@ -297,9 +375,13 @@ export default function MJourneyEntrySheet({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => captureOnly ? fileRef.current?.click() : setShowGalleryPick(v => !v)}
|
||||
onClick={() => {
|
||||
if (captureOnly) { fileRef.current?.click(); return }
|
||||
setShowGalleryPick(v => !v)
|
||||
setShowExternal(false)
|
||||
}}
|
||||
disabled={!captureOnly && galleryPhotos.length === 0}
|
||||
className={`flex flex-1 items-center justify-center gap-[6px] rounded-[14px] border-[1.5px] p-3 text-[0.75rem] font-semibold disabled:opacity-40 ${
|
||||
className={`flex min-w-0 flex-1 items-center justify-center gap-[6px] rounded-[14px] border-[1.5px] p-3 text-center text-[0.75rem] font-semibold disabled:opacity-40 ${
|
||||
!captureOnly && showGalleryPick
|
||||
? 'border-[color:var(--m-act)] text-m-ink'
|
||||
: 'border-dashed border-[color:var(--m-rowbr)] text-m-muted'
|
||||
@@ -308,8 +390,109 @@ export default function MJourneyEntrySheet({
|
||||
<Image size={14} strokeWidth={2} />
|
||||
{captureOnly ? t('journey.share.gallery') : t('journey.editor.fromGallery')}
|
||||
</button>
|
||||
{/* Immich/Synology, the same source the desktop editor offers (#1808).
|
||||
Only shown once a provider is actually connected — on a phone a
|
||||
button that can only say "nothing connected" is not worth its width. */}
|
||||
{!captureOnly && providers.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowExternal(v => !v); setShowGalleryPick(false) }}
|
||||
disabled={saving}
|
||||
className={`flex min-w-0 flex-1 items-center justify-center gap-[6px] rounded-[14px] border-[1.5px] p-3 text-center text-[0.75rem] font-semibold disabled:opacity-50 ${
|
||||
showExternal
|
||||
? 'border-[color:var(--m-act)] text-m-ink'
|
||||
: 'border-dashed border-[color:var(--m-rowbr)] text-m-muted'
|
||||
}`}
|
||||
>
|
||||
<Images size={14} strokeWidth={2} />
|
||||
{t('journey.editor.externalPhotos')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Outside the panel: picking collapses it again, and the queue has
|
||||
to stay visible until the save actually writes it. */}
|
||||
{!captureOnly && queuedProviderPhotos > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingProviderGroups([])}
|
||||
className="mt-2 rounded-full border border-[color:var(--m-rowbr)] px-3 py-[5px] font-geist text-[0.65625rem] font-semibold text-m-muted"
|
||||
>
|
||||
{queuedProviderPhotos} {t('journey.editor.externalPhotosQueued')} · {t('common.clear')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!captureOnly && showExternal && activeProvider && (
|
||||
<div
|
||||
className="mt-2 flex flex-col overflow-hidden rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]"
|
||||
style={{ height: 'min(46vh, 420px)' }}
|
||||
>
|
||||
<div className="flex flex-none items-center gap-2 border-b border-[color:var(--m-rowbr)] px-3 py-2">
|
||||
<span className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] font-semibold text-m-muted">
|
||||
{contextLocation?.name
|
||||
? `${t('journey.editor.externalPhotosNearby')} · ${contextLocation.name}`
|
||||
: t('journey.editor.externalPhotosNoLocation')}
|
||||
</span>
|
||||
</div>
|
||||
{providers.length > 1 && (
|
||||
<div className="flex flex-none gap-1 overflow-x-auto border-b border-[color:var(--m-rowbr)] px-3 py-2">
|
||||
{providers.map(provider => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
data-testid={`journey-external-provider-${provider.id}`}
|
||||
onClick={() => setExternalProvider(provider.id)}
|
||||
className={`whitespace-nowrap rounded-full px-[10px] py-[4px] text-[0.6875rem] font-bold ${
|
||||
activeProvider === provider.id ? 'bg-m-act text-m-actfg' : 'text-m-muted'
|
||||
}`}
|
||||
>
|
||||
{provider.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* `embedded` is load-bearing: the standalone picker is
|
||||
position:fixed, which the transformed MSheet panel would
|
||||
anchor to itself and then clip. */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<ProviderPicker
|
||||
key={`${activeProvider}-${entryDate}`}
|
||||
provider={activeProvider}
|
||||
userId={userId}
|
||||
entries={[entry]}
|
||||
trips={trips}
|
||||
existingAssetIds={providerExistingAssetIds}
|
||||
initialDate={entryDate}
|
||||
contextLocation={contextLocation}
|
||||
initialEntryId={entry.id || null}
|
||||
embedded
|
||||
onClose={() => setShowExternal(false)}
|
||||
onAdd={async groups => {
|
||||
setPendingProviderGroups(previous => {
|
||||
const next = [...previous]
|
||||
for (const group of groups) {
|
||||
const existing = next.find(item => item.provider === activeProvider && item.passphrase === group.passphrase)
|
||||
if (existing) {
|
||||
const seen = new Set(existing.assetIds)
|
||||
group.assetIds.forEach((assetId, index) => {
|
||||
if (seen.has(assetId)) return
|
||||
seen.add(assetId)
|
||||
existing.assetIds.push(assetId)
|
||||
existing.mediaTypes?.push(group.mediaTypes?.[index] || 'image')
|
||||
})
|
||||
} else {
|
||||
next.push({ ...group, provider: activeProvider })
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
setShowExternal(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!captureOnly && showGalleryPick && (
|
||||
<div className="mt-2 max-h-[160px] overflow-y-auto rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-2">
|
||||
<div className="grid grid-cols-5 gap-[6px]">
|
||||
|
||||
@@ -12,9 +12,10 @@ type Provider = NonNullable<Settings['llm_provider']>
|
||||
|
||||
/**
|
||||
* Mobile-native twin of components/Settings/LlmConnectionSection. Same per-user
|
||||
* AI-parsing model logic (provider/model/base URL/key/multimodal, key never
|
||||
* prefilled), rebuilt on the MSet* card system: the provider CustomSelect becomes
|
||||
* an MSetSelectRow + MSetPickerSheet, the toggle becomes MToggle. Presentation only.
|
||||
* AI-parsing model logic (provider/model/key/multimodal, key never prefilled,
|
||||
* free-form endpoint moved to the instance config per #1772), rebuilt on the
|
||||
* MSet* card system: the provider CustomSelect becomes an MSetSelectRow +
|
||||
* MSetPickerSheet, the toggle becomes MToggle. Presentation only.
|
||||
*/
|
||||
export default function MLlmConnectionSection(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
@@ -22,10 +23,10 @@ export default function MLlmConnectionSection(): React.ReactElement {
|
||||
const settings = useSettingsStore(s => s.settings)
|
||||
const isLoaded = useSettingsStore(s => s.isLoaded)
|
||||
const updateSettings = useSettingsStore(s => s.updateSettings)
|
||||
const loadSettings = useSettingsStore(s => s.loadSettings)
|
||||
|
||||
const [provider, setProvider] = useState<Provider>('local')
|
||||
const [provider, setProvider] = useState<Provider>('openai')
|
||||
const [model, setModel] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [multimodal, setMultimodal] = useState(false)
|
||||
const [hasStoredKey, setHasStoredKey] = useState(false)
|
||||
@@ -33,22 +34,20 @@ export default function MLlmConnectionSection(): React.ReactElement {
|
||||
const [providerOpen, setProviderOpen] = useState(false)
|
||||
|
||||
// Hydrate from the loaded settings. llm_api_key arrives masked, so we only use
|
||||
// its presence to drive the placeholder — never the value itself.
|
||||
// its presence to drive the placeholder, never the value itself. A stored
|
||||
// 'local' from before #1772 shows as OpenAI (local state only, nothing is
|
||||
// saved until Save is pressed).
|
||||
useEffect(() => {
|
||||
if (!isLoaded) return
|
||||
setProvider(settings.llm_provider || 'local')
|
||||
const stored = settings.llm_provider || 'openai'
|
||||
setProvider(stored === 'local' ? 'openai' : stored)
|
||||
setModel(settings.llm_model || '')
|
||||
setBaseUrl(settings.llm_base_url || '')
|
||||
setMultimodal(settings.llm_multimodal === true)
|
||||
setHasStoredKey(!!settings.llm_api_key)
|
||||
}, [isLoaded, settings.llm_provider, settings.llm_model, settings.llm_base_url, settings.llm_multimodal, settings.llm_api_key])
|
||||
|
||||
const needsKey = provider !== 'local'
|
||||
const showBaseUrl = provider === 'local' || provider === 'openai'
|
||||
}, [isLoaded, settings.llm_provider, settings.llm_model, settings.llm_multimodal, settings.llm_api_key])
|
||||
|
||||
const providerOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'local', label: t('settings.aiParsing.providerLocal') },
|
||||
{ value: 'openai', label: t('settings.aiParsing.providerOpenai') },
|
||||
{ value: 'anthropic', label: t('settings.aiParsing.providerAnthropic') },
|
||||
],
|
||||
@@ -62,7 +61,9 @@ export default function MLlmConnectionSection(): React.ReactElement {
|
||||
const payload: Partial<Settings> = {
|
||||
llm_provider: provider,
|
||||
llm_model: model.trim(),
|
||||
llm_base_url: showBaseUrl ? baseUrl.trim() : '',
|
||||
// Always cleared: the endpoint is instance configuration now, and this
|
||||
// also drops a value left over from before #1772.
|
||||
llm_base_url: '',
|
||||
llm_multimodal: multimodal,
|
||||
}
|
||||
// Send the key only when the user typed a new one — a blank field means
|
||||
@@ -74,6 +75,11 @@ export default function MLlmConnectionSection(): React.ReactElement {
|
||||
if (key) setHasStoredKey(true)
|
||||
toast.success(t('settings.aiParsing.toast.saved'))
|
||||
} catch {
|
||||
// updateSettings patches the store before the request and keeps the patch
|
||||
// when the request fails, so a refused save (the 403 from #1772, or any
|
||||
// other error) would leave the form showing a value the server never
|
||||
// stored. Pull the stored settings back in so what is on screen is real.
|
||||
await loadSettings()
|
||||
toast.error(t('settings.aiParsing.toast.saveError'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
@@ -90,6 +96,7 @@ export default function MLlmConnectionSection(): React.ReactElement {
|
||||
trailing={<ChevronDown size={13} strokeWidth={2} className="flex-none text-m-faint" />}
|
||||
onClick={() => setProviderOpen(true)}
|
||||
/>
|
||||
<MSetHint>{t('settings.aiParsing.localAdminOnly')}</MSetHint>
|
||||
|
||||
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.aiParsing.model')}</MSetEyebrow>
|
||||
<MSetInput
|
||||
@@ -100,33 +107,17 @@ export default function MLlmConnectionSection(): React.ReactElement {
|
||||
placeholder="qwen3:8b"
|
||||
/>
|
||||
|
||||
{showBaseUrl && (
|
||||
<>
|
||||
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.aiParsing.baseUrl')}</MSetEyebrow>
|
||||
<MSetInput
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
value={baseUrl}
|
||||
onChange={e => setBaseUrl(e.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
/>
|
||||
<MSetHint>{t('settings.aiParsing.baseUrlHint')}</MSetHint>
|
||||
</>
|
||||
)}
|
||||
|
||||
{needsKey && (
|
||||
<>
|
||||
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.aiParsing.apiKey')}</MSetEyebrow>
|
||||
<MSetInput
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={hasStoredKey && !apiKey ? '••••••••' : t('settings.aiParsing.apiKey')}
|
||||
/>
|
||||
<MSetHint>{t('settings.aiParsing.apiKeyHint')}</MSetHint>
|
||||
</>
|
||||
)}
|
||||
{/* Both remaining providers are hosted and need a key, so this is no longer
|
||||
conditional (#1772). */}
|
||||
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.aiParsing.apiKey')}</MSetEyebrow>
|
||||
<MSetInput
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={hasStoredKey && !apiKey ? '••••••••' : t('settings.aiParsing.apiKey')}
|
||||
/>
|
||||
<MSetHint>{t('settings.aiParsing.apiKeyHint')}</MSetHint>
|
||||
|
||||
<div className="mt-3">
|
||||
<MSetRow
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Bookmark, Check, CheckCheck, ListChecks, MapPin, MoreHorizontal, Plus,
|
||||
Bookmark, Check, CheckCheck, CheckCircle2, ListChecks, Loader2, MapPin, MoreHorizontal, Plus,
|
||||
SlidersHorizontal, Tag, Trash2, X,
|
||||
} from 'lucide-react'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { useAddonStore } from '../../../../store/addonStore'
|
||||
import { useToast } from '../../../../components/shared/Toast'
|
||||
import { collectionsApi } from '../../../../api/collections'
|
||||
import PlaceAvatar from '../../../../components/shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
|
||||
import { resolveTrackColor } from '../../../../components/Map/trackColors'
|
||||
@@ -43,6 +45,8 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
|
||||
const [saveToListOpen, setSaveToListOpen] = useState(false)
|
||||
const [markVisitedBusy, setMarkVisitedBusy] = useState(false)
|
||||
const toast = useToast()
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
|
||||
|
||||
// Entering the browser from the edit segment starts on the unplanned pool.
|
||||
@@ -85,6 +89,23 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
return next
|
||||
})
|
||||
|
||||
/** Mark the selection visited in every list it is saved to (#1469). */
|
||||
const markSelectionVisited = async () => {
|
||||
const ids = [...selectedIds]
|
||||
if (ids.length === 0 || markVisitedBusy) return
|
||||
setMarkVisitedBusy(true)
|
||||
try {
|
||||
const { updated, places: matched } = await collectionsApi.setStatusFromTrip(trip.id, ids, 'visited')
|
||||
if (updated === 0) toast.info(t('collections.markVisitedNone'))
|
||||
else toast.success(t('collections.markedVisitedTrip', { count: matched ?? 0 }))
|
||||
exitSelectMode()
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setMarkVisitedBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Compare the ids, not just the counts: a place removed remotely while another
|
||||
// one is selected keeps the sizes equal without the sets matching.
|
||||
const allSelected = filtered.length > 0 && filtered.every(p => selectedIds.has(p.id))
|
||||
@@ -223,6 +244,15 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
<Bookmark size={14} strokeWidth={2} />
|
||||
</BulkBtn>
|
||||
)}
|
||||
{collectionsEnabled && (
|
||||
<BulkBtn
|
||||
label={t('collections.markVisitedSelection')}
|
||||
disabled={selectedIds.size === 0 || markVisitedBusy}
|
||||
onClick={markSelectionVisited}
|
||||
>
|
||||
{markVisitedBusy ? <Loader2 size={14} className="animate-spin" /> : <CheckCircle2 size={14} strokeWidth={2} />}
|
||||
</BulkBtn>
|
||||
)}
|
||||
<BulkBtn label={t('places.deleteSelected')} disabled={selectedIds.size === 0} onClick={() => setConfirmDeleteOpen(true)}>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</BulkBtn>
|
||||
|
||||
@@ -258,6 +258,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
note: note.trim() || null,
|
||||
ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null,
|
||||
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
||||
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
|
||||
}
|
||||
try {
|
||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ClipboardEvent } from 'react'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { MapPin, Plus } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import {
|
||||
DEFAULT_FORM,
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
type ResultField,
|
||||
} from '../../../../components/Planner/PlaceFormModal.helpers'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import { useAddonStore } from '../../../../store/addonStore'
|
||||
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
|
||||
import PlPlaceSearch, { type PlSearchPick } from './PlPlaceSearch'
|
||||
import PlCategoryPicker from './PlCategoryPicker'
|
||||
import PlTimeFields from './PlTimeFields'
|
||||
@@ -17,6 +19,8 @@ import type { TripPlanner } from '../MTripShell'
|
||||
|
||||
export interface MPlaceEditSheetProps {
|
||||
planner: TripPlanner
|
||||
/** Opens the Costs editor for this place's linked expense (#1298). */
|
||||
onOpenExpense: (req: BookingExpenseRequest) => void
|
||||
}
|
||||
|
||||
// #1152: same duplicate heuristic as the desktop form — shared Google Place ID,
|
||||
@@ -51,7 +55,7 @@ function findDuplicateName(
|
||||
* planner.handleSavePlace, which owns the assignment-time split, pending-file
|
||||
* upload and undo.
|
||||
*/
|
||||
export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) {
|
||||
export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSheetProps) {
|
||||
const {
|
||||
t, toast, places, assignments, canUploadFiles,
|
||||
showPlaceForm, setShowPlaceForm,
|
||||
@@ -68,6 +72,10 @@ export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) {
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [resolvingPick, setResolvingPick] = useState(false)
|
||||
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
// Set right before submit: the place has to exist before an expense can point
|
||||
// at it, exactly like MReservationSheet does it.
|
||||
const expenseIntentRef = useRef(false)
|
||||
const [deleteArmed, setDeleteArmed] = useState(false)
|
||||
// Open-time snapshot: closing clears the planner flags immediately, but the
|
||||
// sheet still shows through its exit animation — render off the snapshot so
|
||||
@@ -221,15 +229,21 @@ export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const withExpense = expenseIntentRef.current
|
||||
expenseIntentRef.current = false
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await handleSavePlace({
|
||||
const saved = await handleSavePlace({
|
||||
...form,
|
||||
lat: form.lat ? parseFloat(form.lat) : null,
|
||||
lng: form.lng ? parseFloat(form.lng) : null,
|
||||
category_id: form.category_id || null,
|
||||
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||
})
|
||||
const savedId = saved?.id ?? sheetPlace?.id ?? null
|
||||
if (withExpense && savedId) {
|
||||
onOpenExpense({ prefill: { placeId: savedId, name: form.name.trim(), category: 'activities' } })
|
||||
}
|
||||
handleClose()
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('places.saveError'))
|
||||
@@ -360,6 +374,23 @@ export default function MPlaceEditSheet({ planner }: MPlaceEditSheetProps) {
|
||||
onRemove={idx => setPendingFiles(prev => prev.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* COSTS — same block, same flow as the booking sheet (#1298) */}
|
||||
{isBudgetEnabled && (
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('reservations.costsLabel')}</Eyebrow>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { expenseIntentRef.current = true; handleSubmit() }}
|
||||
disabled={!form.name.trim() || isSaving}
|
||||
className="flex w-full items-center justify-center gap-[6px] rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-[11px] text-[0.78125rem] font-semibold text-m-ink disabled:opacity-40"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.2} />
|
||||
{t('reservations.createExpense')}
|
||||
</button>
|
||||
<div className="mt-[5px] font-geist text-[0.625rem] text-m-faint">{t('places.createExpenseHint')}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormSheetFooter
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
<MImportSheet planner={planner} open={sheet?.id === 'import'} onClose={shell.closeSheet} />
|
||||
|
||||
{/* ── Planner-flag editors (also serve ?create= and the import review) ── */}
|
||||
<MPlaceEditSheet planner={planner} />
|
||||
<MPlaceEditSheet planner={planner} onOpenExpense={openBookingExpense} />
|
||||
|
||||
<MReservationSheet planner={planner} onOpenExpense={openBookingExpense} />
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
|
||||
import { ChevronLeft, ChevronRight, Download, ExternalLink, X } from 'lucide-react'
|
||||
import { getAuthUrl } from '../../../../api/authUrl'
|
||||
import { downloadFile, openFile } from '../../../../utils/fileDownload'
|
||||
import { lockBodyScroll } from '../../../../utils/bodyScrollLock'
|
||||
import { isVideo } from '../../../../components/Files/FileManager.helpers'
|
||||
import VideoPlayer from '../../../../components/Journey/VideoPlayerLazy'
|
||||
import type { TranslationFn, TripFile } from '../../../../types'
|
||||
@@ -56,11 +57,7 @@ export default function MFileLightbox({ files, index, onIndexChange, onClose, t
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [index, files.length])
|
||||
|
||||
useEffect(() => {
|
||||
const prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = prevOverflow }
|
||||
}, [])
|
||||
useEffect(() => lockBodyScroll(), [])
|
||||
|
||||
if (!file) return null
|
||||
|
||||
|
||||
@@ -767,6 +767,7 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
|
||||
}) {
|
||||
const { t, tripId, tripActions } = planner
|
||||
const [bagPickerOpen, setBagPickerOpen] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [creatingBag, setCreatingBag] = useState(false)
|
||||
const [newBagName, setNewBagName] = useState('')
|
||||
|
||||
@@ -821,16 +822,26 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
|
||||
{isPlaceholder ? t('packing.addItemPlaceholder') : item.name}
|
||||
</span>
|
||||
|
||||
{/* Sharing state is an icon, not a sentence (#1525): "Taken care of by
|
||||
Alexander" spelled out took a third of the row away from the item
|
||||
name. The full wording lives on the pill as its label. */}
|
||||
{badgeSharedToMe && (
|
||||
<span className="flex flex-none items-center gap-1 whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.59375rem] font-bold text-m-muted">
|
||||
<HandHelping size={9} strokeWidth={2.2} />
|
||||
{t('packing.takenCareOf', { name: item.owner_username || '' })}
|
||||
<span
|
||||
className="flex h-5 w-5 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted"
|
||||
title={t('packing.takenCareOf', { name: item.owner_username || '' })}
|
||||
aria-label={t('packing.takenCareOf', { name: item.owner_username || '' })}
|
||||
>
|
||||
<HandHelping size={11} strokeWidth={2.2} />
|
||||
</span>
|
||||
)}
|
||||
{!badgeSharedToMe && badgeSharedByMe && (
|
||||
<span className="flex flex-none items-center gap-1 whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.59375rem] font-bold text-m-muted">
|
||||
<UserRound size={9} strokeWidth={2.2} />
|
||||
{t('packing.sharedWithCount', { count: recipients.length })}
|
||||
<span
|
||||
className="flex flex-none items-center gap-[2px] rounded-full bg-[color:var(--m-ic)] px-[5px] py-[3px] font-geist text-[0.59375rem] font-bold text-m-muted"
|
||||
title={t('packing.sharedWithCount', { count: recipients.length })}
|
||||
aria-label={t('packing.sharedWithCount', { count: recipients.length })}
|
||||
>
|
||||
<UserRound size={10} strokeWidth={2.2} />
|
||||
{recipients.length}
|
||||
</span>
|
||||
)}
|
||||
{!badgeSharedToMe && !badgeSharedByMe && badgeBroughtBy && (
|
||||
@@ -850,16 +861,18 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
|
||||
</span>
|
||||
)}
|
||||
|
||||
{editMode && bagTrackingEnabled && (
|
||||
{/* A weight nobody entered is not worth a column: "— g" on every row
|
||||
cost as much width as a real value (#1525). */}
|
||||
{editMode && bagTrackingEnabled && item.weight_grams != null && (
|
||||
<span className="flex-none font-geist text-[0.625rem] font-semibold tabular-nums text-m-faint">
|
||||
{item.weight_grams != null ? formatWeight(item.weight_grams) : '— g'}
|
||||
{formatWeight(item.weight_grams)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{bagTrackingEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBagPickerOpen(v => !v)}
|
||||
onClick={() => { setMenuOpen(false); setBagPickerOpen(v => !v) }}
|
||||
aria-label={t('packing.bags')}
|
||||
className="h-[18px] w-[18px] flex-none rounded-full"
|
||||
style={bag
|
||||
@@ -868,18 +881,43 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit and delete moved behind one button (#1525): two round targets
|
||||
plus their gaps were the widest thing on an edit-mode row, and
|
||||
neither is used often enough to earn a permanent seat. */}
|
||||
{editMode && canEdit && (
|
||||
<>
|
||||
<button type="button" onClick={onEdit} aria-label={t('common.edit')} className="flex h-7 w-7 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted">
|
||||
<Pencil size={11} strokeWidth={2} />
|
||||
</button>
|
||||
<button type="button" onClick={onDelete} aria-label={t('common.delete')} className="flex h-7 w-7 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted">
|
||||
<Trash2 size={11} strokeWidth={2} />
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setBagPickerOpen(false); setMenuOpen(v => !v) }}
|
||||
aria-label={t('mobileTrip.more')}
|
||||
aria-expanded={menuOpen}
|
||||
className={`flex h-7 w-7 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] ${menuOpen ? 'text-m-ink' : 'text-m-muted'}`}
|
||||
>
|
||||
<MoreHorizontal size={13} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{menuOpen && editMode && canEdit && (
|
||||
<div className="mb-[6px] ml-[28px] overflow-hidden rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)] backdrop-blur-[24px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMenuOpen(false); onEdit() }}
|
||||
className="flex w-full items-center gap-[9px] border-b border-[color:var(--m-rowbr)] px-3 py-2 text-left text-[0.75rem] font-medium text-m-ink"
|
||||
>
|
||||
<Pencil size={12} strokeWidth={2} />
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMenuOpen(false); onDelete() }}
|
||||
className="flex w-full items-center gap-[9px] px-3 py-2 text-left text-[0.75rem] font-medium text-[color:var(--m-st-danger)]"
|
||||
>
|
||||
<Trash2 size={12} strokeWidth={2} />
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bagPickerOpen && bagTrackingEnabled && (
|
||||
<div className="mb-[6px] ml-[28px] overflow-hidden rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)] backdrop-blur-[24px]">
|
||||
<button type="button" onClick={() => assignBag(null)} className="flex w-full items-center gap-[9px] border-b border-[color:var(--m-rowbr)] px-3 py-2 text-left text-[0.75rem] font-medium text-m-muted">
|
||||
|
||||
@@ -21,7 +21,8 @@ const WEEKDAY_KEYS_SUNDAY = ['vacay.sun', 'vacay.mon', 'vacay.tue', 'vacay.wed',
|
||||
* Mobile Vacay screen: year pill header, person card with inline entitlement
|
||||
* stepper, member/legend chips and the 12-month grid — or, in edit mode, the
|
||||
* single-month editor with quick month nav and the person/company mode
|
||||
* switch. The dock FAB flips between the two views.
|
||||
* switch. The grid navigates (month chip or day cell open that month's editor),
|
||||
* the editor logs; the screen's own FAB in the dock centre flips back.
|
||||
*/
|
||||
export default function MVacay() {
|
||||
const { t } = useTranslation()
|
||||
@@ -29,7 +30,7 @@ export default function MVacay() {
|
||||
|
||||
if (v.loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex h-dvh items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-[color:var(--m-ink)]" />
|
||||
</div>
|
||||
)
|
||||
@@ -44,7 +45,8 @@ export default function MVacay() {
|
||||
const weekdayKeys = v.weekStart === 0 ? WEEKDAY_KEYS_SUNDAY : WEEKDAY_KEYS_MONDAY
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
// h-dvh, not h-full: the shell stopped providing a definite height (#1809).
|
||||
<div className="relative h-dvh">
|
||||
{/* Header */}
|
||||
<div className="absolute left-4 right-4 z-[5] flex items-center gap-2 top-[var(--m-safe-top,12px)]">
|
||||
<MIconBtn onClick={() => v.setSheet('invite')} ariaLabel={t('vacay.inviteUser')}>
|
||||
@@ -172,7 +174,17 @@ export default function MVacay() {
|
||||
year, Jul–Jun once the leave year is shifted. */}
|
||||
{v.months.map(({ year, month }, slot) => (
|
||||
<div key={`${year}-${month}`} className="rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] p-2">
|
||||
<div className="mb-1 text-[0.75rem] font-extrabold capitalize">{v.monthNamesShort[slot]}</div>
|
||||
{/* The month name is the comfortable way into the editor: the mini
|
||||
cells below it are ~21px on a phone, precise enough to navigate
|
||||
with but not to aim at (#1811). Same chip as the edit view's
|
||||
month nav, so it reads as tappable. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => v.openMonthSlot(slot)}
|
||||
className="mb-[6px] w-full rounded-[11px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-[7px] text-center text-[0.75rem] font-extrabold capitalize"
|
||||
>
|
||||
{v.monthNamesShort[slot]}
|
||||
</button>
|
||||
<MVacayMonth
|
||||
year={year}
|
||||
month={month}
|
||||
@@ -305,7 +317,8 @@ export default function MVacay() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View/edit FAB — replaces the dock "+" on this screen */}
|
||||
{/* View/edit FAB. It sits in the dock's centre slot, which MBottomNav leaves
|
||||
empty on /vacay so this stays the only action there (#1811). */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={v.toggleView}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface MVacayMonthProps {
|
||||
ctx: DayVisualContext
|
||||
tripDates: Set<string>
|
||||
tripDotColor: string
|
||||
/** mini cells zoom into that month, full cells log the day (#1811). */
|
||||
onDayTap: (date: string) => void
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ArrowRightLeft, Building2, Calendar, CalendarRange, CalendarX, ChevronDown, Globe, GraduationCap, Plus, Trash2, Unlink, X } from 'lucide-react'
|
||||
import { ArrowRightLeft, Building2, Calendar, CalendarRange, CalendarX, ChevronDown, Globe, GraduationCap, Loader2, Plus, Trash2, Unlink, X } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MToggle from '../../components/MToggle'
|
||||
@@ -442,6 +442,30 @@ function ColorSwatch({ color, onPick }: { color: string; onPick: (color: string)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Region options for a holiday-calendar country.
|
||||
*
|
||||
* The loaded list is tagged with the country it belongs to, so switching country
|
||||
* drops the previous list in the same render instead of leaving it selectable while
|
||||
* the next request is still running (#1813: picking a German region for the
|
||||
* Netherlands was possible that way). `loadingRegions` tells "not answered yet"
|
||||
* apart from "this country has no regions"; on the list alone both are empty.
|
||||
*/
|
||||
function useRegionOptions(country: string, calendarType: 'public_holiday' | 'school_holiday') {
|
||||
const [loaded, setLoaded] = useState<{ country: string; options: Option[] }>({ country: '', options: [] })
|
||||
|
||||
useEffect(() => {
|
||||
if (!country) return
|
||||
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
|
||||
let stale = false
|
||||
load(country).then(options => { if (!stale) setLoaded({ country, options }) })
|
||||
return () => { stale = true }
|
||||
}, [country, calendarType])
|
||||
|
||||
const ready = loaded.country === country
|
||||
return { regions: ready ? loaded.options : [], loadingRegions: Boolean(country) && !ready }
|
||||
}
|
||||
|
||||
function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpdate, onDelete }: {
|
||||
cal: VacayHolidayCalendar
|
||||
countries: Option[]
|
||||
@@ -451,19 +475,14 @@ function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpd
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [label, setLabel] = useState(cal.label || '')
|
||||
const [regions, setRegions] = useState<Option[]>([])
|
||||
// A school-holiday group carries a `|group:…` suffix — strip it before the
|
||||
// country is read off, otherwise the region lookup finds nothing.
|
||||
const [baseRegion] = cal.region.split('|')
|
||||
const country = baseRegion.split('-')[0]
|
||||
const region = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : ''
|
||||
const { regions } = useRegionOptions(country, calendarType)
|
||||
|
||||
useEffect(() => { setLabel(cal.label || '') }, [cal.label])
|
||||
useEffect(() => {
|
||||
if (!country) { setRegions([]); return }
|
||||
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
|
||||
load(country).then(setRegions)
|
||||
}, [country, calendarType])
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3">
|
||||
@@ -499,19 +518,16 @@ function AddCalendarDraft({ countries, calendarType = 'public_holiday', onAdd, o
|
||||
const [region, setRegion] = useState('')
|
||||
const [color, setColor] = useState(CALENDAR_COLORS[0])
|
||||
const [label, setLabel] = useState('')
|
||||
const [regions, setRegions] = useState<Option[]>([])
|
||||
|
||||
const [baseRegion] = region.split('|')
|
||||
const country = baseRegion.split('-')[0] || ''
|
||||
const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : ''
|
||||
const { regions, loadingRegions } = useRegionOptions(country, calendarType)
|
||||
|
||||
useEffect(() => {
|
||||
if (!country) { setRegions([]); return }
|
||||
const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions
|
||||
load(country).then(setRegions)
|
||||
}, [country, calendarType])
|
||||
|
||||
const canAdd = Boolean(country) && (regions.length === 0 || selectedRegion !== '')
|
||||
// Adding is blocked while the region list is on its way: an empty list would
|
||||
// otherwise pass as "this country needs no region" and create a calendar that
|
||||
// draws nothing (#1813). The button shows a spinner so the wait is visible.
|
||||
const canAdd = Boolean(country) && !loadingRegions && (regions.length === 0 || selectedRegion !== '')
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-[14px] border-[1.5px] border-dashed border-[color:var(--m-rowbr)] p-3">
|
||||
@@ -534,9 +550,11 @@ function AddCalendarDraft({ countries, calendarType = 'public_holiday', onAdd, o
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canAdd}
|
||||
aria-busy={loadingRegions}
|
||||
onClick={() => onAdd({ region: region || country, color, label: label.trim() || null })}
|
||||
className="mt-[7px] w-full rounded-[10px] bg-m-act p-[9px] text-center text-[0.78125rem] font-semibold text-m-actfg disabled:opacity-40"
|
||||
className="mt-[7px] flex w-full items-center justify-center gap-[6px] rounded-[10px] bg-m-act p-[9px] text-[0.78125rem] font-semibold text-m-actfg disabled:opacity-40"
|
||||
>
|
||||
{loadingRegions && <Loader2 size={13} strokeWidth={2} className="animate-spin" />}
|
||||
{t('vacay.add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -172,10 +172,25 @@ export function useMVacay() {
|
||||
}
|
||||
}, [isFused, currentUser?.id, setSelectedUserId])
|
||||
|
||||
// Zoom the year overview into one month's editor (#1811). The overview used to
|
||||
// swallow every tap, leaving the pen FAB as the only way in; it is a way in
|
||||
// itself now, while its ~21px mini cells stay out of data changes.
|
||||
const openMonthSlot = useCallback((slot: number) => {
|
||||
setMonthSlot(slot)
|
||||
setView('edit')
|
||||
}, [])
|
||||
|
||||
const handleDayTap = useCallback(async (dateStr: string) => {
|
||||
// The year overview is read-only — logging only happens in edit mode
|
||||
// (neither vacation days nor company holidays can be set while viewing).
|
||||
if (view !== 'edit') return
|
||||
// Outside edit mode a tap navigates instead of logging: it opens the month
|
||||
// the day belongs to, where the cells are big enough to hit reliably (#1811).
|
||||
if (view !== 'edit') {
|
||||
const [year, month] = dateStr.split('-').map(Number)
|
||||
// Resolved against the window months, not the calendar month: with a
|
||||
// shifted leave year (#737) slot 0 is not January.
|
||||
const slot = months.findIndex(m => m.year === year && m.month === month - 1)
|
||||
if (slot >= 0) openMonthSlot(slot)
|
||||
return
|
||||
}
|
||||
if (mode === 'company') {
|
||||
if (!companyHolidaysEnabled) return
|
||||
await toggleCompanyHoliday(dateStr)
|
||||
@@ -193,7 +208,7 @@ export function useMVacay() {
|
||||
}
|
||||
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
|
||||
await toggleEntry(dateStr, selectedUserId || undefined, halfDay ? 0.5 : 1, compDay ? 'comp' : 'vacation')
|
||||
}, [view, mode, halfDay, compDay, companyHolidaysEnabled, blockWeekends, weekendDays, companyHolidaySet, toggleEntry, toggleCompanyHoliday, selectedUserId, currentUser?.id, entryMap])
|
||||
}, [view, months, openMonthSlot, mode, halfDay, compDay, companyHolidaysEnabled, blockWeekends, weekendDays, companyHolidaySet, toggleEntry, toggleCompanyHoliday, selectedUserId, currentUser?.id, entryMap])
|
||||
|
||||
// Entitlement stepper: never below what is already used this year
|
||||
// (carried-over days cover the difference when used > entitlement).
|
||||
@@ -243,7 +258,7 @@ export function useMVacay() {
|
||||
blockWeekends, companyHolidaysEnabled, holidaysEnabled, weekStart, weekendDays,
|
||||
dayCtx, monthNamesShort, monthNameLong,
|
||||
selectedUser, selectedColor, selectedStat, selectedUserId, selectPerson,
|
||||
handleDayTap, allowInc, allowDec,
|
||||
handleDayTap, openMonthSlot, allowInc, allowDec,
|
||||
prevYear, nextYear, prevMonth, nextMonth, toggleView, goBack,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +99,10 @@ function baseDayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext
|
||||
// onto whatever visual this day resolves to rather than picking a single winner.
|
||||
const withSchool = (v: DayVisual): DayVisual => (schoolColors.length > 0 ? { ...v, school: schoolColors } : v)
|
||||
|
||||
if (dateStr === ctx.todayStr) {
|
||||
return withSchool({ background: 'transparent', numColor: 'var(--m-ink)', boxShadow: 'inset 0 0 0 1.5px var(--m-ink)' })
|
||||
}
|
||||
// Today is deliberately NOT decided here: it is a ring around whatever the day
|
||||
// already is, applied in dayVisual. Resolving it as its own visual meant a
|
||||
// vacation day or company holiday logged for today was stored and counted but
|
||||
// drawn as an empty cell.
|
||||
if (ctx.companyHolidaysEnabled && ctx.companyHolidaySet.has(dateStr)) {
|
||||
return withSchool({ background: '#F5D9A6', numColor: '#8A5A00' })
|
||||
}
|
||||
@@ -139,6 +140,18 @@ function baseDayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext
|
||||
|
||||
export function dayVisual(dateStr: string, dayOfWeek: number, ctx: DayVisualContext): DayVisual {
|
||||
const visual = baseDayVisual(dateStr, dayOfWeek, ctx)
|
||||
// Today rings whatever the day resolved to, so a vacation day or company
|
||||
// holiday logged for today keeps its fill and is visible.
|
||||
if (dateStr === ctx.todayStr) {
|
||||
const ring = 'inset 0 0 0 1.5px var(--m-ink)'
|
||||
visual.boxShadow = visual.boxShadow ? `${ring}, ${visual.boxShadow}` : ring
|
||||
// The strong ink only where the cell has nothing of its own to say. On a
|
||||
// pastel fill the fill's own ink is the readable one, and in dark mode
|
||||
// --m-ink is light and would vanish on it.
|
||||
if (!visual.segments && (visual.background === 'transparent' || visual.background === 'var(--m-ic)')) {
|
||||
visual.numColor = 'var(--m-ink)'
|
||||
}
|
||||
}
|
||||
// Shared calendars (#444/#667) draw inset rings over the base cell — capped at
|
||||
// two so tiny mini-grid cells stay readable. Nested inside the today ring.
|
||||
const rings = [...new Set((ctx.sharedMap?.[dateStr] || []).map(m => m.color))].slice(0, 2)
|
||||
|
||||
@@ -922,6 +922,41 @@ describe('AtlasPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-ATLAS-051: bucket search results escape the panel that clips them', () => {
|
||||
it('renders the result list outside the overflow-hidden desktop panel (#1899)', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({
|
||||
places: [
|
||||
{ name: 'Amsterdam', address: 'Amsterdam, North Holland, Netherlands', lat: 52.37, lng: 4.89 },
|
||||
{ name: 'New Amsterdam Island', address: 'French Southern and Antarctic Lands, France', lat: -37.8, lng: 77.5 },
|
||||
{ name: 'City of Amsterdam', address: 'North Holland, Netherlands', lat: 52.35, lng: 4.9 },
|
||||
],
|
||||
})),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<AtlasPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText('Bucket List').length).toBeGreaterThan(0));
|
||||
await user.click(screen.getAllByText('Bucket List')[0]);
|
||||
await waitFor(() => expect(screen.getAllByRole('button', { name: /add place/i }).length).toBeGreaterThan(0));
|
||||
await user.click(screen.getAllByRole('button', { name: /add place/i })[0]);
|
||||
|
||||
const nameInput = await screen.findByPlaceholderText(/name \(country, city, place\.\.\.\)/i);
|
||||
await user.type(nameInput, 'Amsterdam{Enter}');
|
||||
|
||||
// The first hit must be present — it is the one the panel used to swallow.
|
||||
const firstHit = await screen.findByText('Amsterdam, North Holland, Netherlands');
|
||||
const list = firstHit.closest('button')!.parentElement as HTMLElement;
|
||||
|
||||
// Portalled straight onto the body, so no ancestor's overflow can clip it.
|
||||
expect(list.style.position).toBe('fixed');
|
||||
expect(list.parentElement).toBe(document.body);
|
||||
expect(list.closest('.overflow-hidden')).toBeNull();
|
||||
expect(screen.getByText('City of Amsterdam')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-ATLAS-033: GeoJSON with unvisited country covers onEachFeature else branch', () => {
|
||||
it('loads map with visited FR and unvisited DE, covering both onEachFeature branches', async () => {
|
||||
const geoJsonFRandDE = {
|
||||
@@ -1291,7 +1326,7 @@ describe('AtlasPage', () => {
|
||||
() => {
|
||||
const els = screen.queryAllByText('Tokyo');
|
||||
// Filter to those that are inside the search results dropdown (not the input itself)
|
||||
const resultEl = els.find((el) => el.tagName !== 'INPUT' && el.closest('div[style*="position: absolute"]'));
|
||||
const resultEl = els.find((el) => el.tagName !== 'INPUT' && el.closest('div[style*="position: fixed"]'));
|
||||
if (!resultEl) throw new Error('Tokyo result not found in dropdown');
|
||||
return resultEl;
|
||||
},
|
||||
@@ -1571,12 +1606,12 @@ describe('AtlasPage', () => {
|
||||
await user.type(nameInput, 'Paris');
|
||||
fireEvent.keyDown(nameInput, { key: 'Enter' });
|
||||
|
||||
// Wait for Paris result in the dropdown (absolute-positioned list)
|
||||
// Wait for Paris result in the dropdown (portalled, fixed-position list)
|
||||
const parisBtn = await waitFor(
|
||||
() => {
|
||||
const btns = Array.from(document.querySelectorAll('button'));
|
||||
const btn = btns.find(
|
||||
(b) => b.textContent?.includes('Paris') && b.closest('[style*="position: absolute"]'),
|
||||
(b) => b.textContent?.includes('Paris') && b.closest('[style*="position: fixed"]'),
|
||||
);
|
||||
if (!btn) throw new Error('Paris dropdown result not found');
|
||||
return btn;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import apiClient from '../api/client'
|
||||
@@ -6,7 +7,7 @@ import CustomSelect from '../components/shared/CustomSelect'
|
||||
import EmptyState from '../components/shared/EmptyState'
|
||||
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
|
||||
import type { TranslationFn } from '../types'
|
||||
import { A2_TO_A3, countryCodeToFlag, withCountryMarkedVisited, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
|
||||
import { A2_TO_A3, countryCodeToFlag, findBucketDuplicate, isBucketDuplicateError, withCountryMarkedVisited, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
|
||||
import { continentForCountry } from '@trek/shared'
|
||||
import { useAtlas } from './atlas/useAtlas'
|
||||
import AtlasCountrySearch from './atlas/AtlasCountrySearch'
|
||||
@@ -392,10 +393,20 @@ function AtlasPageDesktop(): React.ReactElement {
|
||||
</button>
|
||||
<button onClick={async () => {
|
||||
const targetDate = bucketMonth > 0 && bucketYear > 0 ? `${bucketYear}-${String(bucketMonth).padStart(2, '0')}` : null
|
||||
// #1898: one entry per target date. The dialog stays open on a
|
||||
// duplicate so another month can be picked right away.
|
||||
if (findBucketDuplicate(bucketList, { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate, lat: null, lng: null })) {
|
||||
toast.error(t('atlas.bucketDuplicate'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await apiClient.post('/addons/atlas/bucket-list', { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate })
|
||||
setBucketList(prev => [r.data.item, ...prev])
|
||||
} catch (err) {
|
||||
if (isBucketDuplicateError(err)) {
|
||||
toast.error(t('atlas.bucketDuplicate'))
|
||||
return
|
||||
}
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
setBucketMonth(0); setBucketYear(0)
|
||||
@@ -470,6 +481,7 @@ interface SidebarContentProps {
|
||||
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
||||
const { language } = useTranslation()
|
||||
const statsContentRef = useRef<HTMLDivElement>(null)
|
||||
const bucketSearchRowRef = useRef<HTMLDivElement>(null)
|
||||
const [statsWidth, setStatsWidth] = useState<number | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const el = statsContentRef.current
|
||||
@@ -556,8 +568,8 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
{showBucketAdd ? (
|
||||
<div style={{ padding: '8px 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{/* Search or manual name */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<div>
|
||||
<div ref={bucketSearchRowRef} style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={bucketForm.name || bucketSearch}
|
||||
onChange={e => { const v = e.target.value; if (bucketForm.name) setBucketForm({ ...bucketForm, name: v }); else setBucketSearch(v) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !bucketForm.name) onSearchBucket(); else if (e.key === 'Enter') onAddBucket(); if (e.key === 'Escape') setShowBucketAdd(false) }}
|
||||
@@ -581,15 +593,36 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{bucketSearchResults.length > 0 && (
|
||||
<div className="bg-surface-card border border-edge" style={{ position: 'absolute', bottom: '100%', left: 0, right: 0, zIndex: 50, marginBottom: 4, borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.12)', maxHeight: 160, overflowY: 'auto' }}>
|
||||
{/* Portalled to the body: the panel that hosts this form clips its
|
||||
overflow, which used to swallow the top of the list (#1899). */}
|
||||
{bucketSearchResults.length > 0 && createPortal(
|
||||
<div className="bg-surface-card border border-edge" style={{
|
||||
position: 'fixed',
|
||||
...(() => {
|
||||
const rect = bucketSearchRowRef.current?.getBoundingClientRect()
|
||||
if (!rect) return { left: 0, top: 0, width: 240, maxHeight: 160 }
|
||||
const above = rect.top - 12
|
||||
const below = window.innerHeight - rect.bottom - 12
|
||||
const openUp = above >= below
|
||||
return {
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
// Never taller than the room on that side, so the list scrolls
|
||||
// instead of running off the edge of the viewport.
|
||||
maxHeight: Math.min(160, openUp ? above : below),
|
||||
...(openUp ? { bottom: window.innerHeight - rect.top + 4 } : { top: rect.bottom + 4 }),
|
||||
}
|
||||
})(),
|
||||
zIndex: 99999, borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.12)', overflowY: 'auto',
|
||||
}}>
|
||||
{bucketSearchResults.slice(0, 6).map((r, i) => (
|
||||
<button key={i} onClick={() => onSelectBucketPoi(r)} className="border-b border-edge-faint" style={{ display: 'flex', flexDirection: 'column', gap: 1, width: '100%', padding: '6px 10px', borderTop: 'none', borderLeft: 'none', borderRight: 'none', background: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{r.name}</span>
|
||||
{r.address && <span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }}>{r.address}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
{/* Selected place indicator */}
|
||||
|
||||
@@ -378,6 +378,26 @@ describe('AtlasPage wiring', () => {
|
||||
await waitFor(() => expect(body).toEqual({ name: 'Bretagne', country_code: 'FR', target_date: null }));
|
||||
});
|
||||
|
||||
it('FE-PAGE-ATLASW-018b: a country already wishlisted for that date is not posted again (#1898)', async () => {
|
||||
const post = vi.fn();
|
||||
server.use(http.post('/api/addons/atlas/bucket-list', () => {
|
||||
post();
|
||||
return HttpResponse.json({ item: buildBucketItem({ id: 99, name: 'Germany', country_code: 'DE' }) });
|
||||
}));
|
||||
const atlas = setAtlas({
|
||||
confirmAction: { type: 'bucket', code: 'DE', name: 'Germany' },
|
||||
bucketList: [buildBucketItem({ id: 7, name: 'Germany', country_code: 'DE' })],
|
||||
});
|
||||
render(<AtlasPage />);
|
||||
|
||||
fireEvent.click(screen.getByText('atlas.addToBucket'));
|
||||
|
||||
await waitFor(() => expect(toast).toHaveBeenCalledWith('atlas.bucketDuplicate', 'error', undefined));
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
// The popup stays open on the date step so another month can be picked.
|
||||
expect(atlas.setConfirmAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PAGE-ATLASW-018: a failing bucket post shows the error toast', async () => {
|
||||
server.use(
|
||||
http.post('/api/addons/atlas/bucket-list', () => HttpResponse.json({ error: 'Full' }, { status: 400 })),
|
||||
|
||||
@@ -45,6 +45,7 @@ function JourneyDetailPageDesktop() {
|
||||
hideSkeletons, setHideSkeletons,
|
||||
mapRef, fullMapRef, activeLocationId, handleMarkerClick, handleLocationClick,
|
||||
mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
|
||||
feedEdge, scrollFeedTo,
|
||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
|
||||
} = useJourneyDetail()
|
||||
|
||||
@@ -77,6 +78,20 @@ function JourneyDetailPageDesktop() {
|
||||
const showMobileGallery = isMobile && view === 'gallery'
|
||||
const isMobileChromeless = showMobileCombined || showMobileGallery
|
||||
|
||||
// Below 1024px the hero is gone, so its two actions have to live in the
|
||||
// floating bar instead — they were unreachable there until #1848. Only one of
|
||||
// the two hosts is mounted at a time, so both can carry the same labels.
|
||||
const openBookPdf = () => {
|
||||
import('../components/PDF/JourneyBookPDF').then(m => m.downloadJourneyBookPDF(current, { t, locale }))
|
||||
}
|
||||
const toggleSkeletons = async () => {
|
||||
const next = !hideSkeletons
|
||||
setHideSkeletons(next)
|
||||
await journeyApi.updatePreferences(current.id, { hide_skeletons: next })
|
||||
}
|
||||
const skeletonLabel = hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')
|
||||
const barButton = 'w-10 h-10 flex-shrink-0 rounded-lg bg-surface-elevated backdrop-blur-lg border border-edge shadow-lg text-content-secondary flex items-center justify-center hover:bg-surface-hover active:scale-95 transition-transform'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
<Navbar />
|
||||
@@ -108,7 +123,8 @@ function JourneyDetailPageDesktop() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating top bar on mobile Journey + Gallery views: back | tabs+title | settings */}
|
||||
{/* Floating top bar on mobile Journey + Gallery views:
|
||||
back | tabs+title | book export, suggestions, settings */}
|
||||
{isMobileChromeless && (
|
||||
<div
|
||||
className="fixed left-0 right-0 z-30 flex items-start justify-between gap-2 px-4"
|
||||
@@ -117,19 +133,19 @@ function JourneyDetailPageDesktop() {
|
||||
<button
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className="w-10 h-10 flex-shrink-0 rounded-lg bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 shadow-lg text-zinc-700 dark:text-zinc-200 flex items-center justify-center hover:bg-white dark:hover:bg-zinc-800 active:scale-95 transition-transform"
|
||||
className={barButton}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0 flex justify-center">
|
||||
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
|
||||
<div className="flex bg-surface-elevated backdrop-blur-lg border border-edge rounded-lg overflow-hidden shadow-lg">
|
||||
<button
|
||||
onClick={() => setView('timeline')}
|
||||
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||
view === 'timeline'
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
? 'bg-inverse text-inverse-text'
|
||||
: 'text-content-muted hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
<MapPin size={13} />
|
||||
@@ -139,8 +155,8 @@ function JourneyDetailPageDesktop() {
|
||||
onClick={() => setView('gallery')}
|
||||
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||
view === 'gallery'
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
? 'bg-inverse text-inverse-text'
|
||||
: 'text-content-muted hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
<Grid size={13} />
|
||||
@@ -149,17 +165,27 @@ function JourneyDetailPageDesktop() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEditJourney ? (
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
aria-label={t('journey.settings.title')}
|
||||
className="w-10 h-10 flex-shrink-0 rounded-lg bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 shadow-lg text-zinc-700 dark:text-zinc-200 flex items-center justify-center hover:bg-white dark:hover:bg-zinc-800 active:scale-95 transition-transform"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={openBookPdf} aria-label={t('journey.pdf.saveAsPdf')} className={barButton}>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-10 h-10 flex-shrink-0" aria-hidden />
|
||||
)}
|
||||
<button
|
||||
onClick={toggleSkeletons}
|
||||
aria-label={skeletonLabel}
|
||||
className={`${barButton} ${hideSkeletons ? 'bg-surface-selected' : ''}`}
|
||||
>
|
||||
{hideSkeletons ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
{canEditJourney && (
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
aria-label={t('journey.settings.title')}
|
||||
className={barButton}
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -183,8 +209,11 @@ function JourneyDetailPageDesktop() {
|
||||
>
|
||||
<div className={isMobile ? '' : 'w-full px-8 py-6'}>
|
||||
|
||||
{/* Hero card — hidden on mobile gallery/journey views (floating top bar handles branding there) */}
|
||||
<div className={`px-4 md:px-0 mb-6 ${isMobileChromeless ? 'hidden' : ''}`}>
|
||||
{/* Hero card — dropped on mobile gallery/journey views (floating top bar
|
||||
handles branding there). Unmounted rather than `hidden`, so its
|
||||
actions don't sit in the DOM as a second, invisible copy (#1848). */}
|
||||
{!isMobileChromeless && (
|
||||
<div className="px-4 md:px-0 mb-6">
|
||||
<div className="rounded-none md:rounded-[28px] -mx-4 md:mx-0 overflow-hidden relative p-5 md:p-7" style={{ background: pickGradient(current.id), color: 'white' }}>
|
||||
{current.cover_image && (
|
||||
<>
|
||||
@@ -210,36 +239,33 @@ function JourneyDetailPageDesktop() {
|
||||
{/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{lifecycle !== 'live' && lifecycle !== 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
<div className="inline-flex h-[34px] items-center gap-1.5 px-3.5 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle === 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
<div className="inline-flex h-[34px] items-center gap-1.5 px-3.5 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t('journey.status.archived')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => { import('../components/PDF/JourneyBookPDF').then(m => m.downloadJourneyBookPDF(current)) }} className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><Download size={14} /></button>
|
||||
<button onClick={openBookPdf} aria-label={t('journey.pdf.saveAsPdf')} className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><Download size={14} /></button>
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const next = !hideSkeletons
|
||||
setHideSkeletons(next)
|
||||
await journeyApi.updatePreferences(current.id, { hide_skeletons: next })
|
||||
}}
|
||||
onClick={toggleSkeletons}
|
||||
aria-label={skeletonLabel}
|
||||
className={`w-[34px] h-[34px] rounded-full backdrop-blur flex items-center justify-center ${hideSkeletons ? 'bg-white/30' : 'bg-white/15 hover:bg-white/25'}`}
|
||||
>
|
||||
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
<span className="absolute top-full mt-2 right-0 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
|
||||
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
|
||||
{skeletonLabel}
|
||||
</span>
|
||||
</div>
|
||||
{canEditJourney && (
|
||||
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
|
||||
<button onClick={() => setShowSettings(true)} aria-label={t('journey.settings.title')} className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,6 +292,7 @@ function JourneyDetailPageDesktop() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content (was a 2-col grid with right-sidebar panels;
|
||||
now single column inside the left feed — right pane is a
|
||||
@@ -427,6 +454,41 @@ function JourneyDetailPageDesktop() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Jump to the top (where adding lives) and back to the last entry
|
||||
(where reading left off) — #1088. Centred over the feed and
|
||||
clear of both edges: the right one belongs to the Add Entry
|
||||
buttons, the left to the reorder arrows. Zero-height sticky box
|
||||
so it rides the scroll without taking layout space, and each
|
||||
half appears only when there is somewhere to go. */}
|
||||
{!isMobile && (!feedEdge.atTop || !feedEdge.atBottom) && (
|
||||
<div className="sticky bottom-0 z-20 h-0 pointer-events-none">
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex gap-1.5 pointer-events-auto">
|
||||
{!feedEdge.atTop && (
|
||||
<button
|
||||
onClick={() => scrollFeedTo('top')}
|
||||
aria-label={t('journey.detail.jumpToTop')}
|
||||
title={t('journey.detail.jumpToTop')}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center shadow-md transition-transform hover:-translate-y-0.5"
|
||||
style={{ background: 'var(--vg-surf)', border: '1px solid var(--vg-line)', color: 'var(--vg-ink)' }}
|
||||
>
|
||||
<ChevronUp size={16} strokeWidth={2.4} />
|
||||
</button>
|
||||
)}
|
||||
{!feedEdge.atBottom && (
|
||||
<button
|
||||
onClick={() => scrollFeedTo('bottom')}
|
||||
aria-label={t('journey.detail.jumpToLast')}
|
||||
title={t('journey.detail.jumpToLast')}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center shadow-md transition-transform hover:translate-y-0.5"
|
||||
style={{ background: 'var(--vg-surf)', border: '1px solid var(--vg-line)', color: 'var(--vg-ink)' }}
|
||||
>
|
||||
<ChevronDown size={16} strokeWidth={2.4} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -105,6 +105,7 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
|
||||
mapRef: { current: null }, fullMapRef: { current: null },
|
||||
activeLocationId: null, handleMarkerClick: vi.fn(), handleLocationClick: vi.fn(),
|
||||
mapEntries: [], sidebarMapItems: [], tripDates: new Set<string>(), isMobile: false,
|
||||
feedEdge: { atTop: true, atBottom: true }, scrollFeedTo: vi.fn(),
|
||||
loadJourney: vi.fn(), updateEntry: vi.fn(async () => {}), deleteEntry: vi.fn(async () => {}),
|
||||
reorderEntries: vi.fn(async () => {}), uploadPhotos: vi.fn(async () => ({ succeeded: [], failed: [] })),
|
||||
deletePhoto: vi.fn(async () => {}),
|
||||
@@ -154,11 +155,13 @@ describe('JourneyDetailPage wiring', () => {
|
||||
expect(hook.navigate).toHaveBeenCalledWith('/journey');
|
||||
});
|
||||
|
||||
it('FE-JRN-DETWIRE-004: the download button lazy-loads the PDF book renderer', async () => {
|
||||
it('FE-JRN-DETWIRE-004: the download button lazy-loads the PDF book renderer with the app language', async () => {
|
||||
const { hook } = setup();
|
||||
const buttons = screen.getAllByRole('button');
|
||||
fireEvent.click(buttons[1]);
|
||||
await waitFor(() => expect(mocks.downloadPdf).toHaveBeenCalledWith(hook.current));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.pdf.saveAsPdf' }));
|
||||
await waitFor(() => expect(mocks.downloadPdf).toHaveBeenCalledWith(
|
||||
hook.current,
|
||||
{ t: expect.any(Function), locale: 'en' },
|
||||
));
|
||||
});
|
||||
|
||||
it('FE-JRN-DETWIRE-005: the eye toggle flips hide_skeletons and persists the preference', async () => {
|
||||
@@ -407,9 +410,9 @@ describe('JourneyDetailPage wiring', () => {
|
||||
|
||||
it('FE-JRN-DETWIRE-024: the floating mobile bar switches views, goes back and opens settings', () => {
|
||||
const { hook } = setup({ isMobile: true });
|
||||
// The hero keeps its own back button in the DOM (hidden via class) — the
|
||||
// floating bar is the first one.
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'journey.detail.backToJourney' })[0]);
|
||||
// The hero is unmounted below 1024px, so the bar owns these labels alone.
|
||||
expect(screen.queryByRole('heading', { name: 'Japan 2026' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.detail.backToJourney' }));
|
||||
expect(hook.navigate).toHaveBeenCalledWith('/journey');
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /journey.share.gallery/ })[0]);
|
||||
@@ -421,9 +424,12 @@ describe('JourneyDetailPage wiring', () => {
|
||||
expect(hook.setShowSettings).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('FE-JRN-DETWIRE-025: a non-owner sees a spacer instead of the mobile settings button', () => {
|
||||
it('FE-JRN-DETWIRE-025: a non-owner gets the mobile bar without the settings button', () => {
|
||||
setup({ isMobile: true, canEditJourney: false });
|
||||
expect(screen.queryByRole('button', { name: 'journey.settings.title' })).not.toBeInTheDocument();
|
||||
// Export and the suggestions switch are not owner-only.
|
||||
expect(screen.getByRole('button', { name: 'journey.pdf.saveAsPdf' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'journey.skeletons.hide' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-JRN-DETWIRE-026: the fullscreen mobile entry view wires edit, delete and photos', () => {
|
||||
@@ -458,4 +464,71 @@ describe('JourneyDetailPage wiring', () => {
|
||||
setup({ current: buildDetail({ cover_image: 'covers/j.jpg' }) });
|
||||
expect(document.querySelector('img[src="/uploads/covers/j.jpg"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Jump buttons (#1088) ──────────────────────────────────────────────────
|
||||
|
||||
it('FE-JRN-DETWIRE-029: a feed with nowhere to jump shows no buttons', () => {
|
||||
setup({ feedEdge: { atTop: true, atBottom: true } });
|
||||
expect(screen.queryByLabelText('journey.detail.jumpToTop')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('journey.detail.jumpToLast')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-JRN-DETWIRE-030: each button appears only when that direction is available', () => {
|
||||
const { unmount } = setup({ feedEdge: { atTop: false, atBottom: true } });
|
||||
expect(screen.getByLabelText('journey.detail.jumpToTop')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('journey.detail.jumpToLast')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
setup({ feedEdge: { atTop: true, atBottom: false } });
|
||||
expect(screen.queryByLabelText('journey.detail.jumpToTop')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('journey.detail.jumpToLast')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-JRN-DETWIRE-031: the buttons scroll the feed to the matching end', () => {
|
||||
const { hook } = setup({ feedEdge: { atTop: false, atBottom: false } });
|
||||
|
||||
fireEvent.click(screen.getByLabelText('journey.detail.jumpToTop'));
|
||||
expect(hook.scrollFeedTo).toHaveBeenCalledWith('top');
|
||||
|
||||
fireEvent.click(screen.getByLabelText('journey.detail.jumpToLast'));
|
||||
expect(hook.scrollFeedTo).toHaveBeenLastCalledWith('bottom');
|
||||
});
|
||||
|
||||
it('FE-JRN-DETWIRE-032: the phone layout has its own chrome and gets no jump buttons', () => {
|
||||
setup({ isMobile: true, feedEdge: { atTop: false, atBottom: false } });
|
||||
expect(screen.queryByLabelText('journey.detail.jumpToTop')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Book export + suggestions below 1024px (#1848) ────────────────────────
|
||||
|
||||
it('FE-JRN-DETWIRE-033: the mobile bar exports the book with the app language', async () => {
|
||||
const { hook } = setup({ isMobile: true });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.pdf.saveAsPdf' }));
|
||||
await waitFor(() => expect(mocks.downloadPdf).toHaveBeenCalledWith(
|
||||
hook.current,
|
||||
{ t: expect.any(Function), locale: 'en' },
|
||||
));
|
||||
});
|
||||
|
||||
it('FE-JRN-DETWIRE-034: the mobile bar toggle flips hide_skeletons and persists it', async () => {
|
||||
const update = vi.spyOn(journeyApi, 'updatePreferences').mockResolvedValue({});
|
||||
const { hook } = setup({ isMobile: true });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.skeletons.hide' }));
|
||||
|
||||
expect(hook.setHideSkeletons).toHaveBeenCalledWith(true);
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(7, { hide_skeletons: true }));
|
||||
});
|
||||
|
||||
it('FE-JRN-DETWIRE-035: with suggestions hidden the mobile toggle offers to show them again', () => {
|
||||
setup({ isMobile: true, hideSkeletons: true });
|
||||
expect(screen.getByRole('button', { name: 'journey.skeletons.show' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'journey.skeletons.hide' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-JRN-DETWIRE-036: both actions stay reachable on the mobile gallery tab', () => {
|
||||
setup({ isMobile: true, view: 'gallery' });
|
||||
expect(screen.getByRole('button', { name: 'journey.pdf.saveAsPdf' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'journey.skeletons.hide' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -459,6 +459,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true) } : undefined}
|
||||
onOpenTransit={(r) => setTransitJourney(r)}
|
||||
onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true) } : undefined}
|
||||
onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true) } : undefined}
|
||||
onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true) } : undefined}
|
||||
onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true) } : undefined}
|
||||
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
|
||||
@@ -683,7 +684,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
@@ -789,7 +790,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} />
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} />
|
||||
<TripFormModal
|
||||
isOpen={showTripForm}
|
||||
onClose={() => setShowTripForm(false)}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
A2_TO_A3,
|
||||
countryStatus,
|
||||
findBucketDuplicate,
|
||||
isBucketDuplicateError,
|
||||
isCountryVisible,
|
||||
normalizeRegionName,
|
||||
withCountryMarkedVisited,
|
||||
@@ -210,3 +212,59 @@ describe('wishlistA3Codes', () => {
|
||||
expect(result).toEqual(new Set(['JPN']));
|
||||
});
|
||||
});
|
||||
|
||||
// The client half of #1898 — the same identity the server enforces, checked
|
||||
// before the request so the refusal arrives translated and instantly.
|
||||
describe('findBucketDuplicate (#1898)', () => {
|
||||
const candidate = { name: 'Japan', country_code: 'JP', target_date: null, lat: null, lng: null };
|
||||
|
||||
it('finds an entry with the same name, country and target date', () => {
|
||||
const existing = bucketItem({ id: 5, name: 'Japan', country_code: 'JP' });
|
||||
expect(findBucketDuplicate([existing], candidate)).toBe(existing);
|
||||
});
|
||||
|
||||
it('lets the same place through for a different target date', () => {
|
||||
const existing = bucketItem({ name: 'Japan', country_code: 'JP', target_date: '2027-05' });
|
||||
expect(findBucketDuplicate([existing], candidate)).toBeUndefined();
|
||||
expect(findBucketDuplicate([existing], { ...candidate, target_date: '2028-09' })).toBeUndefined();
|
||||
expect(findBucketDuplicate([existing], { ...candidate, target_date: '2027-05' })).toBe(existing);
|
||||
});
|
||||
|
||||
it('treats an empty string and null as the same "not set"', () => {
|
||||
const undated = bucketItem({ name: 'Japan', country_code: 'JP', target_date: null });
|
||||
expect(findBucketDuplicate([undated], { ...candidate, target_date: '' })).toBe(undated);
|
||||
const blankCountry = bucketItem({ name: 'Japan', country_code: '' });
|
||||
expect(findBucketDuplicate([blankCountry], { ...candidate, country_code: null })).toBe(blankCountry);
|
||||
});
|
||||
|
||||
it('ignores surrounding whitespace and ASCII case in the name', () => {
|
||||
const existing = bucketItem({ name: 'Kyoto', country_code: 'JP' });
|
||||
expect(findBucketDuplicate([existing], { ...candidate, name: ' kyoto ' })).toBe(existing);
|
||||
});
|
||||
|
||||
it('keeps non-ASCII case apart, matching SQLite lower()', () => {
|
||||
// The server folds ASCII only, so folding more here would block a name the
|
||||
// server would have accepted.
|
||||
const existing = bucketItem({ name: 'Ísland', country_code: 'IS' });
|
||||
expect(findBucketDuplicate([existing], { ...candidate, name: 'ísland', country_code: 'IS' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('separates the same name in another country or at other coordinates', () => {
|
||||
const existing = bucketItem({ name: 'Altstadt', country_code: 'DE', lat: 48.13, lng: 11.57 });
|
||||
expect(findBucketDuplicate([existing], { ...candidate, name: 'Altstadt', country_code: 'AT', lat: 48.13, lng: 11.57 }))
|
||||
.toBeUndefined();
|
||||
expect(findBucketDuplicate([existing], { ...candidate, name: 'Altstadt', country_code: 'DE', lat: 50.94, lng: 6.96 }))
|
||||
.toBeUndefined();
|
||||
expect(findBucketDuplicate([existing], { ...candidate, name: 'Altstadt', country_code: 'DE', lat: 48.13, lng: 11.57 }))
|
||||
.toBe(existing);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBucketDuplicateError (#1898)', () => {
|
||||
it('recognises the 409 and nothing else', () => {
|
||||
expect(isBucketDuplicateError({ response: { status: 409 } })).toBe(true);
|
||||
expect(isBucketDuplicateError({ response: { status: 500 } })).toBe(false);
|
||||
expect(isBucketDuplicateError(new Error('offline'))).toBe(false);
|
||||
expect(isBucketDuplicateError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,6 +121,44 @@ export function normalizeRegionName(name: string): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
// SQLite's lower() folds ASCII only, and the server's duplicate check runs the
|
||||
// name through it — folding more here would block names the server accepts.
|
||||
function foldBucketName(name: string): string {
|
||||
return name.trim().replace(/[A-Z]/g, c => c.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry this wish would duplicate, if any (#1898). Identity mirrors the
|
||||
* server: name (trimmed, ASCII case-insensitive), country, target date and
|
||||
* coordinates — so a different target date stays a separate, allowed entry.
|
||||
* Empty string and null both mean "not set" because the forms send ''.
|
||||
* The server answers 409 either way; this is what lets the UI say so up front,
|
||||
* in the user's language and without losing what they typed.
|
||||
*/
|
||||
export function findBucketDuplicate(
|
||||
items: BucketItem[],
|
||||
candidate: Pick<BucketItem, 'name' | 'country_code' | 'target_date' | 'lat' | 'lng'>,
|
||||
): BucketItem | undefined {
|
||||
const name = foldBucketName(candidate.name)
|
||||
const country = candidate.country_code || null
|
||||
const targetDate = candidate.target_date || null
|
||||
const lat = candidate.lat ?? null
|
||||
const lng = candidate.lng ?? null
|
||||
return items.find(
|
||||
item =>
|
||||
foldBucketName(item.name) === name &&
|
||||
(item.country_code || null) === country &&
|
||||
(item.target_date || null) === targetDate &&
|
||||
(item.lat ?? null) === lat &&
|
||||
(item.lng ?? null) === lng,
|
||||
)
|
||||
}
|
||||
|
||||
/** A bucket-list write the server refused as a duplicate (#1898). */
|
||||
export function isBucketDuplicateError(err: unknown): boolean {
|
||||
return (err as { response?: { status?: number } } | null)?.response?.status === 409
|
||||
}
|
||||
|
||||
// Alpha-3 codes for bucket-list countries that aren't visited yet — the Atlas
|
||||
// map renders these with a hatched fill so they read as "on the wishlist"
|
||||
// instead of blending into the flat unvisited gray.
|
||||
|
||||
@@ -211,6 +211,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
describe('useAtlas', () => {
|
||||
@@ -460,6 +461,42 @@ describe('useAtlas', () => {
|
||||
expect(atlas.bucketForm.name).toBe('Lisbon');
|
||||
});
|
||||
|
||||
it('FE-HOOK-ATLAS-036: an entry already on the list is not posted again (#1898)', async () => {
|
||||
await mountAtlas({ bucket: [{ id: 3, name: 'Kyoto', lat: null, lng: null, country_code: null, notes: null, target_date: null }] });
|
||||
const post = vi.fn();
|
||||
server.use(http.post('/api/addons/atlas/bucket-list', () => { post(); return HttpResponse.json({ item: { id: 4 } }); }));
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast;
|
||||
|
||||
act(() => atlas.setBucketForm({ name: ' kyoto ', notes: '', lat: '', lng: '', target_date: '' }));
|
||||
await act(async () => { await atlas.handleAddBucketItem(); });
|
||||
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(atlas.bucketList).toHaveLength(1);
|
||||
// The form keeps what was typed so another date can be picked right away.
|
||||
expect(atlas.bucketForm.name).toBe(' kyoto ');
|
||||
expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
|
||||
|
||||
// The same place for another date is a different wish and does go out.
|
||||
act(() => atlas.setBucketForm({ name: 'Kyoto', notes: '', lat: '', lng: '', target_date: '2027-05' }));
|
||||
await act(async () => { await atlas.handleAddBucketItem(); });
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ATLAS-037: a 409 from the server surfaces as a toast instead of silence (#1898)', async () => {
|
||||
await mountAtlas();
|
||||
server.use(http.post('/api/addons/atlas/bucket-list', () => HttpResponse.json({ error: 'Already on your bucket list' }, { status: 409 })));
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast;
|
||||
|
||||
act(() => atlas.setBucketForm({ name: 'Lisbon', notes: '', lat: '', lng: '', target_date: '' }));
|
||||
await act(async () => { await atlas.handleAddBucketItem(); });
|
||||
|
||||
expect(atlas.bucketList).toHaveLength(0);
|
||||
expect(atlas.bucketForm.name).toBe('Lisbon');
|
||||
expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ATLAS-017: deleting drops the item, a failure keeps it', async () => {
|
||||
await mountAtlas({ bucket: [{ id: 3, name: 'Kyoto', lat: null, lng: null, country_code: 'JP', notes: null, target_date: null }] });
|
||||
server.use(http.delete('/api/addons/atlas/bucket-list/:id', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import apiClient, { mapsApi, pluginsApi, type PluginAtlasLayer } from '../../api/client'
|
||||
import L from 'leaflet'
|
||||
import type { GeoJsonFeatureCollection } from '../../types'
|
||||
import { A2_TO_A3, countryStatus, isCountryVisible, normalizeRegionName, withCountryMarkedVisited, wishlistA3Codes, countryColor, type AtlasData, type AtlasPlaceHit, type CountryDetail, type BucketItem } from './atlasModel'
|
||||
import { A2_TO_A3, countryStatus, findBucketDuplicate, isBucketDuplicateError, isCountryVisible, normalizeRegionName, withCountryMarkedVisited, wishlistA3Codes, countryColor, type AtlasData, type AtlasPlaceHit, type CountryDetail, type BucketItem } from './atlasModel'
|
||||
import { continentForCountry, type VisitStatus } from '@trek/shared'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
const PLANNED_KEY = 'trek_atlas_show_planned'
|
||||
|
||||
@@ -73,6 +75,7 @@ export function useAtlas() {
|
||||
const { t, language } = useTranslation()
|
||||
const { settings } = useSettingsStore()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const resolveName = useCountryNames(language)
|
||||
const dm = settings.dark_mode
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
@@ -867,18 +870,31 @@ export function useAtlas() {
|
||||
|
||||
const handleAddBucketItem = async (): Promise<void> => {
|
||||
if (!bucketForm.name.trim()) return
|
||||
const hasCoords = !!(bucketForm.lat && bucketForm.lng)
|
||||
const lat = hasCoords ? parseFloat(bucketForm.lat) : null
|
||||
const lng = hasCoords ? parseFloat(bucketForm.lng) : null
|
||||
const targetDate = bucketForm.target_date || (bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null)
|
||||
// #1898: this form never sends a country code, so the entry it would create
|
||||
// is identified by name, date and coordinates alone. Keep the form filled so
|
||||
// the user can just pick another date.
|
||||
if (findBucketDuplicate(bucketList, { name: bucketForm.name, country_code: null, target_date: targetDate, lat, lng })) {
|
||||
toast.error(t('atlas.bucketDuplicate'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data: Record<string, unknown> = { name: bucketForm.name.trim() }
|
||||
if (bucketForm.notes.trim()) data.notes = bucketForm.notes.trim()
|
||||
if (bucketForm.lat && bucketForm.lng) { data.lat = parseFloat(bucketForm.lat); data.lng = parseFloat(bucketForm.lng) }
|
||||
const targetDate = bucketForm.target_date || (bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null)
|
||||
if (hasCoords) { data.lat = lat; data.lng = lng }
|
||||
if (targetDate) data.target_date = targetDate
|
||||
const r = await apiClient.post('/addons/atlas/bucket-list', data)
|
||||
setBucketList(prev => [r.data.item, ...prev])
|
||||
setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' })
|
||||
setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0)
|
||||
setShowBucketAdd(false)
|
||||
} catch { /* */ }
|
||||
} catch (err) {
|
||||
// The 409 used to vanish into a silent catch, leaving the button looking broken.
|
||||
toast.error(isBucketDuplicateError(err) ? t('atlas.bucketDuplicate') : getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteBucketItem = async (id: number): Promise<void> => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-DETHOOK-001 to FE-JRN-DETHOOK-022
|
||||
// FE-JRN-DETHOOK-001 to FE-JRN-DETHOOK-025
|
||||
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
|
||||
import { useLocation } from 'react-router';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -335,4 +335,52 @@ describe('useJourneyDetail', () => {
|
||||
await waitFor(() => expect(invalidateSize).toHaveBeenCalled());
|
||||
expect(latest.view).toBe('gallery');
|
||||
});
|
||||
|
||||
// ── Jump to top / to the last entry (#1088) ────────────────────────────────
|
||||
|
||||
/** jsdom has no layout, so hand the feed the numbers a real scrollport would report. */
|
||||
function stubFeedScroll(metrics: { scrollTop: number; scrollHeight: number; clientHeight: number }) {
|
||||
const feed = screen.getByTestId('feed');
|
||||
for (const [prop, value] of Object.entries(metrics)) {
|
||||
Object.defineProperty(feed, prop, { value, configurable: true });
|
||||
}
|
||||
return feed;
|
||||
}
|
||||
|
||||
it('FE-JRN-DETHOOK-023: a feed short enough to read in one go offers no jump', async () => {
|
||||
setup();
|
||||
await waitFor(() => expect(latest.current).not.toBeNull());
|
||||
const feed = stubFeedScroll({ scrollTop: 0, scrollHeight: 900, clientHeight: 800 });
|
||||
|
||||
fireEvent.scroll(feed);
|
||||
await waitFor(() => expect(latest.feedEdge).toEqual({ atTop: true, atBottom: true }));
|
||||
});
|
||||
|
||||
it('FE-JRN-DETHOOK-024: deep in a long journal both directions are offered', async () => {
|
||||
setup();
|
||||
await waitFor(() => expect(latest.current).not.toBeNull());
|
||||
const feed = stubFeedScroll({ scrollTop: 3000, scrollHeight: 9000, clientHeight: 800 });
|
||||
|
||||
fireEvent.scroll(feed);
|
||||
await waitFor(() => expect(latest.feedEdge).toEqual({ atTop: false, atBottom: false }));
|
||||
|
||||
// Back at the bottom only the way up is left.
|
||||
Object.defineProperty(feed, 'scrollTop', { value: 8200, configurable: true });
|
||||
fireEvent.scroll(feed);
|
||||
await waitFor(() => expect(latest.feedEdge).toEqual({ atTop: false, atBottom: true }));
|
||||
});
|
||||
|
||||
it('FE-JRN-DETHOOK-025: the jumps scroll the feed, not the window', async () => {
|
||||
setup();
|
||||
await waitFor(() => expect(latest.current).not.toBeNull());
|
||||
const feed = stubFeedScroll({ scrollTop: 3000, scrollHeight: 9000, clientHeight: 800 });
|
||||
const scrollTo = vi.fn();
|
||||
Object.defineProperty(feed, 'scrollTo', { value: scrollTo, configurable: true });
|
||||
|
||||
latest.scrollFeedTo('top');
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
|
||||
|
||||
latest.scrollFeedTo('bottom');
|
||||
expect(scrollTo).toHaveBeenLastCalledWith({ top: 9000, behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DAY_COLORS } from '../../components/Journey/dayColors'
|
||||
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../../components/Journey/JourneyMapAuto'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { lockBodyScroll } from '../../utils/bodyScrollLock'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
import { createDraftJourneyEntry } from './JourneyDetailPage.helpers'
|
||||
|
||||
@@ -186,6 +187,58 @@ export function useJourneyDetail() {
|
||||
return () => scrollCleanupRef.current?.()
|
||||
}, [current?.entries, setupScrollSync])
|
||||
|
||||
// ── Jump to top / to the last entry (#1088) ───────────────────────────────
|
||||
// A long journal is a long scroll: everything that adds to it — the entry
|
||||
// button, the gallery upload — sits at the top, and where you were reading
|
||||
// sits at the bottom. Two buttons beat two trips of the scroll wheel.
|
||||
const [feedEdge, setFeedEdge] = useState<{ atTop: boolean; atBottom: boolean }>({ atTop: true, atBottom: true })
|
||||
|
||||
useEffect(() => {
|
||||
const feed = feedRef.current
|
||||
if (!feed) return
|
||||
// 400px of travel before either button is worth offering; below that the
|
||||
// scroll is short enough that a button is just clutter.
|
||||
const THRESHOLD = 400
|
||||
let frame: number | null = null
|
||||
let pending = false
|
||||
const measure = () => {
|
||||
pending = false
|
||||
const el = feedRef.current
|
||||
if (!el) return
|
||||
const scrolled = el.scrollTop
|
||||
const remaining = el.scrollHeight - el.clientHeight - scrolled
|
||||
setFeedEdge(prev => {
|
||||
const next = { atTop: scrolled <= THRESHOLD, atBottom: remaining <= THRESHOLD }
|
||||
return prev.atTop === next.atTop && prev.atBottom === next.atBottom ? prev : next
|
||||
})
|
||||
}
|
||||
// The "already scheduled" flag is its own variable rather than the frame id:
|
||||
// the id is only assigned once requestAnimationFrame returns, which is after
|
||||
// the callback has already run whenever rAF fires synchronously.
|
||||
const onScroll = () => {
|
||||
if (pending) return
|
||||
pending = true
|
||||
frame = window.requestAnimationFrame(measure)
|
||||
}
|
||||
feed.addEventListener('scroll', onScroll, { passive: true })
|
||||
measure()
|
||||
// Entries load in batches, so the scrollable height grows after mount.
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(feed)
|
||||
if (feed.firstElementChild) ro.observe(feed.firstElementChild)
|
||||
return () => {
|
||||
feed.removeEventListener('scroll', onScroll)
|
||||
ro.disconnect()
|
||||
if (frame != null) window.cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [current?.id, view])
|
||||
|
||||
const scrollFeedTo = useCallback((edge: 'top' | 'bottom') => {
|
||||
const el = feedRef.current
|
||||
if (!el) return
|
||||
el.scrollTo({ top: edge === 'top' ? 0 : el.scrollHeight, behavior: 'smooth' })
|
||||
}, [])
|
||||
|
||||
const handleMarkerClick = useCallback((entryId: string) => {
|
||||
const el = document.querySelector(`[data-entry-id="${entryId}"]`)
|
||||
if (!el) return
|
||||
@@ -221,12 +274,11 @@ export function useJourneyDetail() {
|
||||
}, [view])
|
||||
|
||||
// On desktop we run a two-pane layout where only the feed column scrolls;
|
||||
// the body must not scroll underneath it. Restore on unmount.
|
||||
// the body must not scroll underneath it. Goes through the shared counter so a
|
||||
// modal opening on top of it cannot hand the page back early (#1809).
|
||||
useEffect(() => {
|
||||
if (isMobile) return
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = prev }
|
||||
return lockBodyScroll()
|
||||
}, [isMobile])
|
||||
|
||||
// Map only shows real journal entries — skeletons are trip-derived
|
||||
@@ -303,6 +355,7 @@ export function useJourneyDetail() {
|
||||
hideSkeletons, setHideSkeletons,
|
||||
mapRef, fullMapRef, activeLocationId, handleMarkerClick, handleLocationClick,
|
||||
mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
|
||||
feedEdge, scrollFeedTo,
|
||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ export function useTripPlanner() {
|
||||
// Public transit (#1065): open the TransportModal in its Automated mode, seed
|
||||
// the search (change-route), and show the journey view for a saved entry.
|
||||
const [transportModalAutomated, setTransportModalAutomated] = useState<boolean>(false)
|
||||
const [transitPrefill, setTransitPrefill] = useState<{ from?: { name: string; lat: number; lng: number } | null; to?: { name: string; lat: number; lng: number } | null } | null>(null)
|
||||
const [transitPrefill, setTransitPrefill] = useState<{ from?: { name: string; lat: number; lng: number } | null; to?: { name: string; lat: number; lng: number } | null; time?: string | null } | null>(null)
|
||||
const [transitJourney, setTransitJourney] = useState<Reservation | null>(null)
|
||||
|
||||
// The bottom-nav "+" is context-aware per tab: on the Bookings / Transports tabs
|
||||
@@ -545,6 +545,7 @@ export function useTripPlanner() {
|
||||
}
|
||||
}
|
||||
toast.success(t('trip.toast.placeUpdated'))
|
||||
return { id: editingPlace.id }
|
||||
} else {
|
||||
const place = await tripActions.addPlace(tripId, data)
|
||||
if (pendingFiles?.length > 0 && place?.id) {
|
||||
@@ -562,6 +563,9 @@ export function useTripPlanner() {
|
||||
await tripActions.deletePlace(tripId, capturedId)
|
||||
})
|
||||
}
|
||||
// Handed back so the form can link an expense to a place that did not
|
||||
// exist a moment ago (#1298), the same way the booking modals work.
|
||||
return place?.id ? { id: place.id } : undefined
|
||||
}
|
||||
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-STORE-COLLECTION-001 to FE-STORE-COLLECTION-062
|
||||
// FE-STORE-COLLECTION-001 to FE-STORE-COLLECTION-063
|
||||
//
|
||||
// The store owns the optimistic updates (status, labels, reorder, bulk delete) and the
|
||||
// reload chains that follow every mutation, so the API layer is mocked here and the
|
||||
@@ -227,11 +227,17 @@ describe('collectionStore — setActive', () => {
|
||||
|
||||
it('FE-STORE-COLLECTION-010: setActive(id) delegates to loadCollection', async () => {
|
||||
vi.mocked(collectionsApi.get).mockResolvedValue(detail({ places: [buildPlace()] }))
|
||||
useCollectionStore.setState({ selectMode: true, selectedIds: [10], selectedPlaceId: 10, labelFilter: [3] })
|
||||
|
||||
await store().setActive(1)
|
||||
|
||||
expect(collectionsApi.get).toHaveBeenCalledWith(1)
|
||||
expect(store().activeId).toBe(1)
|
||||
// Switching lists still drops what belongs to the list we came from.
|
||||
expect(store().selectMode).toBe(false)
|
||||
expect(store().selectedIds).toEqual([])
|
||||
expect(store().selectedPlaceId).toBeNull()
|
||||
expect(store().labelFilter).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-STORE-COLLECTION-011: refreshActive() is a no-op without an active list', async () => {
|
||||
@@ -241,13 +247,36 @@ describe('collectionStore — setActive', () => {
|
||||
expect(collectionsApi.list).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-STORE-COLLECTION-012: refreshActive() re-runs setActive for the current list', async () => {
|
||||
it('FE-STORE-COLLECTION-012: refreshActive() re-reads the current list', async () => {
|
||||
useCollectionStore.setState({ activeId: 1 })
|
||||
|
||||
await store().refreshActive()
|
||||
|
||||
expect(collectionsApi.get).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('FE-STORE-COLLECTION-062: refreshActive() keeps the selection and the label filter', async () => {
|
||||
vi.mocked(collectionsApi.get).mockResolvedValue(detail({ places: [buildPlace({ id: 10 }), buildPlace({ id: 11 })] }))
|
||||
useCollectionStore.setState({ activeId: 1, selectMode: true, selectedIds: [10, 11], selectedPlaceId: 11, labelFilter: [3] })
|
||||
|
||||
await store().refreshActive()
|
||||
|
||||
expect(store().selectMode).toBe(true)
|
||||
expect(store().selectedIds).toEqual([10, 11])
|
||||
expect(store().selectedPlaceId).toBe(11)
|
||||
expect(store().labelFilter).toEqual([3])
|
||||
})
|
||||
|
||||
it('FE-STORE-COLLECTION-063: refreshActive() drops selected places the reload no longer has', async () => {
|
||||
vi.mocked(collectionsApi.get).mockResolvedValue(detail({ places: [buildPlace({ id: 10 })] }))
|
||||
useCollectionStore.setState({ activeId: 1, selectMode: true, selectedIds: [10, 11], selectedPlaceId: 11 })
|
||||
|
||||
await store().refreshActive()
|
||||
|
||||
expect(store().selectedIds).toEqual([10])
|
||||
expect(store().selectedPlaceId).toBeNull()
|
||||
expect(store().selectMode).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectionStore — list CRUD', () => {
|
||||
|
||||
@@ -94,6 +94,49 @@ interface CollectionState {
|
||||
clearSelection: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Load whatever `id` points at (a list, the "All saved" union, or nothing) into
|
||||
* the store. Split out of setActive() because switching lists also has to drop
|
||||
* the per-list UI state, while re-reading the list that is already open must not
|
||||
* (#1921: every add from the still-open add-place dialog runs a refresh).
|
||||
*/
|
||||
async function loadActive(
|
||||
set: (partial: Partial<CollectionState>) => void,
|
||||
get: () => CollectionState,
|
||||
id: ActiveCollectionId,
|
||||
): Promise<void> {
|
||||
if (id === null) {
|
||||
set({ activeId: null, places: [], members: [], labels: [] })
|
||||
return
|
||||
}
|
||||
if (id === ALL_SAVED) {
|
||||
set({ activeId: ALL_SAVED, members: [], labels: [], placesLoading: true })
|
||||
try {
|
||||
// Client-side union of every list the user owns or co-owns (no server change).
|
||||
// On first load the lists may not be fetched yet (loadAll still in flight),
|
||||
// which would union nothing — make sure they're loaded first.
|
||||
let lists = get().collections
|
||||
if (lists.length === 0) { await get().loadAll(); lists = get().collections }
|
||||
const results = await Promise.all(lists.map(l => collectionsApi.get(l.id).catch(() => null)))
|
||||
const seen = new Set<number>()
|
||||
const merged: CollectionPlace[] = []
|
||||
for (const res of results) {
|
||||
if (!res) continue
|
||||
for (const p of res.places) {
|
||||
if (seen.has(p.id)) continue
|
||||
seen.add(p.id)
|
||||
merged.push(p)
|
||||
}
|
||||
}
|
||||
set({ places: merged })
|
||||
} finally {
|
||||
set({ placesLoading: false })
|
||||
}
|
||||
return
|
||||
}
|
||||
await get().loadCollection(id)
|
||||
}
|
||||
|
||||
export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
collections: [],
|
||||
activeId: null,
|
||||
@@ -148,42 +191,22 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
setActive: async (id: ActiveCollectionId) => {
|
||||
// Labels are per-collection, so their filter can't carry across lists.
|
||||
set({ selectMode: false, selectedIds: [], selectedPlaceId: null, labelFilter: [] })
|
||||
if (id === null) {
|
||||
set({ activeId: null, places: [], members: [], labels: [] })
|
||||
return
|
||||
}
|
||||
if (id === ALL_SAVED) {
|
||||
set({ activeId: ALL_SAVED, members: [], labels: [], placesLoading: true })
|
||||
try {
|
||||
// Client-side union of every list the user owns or co-owns (no server change).
|
||||
// On first load the lists may not be fetched yet (loadAll still in flight),
|
||||
// which would union nothing — make sure they're loaded first.
|
||||
let lists = get().collections
|
||||
if (lists.length === 0) { await get().loadAll(); lists = get().collections }
|
||||
const results = await Promise.all(lists.map(l => collectionsApi.get(l.id).catch(() => null)))
|
||||
const seen = new Set<number>()
|
||||
const merged: CollectionPlace[] = []
|
||||
for (const res of results) {
|
||||
if (!res) continue
|
||||
for (const p of res.places) {
|
||||
if (seen.has(p.id)) continue
|
||||
seen.add(p.id)
|
||||
merged.push(p)
|
||||
}
|
||||
}
|
||||
set({ places: merged })
|
||||
} finally {
|
||||
set({ placesLoading: false })
|
||||
}
|
||||
return
|
||||
}
|
||||
await get().loadCollection(id)
|
||||
await loadActive(set, get, id)
|
||||
},
|
||||
|
||||
refreshActive: async () => {
|
||||
const { activeId } = get()
|
||||
if (activeId === null) return
|
||||
await get().setActive(activeId)
|
||||
// Same list, so select mode, the selection and the label filter stay put.
|
||||
await loadActive(set, get, activeId)
|
||||
// Only what the reload no longer contains can't stay selected.
|
||||
const ids = new Set(get().places.map(p => p.id))
|
||||
const { selectedIds, selectedPlaceId } = get()
|
||||
const kept = selectedIds.filter(id => ids.has(id))
|
||||
const keptPlaceId = selectedPlaceId != null && ids.has(selectedPlaceId) ? selectedPlaceId : null
|
||||
if (kept.length !== selectedIds.length || keptPlaceId !== selectedPlaceId) {
|
||||
set({ selectedIds: kept, selectedPlaceId: keptPlaceId })
|
||||
}
|
||||
},
|
||||
|
||||
createCollection: async (payload) => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { lockBodyScroll, bodyScrollLocks, resetBodyScrollLock } from './bodyScrollLock'
|
||||
|
||||
/**
|
||||
* The lock only became load-bearing with #1809 (below 768px the document is the
|
||||
* scroller), and the bug it has to rule out is an overlay clearing a lock that
|
||||
* another overlay still holds.
|
||||
*/
|
||||
describe('bodyScrollLock', () => {
|
||||
beforeEach(() => {
|
||||
resetBodyScrollLock()
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
it('FE-UTIL-SCROLLLOCK-001: locks the body and restores the previous value', () => {
|
||||
document.body.style.overflow = 'auto'
|
||||
const release = lockBodyScroll()
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
release()
|
||||
expect(document.body.style.overflow).toBe('auto')
|
||||
})
|
||||
|
||||
it('FE-UTIL-SCROLLLOCK-002: stacked overlays only unlock on the last release', () => {
|
||||
const first = lockBodyScroll()
|
||||
const second = lockBodyScroll()
|
||||
expect(bodyScrollLocks()).toBe(2)
|
||||
|
||||
second()
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
|
||||
first()
|
||||
expect(document.body.style.overflow).toBe('')
|
||||
expect(bodyScrollLocks()).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-UTIL-SCROLLLOCK-003: releasing twice leaves the other overlay lock intact', () => {
|
||||
const first = lockBodyScroll()
|
||||
const second = lockBodyScroll()
|
||||
|
||||
second()
|
||||
second()
|
||||
second()
|
||||
|
||||
expect(bodyScrollLocks()).toBe(1)
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
first()
|
||||
expect(document.body.style.overflow).toBe('')
|
||||
})
|
||||
|
||||
it('FE-UTIL-SCROLLLOCK-004: an unlock never sets a value the caller did not save', () => {
|
||||
document.body.style.overflow = 'scroll'
|
||||
const release = lockBodyScroll()
|
||||
const nested = lockBodyScroll()
|
||||
nested()
|
||||
release()
|
||||
expect(document.body.style.overflow).toBe('scroll')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The one body scroll lock every overlay in the app shares.
|
||||
*
|
||||
* Below 768px the document itself is the scroller now (#1809), so locking is no
|
||||
* longer cosmetic: an overlay that resets `body.style.overflow` unconditionally
|
||||
* really does unlock the page behind another overlay that is still open (a
|
||||
* system notice re-running its effect used to do exactly that to an open
|
||||
* sheet). Overlays stack, so the first lock remembers the original value and
|
||||
* only the last release puts it back.
|
||||
*/
|
||||
let locks = 0
|
||||
let savedOverflow = ''
|
||||
|
||||
/**
|
||||
* Locks body scrolling and returns the matching release. Releasing twice is a
|
||||
* no-op, so the return value can be used directly as an effect cleanup.
|
||||
*/
|
||||
export function lockBodyScroll(): () => void {
|
||||
if (locks === 0) {
|
||||
savedOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
locks += 1
|
||||
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) return
|
||||
released = true
|
||||
locks = Math.max(0, locks - 1)
|
||||
if (locks === 0) document.body.style.overflow = savedOverflow
|
||||
}
|
||||
}
|
||||
|
||||
/** How many locks are currently held. For tests and diagnostics. */
|
||||
export function bodyScrollLocks(): number {
|
||||
return locks
|
||||
}
|
||||
|
||||
/** Test seam: the counter is module state and outlives a single test case. */
|
||||
export function resetBodyScrollLock(): void {
|
||||
locks = 0
|
||||
savedOverflow = ''
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { render, screen, fireEvent } from '../../helpers/render';
|
||||
import MBottomNav from '../../../src/mobile/components/MBottomNav';
|
||||
import { useAddonStore } from '../../../src/store/addonStore';
|
||||
import { usePluginStore } from '../../../src/store/pluginStore';
|
||||
import { useSettingsStore } from '../../../src/store/settingsStore';
|
||||
import { DEFAULT_APPEARANCE } from '@trek/shared';
|
||||
|
||||
function LocationEcho() {
|
||||
const location = useLocation();
|
||||
@@ -25,10 +27,31 @@ function seedAddons(ids: string[]) {
|
||||
});
|
||||
}
|
||||
|
||||
type MobileNavCfg = { bar: string[]; more: string[] } | null;
|
||||
|
||||
/** A dock split as the Appearance customizer persists it; null = un-customised. */
|
||||
function seedMobileNav(cfg: MobileNavCfg) {
|
||||
const { settings } = useSettingsStore.getState();
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
...settings,
|
||||
appearance: { ...DEFAULT_APPEARANCE, mobileNav: cfg ?? { bar: [], more: [] } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** [left group, centre slot, right group] of the dock, in DOM order. */
|
||||
function dockSlots(container: HTMLElement) {
|
||||
const dock = container.querySelector('nav') as HTMLElement;
|
||||
const [left, centre, right] = Array.from(dock.children) as HTMLElement[];
|
||||
return { count: dock.children.length, left, centre, right };
|
||||
}
|
||||
|
||||
describe('MBottomNav', () => {
|
||||
beforeEach(() => {
|
||||
useAddonStore.setState({ addons: [], loaded: true });
|
||||
usePluginStore.setState({ plugins: [], loaded: true });
|
||||
seedMobileNav(null);
|
||||
});
|
||||
|
||||
it('FE-MOB-NAV-001: renders the dashboard tab plus enabled global addons', () => {
|
||||
@@ -180,4 +203,44 @@ describe('MBottomNav', () => {
|
||||
|
||||
expect(at()).toBe('/collections');
|
||||
});
|
||||
|
||||
it('FE-MOB-NAV-018: vacay owns its centre slot, so the dock offers no create action (#1811)', () => {
|
||||
seedAddons(['vacay', 'atlas']);
|
||||
const { container } = render(<MBottomNav />, { initialEntries: ['/vacay'] });
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument();
|
||||
// Nothing tappable in the middle at all: the screen's own FAB covers it, and
|
||||
// until it mounts an empty slot beats a wrong action.
|
||||
expect(dockSlots(container).centre).toBeEmptyDOMElement();
|
||||
expect(screen.getByRole('button', { name: 'Vacay' })).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
const DOCK_CONFIGS: [string, MobileNavCfg][] = [
|
||||
['the built-in dock', null],
|
||||
['vacay pinned alone', { bar: ['vacay'], more: ['atlas', 'journey', 'collections'] }],
|
||||
['vacay demoted to More', { bar: ['journey', 'collections'], more: ['vacay', 'atlas'] }],
|
||||
];
|
||||
|
||||
it.each(DOCK_CONFIGS)('FE-MOB-NAV-019: the empty vacay slot keeps the FAB geometry (%s)', (_label, cfg) => {
|
||||
seedAddons(['vacay', 'atlas', 'journey', 'collections']);
|
||||
seedMobileNav(cfg);
|
||||
|
||||
const dashboard = render(<MBottomNav />, { initialEntries: ['/dashboard'] });
|
||||
const withFab = dockSlots(dashboard.container);
|
||||
const groups = [withFab.left.children.length, withFab.right.children.length];
|
||||
const fabClasses = withFab.centre.className.split(' ');
|
||||
const slots = withFab.count;
|
||||
dashboard.unmount();
|
||||
|
||||
const vacay = render(<MBottomNav />, { initialEntries: ['/vacay'] });
|
||||
const placeholder = dockSlots(vacay.container);
|
||||
|
||||
expect(placeholder.count).toBe(slots);
|
||||
// Same 56px box as MFab, so both tab groups stay symmetric around the centre.
|
||||
['mx-2', 'h-14', 'w-14', 'flex-none'].forEach((cls) => {
|
||||
expect(fabClasses).toContain(cls);
|
||||
expect(placeholder.centre).toHaveClass(cls);
|
||||
});
|
||||
expect([placeholder.left.children.length, placeholder.right.children.length]).toEqual(groups);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,4 +57,64 @@ describe('MobileShell', () => {
|
||||
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
// #1809: iOS Safari only minimises its address bar when the document scrolls,
|
||||
// so the phone shell must not sit a scroll container between the two.
|
||||
it('FE-MOB-MSHELL-005: the phone shell owns no scroll container and grows with the document', () => {
|
||||
const { container } = render(
|
||||
<MobileShell isPhone><p>page body</p></MobileShell>,
|
||||
{ initialEntries: ['/dashboard'] },
|
||||
);
|
||||
|
||||
const root = container.querySelector('.m-root') as HTMLElement;
|
||||
// svh, not dvh: dvh grows while the toolbar retracts.
|
||||
expect(root.className).toContain('min-h-svh');
|
||||
expect(root.className).not.toContain('h-dvh');
|
||||
expect(root.querySelector(':scope > .overflow-y-auto')).toBeNull();
|
||||
|
||||
const content = screen.getByText('page body').parentElement as HTMLElement;
|
||||
expect(content.className).toContain('flex-1');
|
||||
expect(content.className).not.toContain('overflow');
|
||||
});
|
||||
|
||||
it('FE-MOB-MSHELL-006: paints background and screen gradient on a viewport-sized layer', () => {
|
||||
const { container } = render(
|
||||
<MobileShell isPhone><p>page body</p></MobileShell>,
|
||||
{ initialEntries: ['/dashboard'] },
|
||||
);
|
||||
|
||||
const root = container.querySelector('.m-root') as HTMLElement;
|
||||
const layer = root.querySelector('[aria-hidden="true"]') as HTMLElement;
|
||||
expect(layer).toBeInTheDocument();
|
||||
expect(layer.className).toContain('fixed');
|
||||
expect(layer.className).toContain('inset-0');
|
||||
expect(layer.className).toContain('var(--m-scr)');
|
||||
expect(layer.className).toContain('var(--m-bg)');
|
||||
expect(root.className).not.toContain('var(--m-bg)');
|
||||
expect(layer.className).toContain('-z-10');
|
||||
});
|
||||
|
||||
it('FE-MOB-MSHELL-006b: the content wrapper is not a stacking context', () => {
|
||||
// A positioned wrapper with a z-index traps every fixed overlay a screen
|
||||
// renders inside it: the vacay view/edit FAB (z-50) went underneath the dock
|
||||
// (z-40) when this carried `relative z-10`. The screens rely on their own
|
||||
// z-index competing with the chrome, so this has to stay unpositioned.
|
||||
render(<MobileShell isPhone><p>page body</p></MobileShell>, { initialEntries: ['/dashboard'] });
|
||||
|
||||
const content = screen.getByText('page body').parentElement as HTMLElement;
|
||||
expect(content.className).toContain('flex-1');
|
||||
expect(content.className).not.toMatch(/(^|\s)(relative|absolute|fixed|sticky)(\s|$)/);
|
||||
expect(content.className).not.toMatch(/z-\[?\d/);
|
||||
});
|
||||
|
||||
it('FE-MOB-MSHELL-007: the desktop branch keeps its own scroller', () => {
|
||||
render(
|
||||
<MobileShell isPhone={false}><p>page body</p></MobileShell>,
|
||||
{ initialEntries: ['/dashboard'] },
|
||||
);
|
||||
|
||||
const content = screen.getByText('page body').parentElement as HTMLElement;
|
||||
expect(content.className).toContain('overflow-y-auto');
|
||||
expect(content.className).toContain('md:overflow-visible');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -369,4 +369,15 @@ describe('MAdmin', () => {
|
||||
expect(screen.getByText('7')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ADMIN-018: the long admin flow scrolls with the document', async () => {
|
||||
// #1809: no height and no scroller of its own, otherwise iOS Safari never
|
||||
// sees the root scroller move and keeps its address bar at full height.
|
||||
const { container } = render(<MAdmin />);
|
||||
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
|
||||
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.className).not.toContain('h-full');
|
||||
expect(root.className).not.toContain('overflow-y-auto');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AtlasController } from '../../../../src/mobile/screens/atlas/atlas
|
||||
import type { CountryDetail } from '../../../../src/pages/atlas/atlasModel';
|
||||
import MAtlas from '../../../../src/mobile/screens/atlas/MAtlas';
|
||||
|
||||
// FE-MOB-ATLASSCR-001 to FE-MOB-ATLASSCR-019
|
||||
// FE-MOB-ATLASSCR-001 to FE-MOB-ATLASSCR-020
|
||||
|
||||
const mocks = vi.hoisted(() => ({ atlas: {} as AtlasController }));
|
||||
|
||||
@@ -261,4 +261,15 @@ describe('MAtlas', () => {
|
||||
// Japan has the later date but is only planned, so France leads and keeps "Visited".
|
||||
expect(rows.map((r) => r.textContent)).toEqual(['FranceVisited', 'JapanPlanned']);
|
||||
});
|
||||
|
||||
it('FE-MOB-ATLASSCR-020: the screen measures itself, not the shell', () => {
|
||||
// #1809: the shell scrolls with the document now and hands down no definite
|
||||
// height. A map on a percentage of an auto-height parent collapses to zero.
|
||||
const { container, rerender } = render(<MAtlas />);
|
||||
expect(container.firstElementChild).toHaveClass('h-dvh');
|
||||
|
||||
setAtlas({ loading: true });
|
||||
rerender(<MAtlas />);
|
||||
expect(container.firstElementChild).toHaveClass('h-dvh');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-MOB-COLADD-001 to FE-MOB-COLADD-015
|
||||
// FE-MOB-COLADD-001 to FE-MOB-COLADD-017
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
@@ -98,9 +98,10 @@ describe('MCollAddSheet', () => {
|
||||
await user.click(hitRow)
|
||||
expect(screen.getByPlaceholderText('common.name')).toHaveValue('Elbphilharmonie')
|
||||
expect(screen.getByPlaceholderText('collections.addPlaceSearch')).toHaveValue('Elbphilharmonie')
|
||||
// The result list collapses once a hit is taken, the address stays as a hint.
|
||||
// The result list collapses once a hit is taken; the address lands in the
|
||||
// editable field instead of the old read-only hint (#1870).
|
||||
expect(screen.queryByText('COMMON.SEARCH')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Platz der Deutschen Einheit 1')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('places.formAddressPlaceholder')).toHaveValue('Platz der Deutschen Einheit 1')
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-003: Enter in the search field runs the search and the panel can be dismissed', async () => {
|
||||
@@ -322,4 +323,39 @@ describe('MCollAddSheet', () => {
|
||||
await waitFor(() => expect(seen.body).toBeDefined())
|
||||
expect(seen.body).toMatchObject({ collection_id: 1 })
|
||||
})
|
||||
|
||||
// ── Typed address (#1870) ──────────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-COLADD-016: a hand-typed address is trimmed into the save request', async () => {
|
||||
const user = userEvent.setup()
|
||||
const seen = mockSave()
|
||||
render(<MCollAddSheet {...baseProps()} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('common.name'), 'Sternschanze')
|
||||
await user.type(screen.getByPlaceholderText('places.formAddressPlaceholder'), ' Schanzenstr. 1 ')
|
||||
await user.click(screen.getByRole('button', { name: /common.add/ }))
|
||||
|
||||
await waitFor(() => expect(seen.body).toBeDefined())
|
||||
expect(seen.body).toMatchObject({ name: 'Sternschanze', address: 'Schanzenstr. 1' })
|
||||
})
|
||||
|
||||
it('FE-MOB-COLADD-017: the address of a picked hit can be corrected before saving', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockSearch()
|
||||
const seen = mockSave()
|
||||
render(<MCollAddSheet {...baseProps()} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('collections.addPlaceSearch'), 'elbphil')
|
||||
await user.click(screen.getByRole('button', { name: 'common.search' }))
|
||||
await user.click(await screen.findByText('Elbphilharmonie'))
|
||||
|
||||
const addressInput = screen.getByPlaceholderText('places.formAddressPlaceholder')
|
||||
await user.clear(addressInput)
|
||||
await user.type(addressInput, 'Platz der Deutschen Einheit 4')
|
||||
await user.click(screen.getByRole('button', { name: /common.add/ }))
|
||||
|
||||
await waitFor(() => expect(seen.body).toBeDefined())
|
||||
// The corrected address is stored, the rest of the maps provenance survives.
|
||||
expect(seen.body).toMatchObject({ address: 'Platz der Deutschen Einheit 4', google_place_id: 'gp-1' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Category } from '../../../../src/types'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { useTranslation } from '../../../../src/i18n'
|
||||
|
||||
// FE-MOB-CPLSH-001 to FE-MOB-CPLSH-033
|
||||
// FE-MOB-CPLSH-001 to FE-MOB-CPLSH-037
|
||||
|
||||
// react-markdown ships ESM-only chunks jsdom chokes on; the sheet only needs
|
||||
// the raw description text to reach the renderer.
|
||||
@@ -177,8 +177,9 @@ describe('MCollPlaceSheet', () => {
|
||||
expect(screen.getByDisplayValue('https://narisawa.example/menu')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Must see' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Rainy day' })).toHaveAttribute('aria-pressed', 'false')
|
||||
// The read-only address disappears while editing.
|
||||
// The read-only address line gives way to the editable field (#1870).
|
||||
expect(screen.queryByText('2-6-15 Minami-Aoyama')).not.toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('2-6-15 Minami-Aoyama')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-013: saving sends the trimmed name, labels, category and normalised links', async () => {
|
||||
@@ -191,6 +192,8 @@ describe('MCollPlaceSheet', () => {
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({
|
||||
name: 'Narisawa Tokyo',
|
||||
// Untouched here, but the address rides along on every save (#1870).
|
||||
address: '2-6-15 Minami-Aoyama',
|
||||
description: 'Two stars, book early',
|
||||
links: [
|
||||
{ label: 'Menu', url: 'https://narisawa.example/menu' },
|
||||
@@ -378,4 +381,41 @@ describe('MCollPlaceSheet', () => {
|
||||
rerender(<Harness {...props} place={null} />)
|
||||
expect(screen.getByText('Narisawa')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Editable address (#1870) ───────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-CPLSH-034: the edit form seeds the address field from the place', () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('2-6-15 Minami-Aoyama')
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-035: a corrected address is trimmed into the patch', async () => {
|
||||
const { onSave } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('Street, City, Country'), { target: { value: ' 3-1-1 Kita-Aoyama ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ address: '3-1-1 Kita-Aoyama' })))
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-036: an emptied address is sent as null', async () => {
|
||||
const { onSave } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('Street, City, Country'), { target: { value: ' ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ address: null })))
|
||||
})
|
||||
|
||||
it('FE-MOB-CPLSH-037: cancel restores the stored address', () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('Street, City, Country'), { target: { value: 'Elsewhere' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.getByText('2-6-15 Minami-Aoyama')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('2-6-15 Minami-Aoyama')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('MCollections', () => {
|
||||
expect(screen.queryByRole('button', { name: 'collections.editListTitle' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'collections.share.title' })).not.toBeInTheDocument()
|
||||
// No per-list labels on the union.
|
||||
expect(screen.queryByRole('button', { name: /collections.linkLabel/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /collections.labels.title/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COLSCR-005: without an active list the generic page title is used', () => {
|
||||
@@ -365,7 +365,7 @@ describe('MCollections', () => {
|
||||
fireEvent.click(chips[1])
|
||||
expect(screen.getByText('collections.labels.empty')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.linkLabel/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.labels.title/ }))
|
||||
expect(fn('setShowLabelManager')).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -443,19 +443,18 @@ describe('MDashboard', () => {
|
||||
expect(navigate).toHaveBeenNthCalledWith(2, '/trips/17');
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-031: the brand tile scrolls the shell content back to the top', () => {
|
||||
// The screen scrolls inside the shell's container, not the window.
|
||||
const scroller = document.createElement('div');
|
||||
scroller.className = 'overflow-y-auto';
|
||||
it('FE-MOB-DASH-031: the brand tile scrolls the page back to the top', () => {
|
||||
// Since #1809 the document itself is the scroller on a phone, so there is no
|
||||
// shell container left to walk up to.
|
||||
const scrollTo = vi.fn();
|
||||
scroller.scrollTo = scrollTo;
|
||||
document.body.appendChild(scroller);
|
||||
render(<MDashboard />, { container: scroller });
|
||||
const original = window.scrollTo;
|
||||
window.scrollTo = scrollTo as unknown as typeof window.scrollTo;
|
||||
render(<MDashboard />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'TREK' }));
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
|
||||
scroller.remove();
|
||||
window.scrollTo = original;
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-032: the user menu backdrop closes it again', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-MOB-JLIST-001 to FE-MOB-JLIST-012
|
||||
// FE-MOB-JLIST-001 to FE-MOB-JLIST-013
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '../../../helpers/render';
|
||||
import MJourney from '../../../../src/mobile/screens/journey/MJourney';
|
||||
@@ -149,4 +149,14 @@ describe('MJourney', () => {
|
||||
fireEvent.click(cancels[cancels.length - 1]);
|
||||
expect(hook.setShowCreate).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-JLIST-013: the feed keeps its own scroller on a self-measured screen', () => {
|
||||
// #1809: the shell hands down no definite height any more, so the screen
|
||||
// sets one and the feed keeps scrolling inside it.
|
||||
mocks.journey = buildHook();
|
||||
const { container } = render(<MJourney />);
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('h-dvh');
|
||||
expect(container.querySelector('.overflow-y-auto')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-MOB-JDET-001 to FE-MOB-JDET-031
|
||||
// FE-MOB-JDET-001 to FE-MOB-JDET-037
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '../../../helpers/render';
|
||||
import MJourneyDetail from '../../../../src/mobile/screens/journey/MJourneyDetail';
|
||||
@@ -12,12 +12,17 @@ const mocks = vi.hoisted(() => ({
|
||||
focusMarker: vi.fn(),
|
||||
highlightMarker: vi.fn(),
|
||||
captured: {} as Record<string, Record<string, unknown>>,
|
||||
downloadPdf: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/pages/journeyDetail/useJourneyDetail', () => ({
|
||||
useJourneyDetail: () => mocks.detail,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/components/PDF/JourneyBookPDF', () => ({
|
||||
downloadJourneyBookPDF: mocks.downloadPdf,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/components/Journey/JourneyMapAuto', async () => {
|
||||
const React = await import('react');
|
||||
const Comp = React.forwardRef((props: Record<string, unknown>, ref: React.Ref<unknown>) => {
|
||||
@@ -151,6 +156,7 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
|
||||
navigate: vi.fn(),
|
||||
toast,
|
||||
t: (key: string) => key,
|
||||
locale: 'en-US',
|
||||
current,
|
||||
loading: false,
|
||||
canEditEntries: true,
|
||||
@@ -168,6 +174,7 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
|
||||
showSettings: false,
|
||||
setShowSettings: vi.fn(),
|
||||
hideSkeletons: false,
|
||||
setHideSkeletons: vi.fn(),
|
||||
sidebarMapItems: (current?.entries ?? []).map(e => ({ id: String(e.id), lat: 1, lng: 2, title: e.title ?? '' })),
|
||||
loadJourney: vi.fn(),
|
||||
updateEntry: vi.fn(async () => {}),
|
||||
@@ -462,14 +469,16 @@ describe('MJourneyDetail', () => {
|
||||
expect(hook.loadJourney).toHaveBeenCalledWith(12);
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-026: the settings button is hidden for a non-owner and shown for the owner', () => {
|
||||
it('FE-MOB-JDET-026: the overflow sheet opens the settings for an owner only', () => {
|
||||
const { hook, unmount } = setup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.settings.title' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.menu' }));
|
||||
fireEvent.click(screen.getByText('journey.settings.title'));
|
||||
expect(hook.setShowSettings).toHaveBeenCalledWith(true);
|
||||
unmount();
|
||||
|
||||
setup({ canEditJourney: false });
|
||||
expect(screen.queryByRole('button', { name: 'journey.settings.title' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.menu' }));
|
||||
expect(screen.queryByText('journey.settings.title')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-027: the lightbox renders the mapped photos and closes back into the hook', () => {
|
||||
@@ -556,4 +565,79 @@ describe('MJourneyDetail', () => {
|
||||
(mocks.captured.picker.onClose as () => void)();
|
||||
await waitFor(() => expect(screen.queryByTestId('provider-picker')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
// ── Book export + suggestions on the phone (#1848) ────────────────────────
|
||||
|
||||
it('FE-MOB-JDET-032: the overflow sheet exports the book in the app language', async () => {
|
||||
const { hook } = setup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.menu' }));
|
||||
fireEvent.click(screen.getByText('journey.pdf.saveAsPdf'));
|
||||
|
||||
await waitFor(() => expect(mocks.downloadPdf).toHaveBeenCalledWith(
|
||||
hook.current,
|
||||
{ t: expect.any(Function), locale: 'en-US' },
|
||||
));
|
||||
await waitFor(() => expect(screen.queryByText('journey.pdf.saveAsPdf')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-033: the suggestions switch flips hide_skeletons and persists it', async () => {
|
||||
const update = vi.spyOn(journeyApi, 'updatePreferences').mockResolvedValue({});
|
||||
const { hook } = setup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.menu' }));
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: 'journey.skeletons.hide' });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(hook.setHideSkeletons).toHaveBeenCalledWith(true);
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(12, { hide_skeletons: true }));
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-034: a failing preference write leaves the local flip standing', async () => {
|
||||
vi.spyOn(journeyApi, 'updatePreferences').mockRejectedValue(new Error('offline'));
|
||||
const { hook } = setup({ hideSkeletons: true });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.menu' }));
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: 'journey.skeletons.hide' });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await waitFor(() => expect(hook.setHideSkeletons).toHaveBeenCalledWith(false));
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── External photos in the phone entry editor (#1808) ─────────────────────
|
||||
|
||||
it('FE-MOB-JDET-035: the entry sheet gets the provider context and writes the picked photos', async () => {
|
||||
const trips = [{ trip_id: 3, added_at: 0, title: 'Tokyo', start_date: '2026-05-01', end_date: '2026-05-04', cover_image: null, currency: 'EUR', place_count: 2 }];
|
||||
const addProvider = vi.spyOn(journeyApi, 'addProviderPhotos').mockResolvedValue({ added: 1 });
|
||||
setup({ current: buildDetail({ trips }), editingEntry: buildEntry({ id: 4 }) });
|
||||
|
||||
expect(mocks.captured.entrySheet.userId).toBe(5);
|
||||
expect(mocks.captured.entrySheet.trips).toEqual(trips);
|
||||
|
||||
const onAdd = mocks.captured.entrySheet.onAddProviderPhotos as (id: number, g: Record<string, unknown>) => Promise<void>;
|
||||
await onAdd(4, { provider: 'immich', assetIds: ['a1'], passphrase: 'pw', mediaTypes: ['image'] });
|
||||
expect(addProvider).toHaveBeenCalledWith(4, 'immich', ['a1'], undefined, 'pw', ['image']);
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-036: a retried save updates the entry that was already created', async () => {
|
||||
const { hook } = setup({ editingEntry: buildEntry({ id: 0, title: null }) });
|
||||
const onSave = mocks.captured.entrySheet.onSave as (d: Record<string, unknown>, id?: number) => Promise<number>;
|
||||
|
||||
await expect(onSave({ title: 'Fresh' })).resolves.toBe(77);
|
||||
expect(createEntry).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second attempt hands back the id from the first one — no second create.
|
||||
await expect(onSave({ title: 'Fresh' }, 77)).resolves.toBe(77);
|
||||
expect(createEntry).toHaveBeenCalledTimes(1);
|
||||
expect(hook.updateEntry).toHaveBeenCalledWith(77, { title: 'Fresh' });
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-037: the screen measures itself, not the shell', () => {
|
||||
// #1809: the shell scrolls with the document now and hands down no definite
|
||||
// height. A map on a percentage of an auto-height parent collapses to zero.
|
||||
const { container } = setup();
|
||||
expect(container.firstElementChild).toHaveClass('h-dvh');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { mapsApi, weatherApi } from '../../../../src/api/client';
|
||||
import { useAddonStore } from '../../../../src/store/addonStore';
|
||||
import MJourneyEntrySheet from '../../../../src/mobile/screens/journey/MJourneyEntrySheet';
|
||||
import type { GalleryPhoto, JourneyEntry, JourneyPhoto } from '../../../../src/store/journeyStore';
|
||||
import type { ResilientResult, UploadProgress } from '../../../../src/utils/uploadQueue';
|
||||
@@ -71,7 +72,9 @@ describe('MJourneyEntrySheet quick capture', () => {
|
||||
location_lat: 1.3521,
|
||||
location_lng: 103.8198,
|
||||
weather: 'rainy',
|
||||
})
|
||||
}),
|
||||
// A new entry has nothing persisted yet, so no id to retry against (#1808).
|
||||
undefined,
|
||||
)
|
||||
);
|
||||
expect(props.onDone).toHaveBeenCalled();
|
||||
@@ -133,7 +136,8 @@ describe('MJourneyEntrySheet quick capture', () => {
|
||||
entry_time: '09:07',
|
||||
location_lat: null,
|
||||
location_lng: null,
|
||||
})
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
);
|
||||
expect(props.onDone).toHaveBeenCalled();
|
||||
@@ -220,6 +224,7 @@ interface MountOptions {
|
||||
quickCapture?: boolean;
|
||||
readOnly?: boolean;
|
||||
withDelete?: boolean;
|
||||
withProviderHook?: boolean;
|
||||
upload?: UploadFn;
|
||||
}
|
||||
|
||||
@@ -232,22 +237,26 @@ function mountSheet(sheetEntry: JourneyEntry, opts: MountOptions = {}) {
|
||||
const onClose = vi.fn();
|
||||
const onDone = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const onSave = vi.fn(async (_data: Record<string, unknown>) => 42);
|
||||
const onSave = vi.fn(async (_data: Record<string, unknown>, _existingEntryId?: number) => 42);
|
||||
const onUploadPhotos = vi.fn(opts.upload ?? defaultUpload);
|
||||
const onAddProviderPhotos = vi.fn(async (_entryId: number, _group: Record<string, unknown>) => {});
|
||||
const view = render(
|
||||
<MJourneyEntrySheet
|
||||
entry={sheetEntry}
|
||||
galleryPhotos={opts.galleryPhotos ?? []}
|
||||
quickCapture={opts.quickCapture ?? false}
|
||||
readOnly={opts.readOnly ?? false}
|
||||
userId={42}
|
||||
trips={[]}
|
||||
onClose={onClose}
|
||||
onSave={onSave}
|
||||
onUploadPhotos={onUploadPhotos}
|
||||
onAddProviderPhotos={opts.withProviderHook === false ? undefined : onAddProviderPhotos}
|
||||
onDelete={opts.withDelete ? onDelete : undefined}
|
||||
onDone={onDone}
|
||||
/>
|
||||
);
|
||||
return { ...view, onClose, onDone, onDelete, onSave, onUploadPhotos };
|
||||
return { ...view, onClose, onDone, onDelete, onSave, onUploadPhotos, onAddProviderPhotos };
|
||||
}
|
||||
|
||||
const multiFileInput = () => document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
|
||||
@@ -362,7 +371,7 @@ describe('MJourneyEntrySheet full editor', () => {
|
||||
tags: ['food', 'city'],
|
||||
pros_cons: { pros: ['Gelato'], cons: ['Crowds'] },
|
||||
type: undefined,
|
||||
});
|
||||
}, undefined);
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-006: removes a pro and a con row again', async () => {
|
||||
@@ -987,3 +996,153 @@ describe('MJourneyEntrySheet quick capture edge cases', () => {
|
||||
expect(await screen.findByText('Location access was denied. Allow it in your browser settings and try again.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// FE-MOB-JENTRY-041 to FE-MOB-JENTRY-047 — external photo providers in the phone
|
||||
// editor (#1808), mirroring the desktop EntryEditor mechanics.
|
||||
|
||||
const immichAddon = { id: 'immich', name: 'Immich', type: 'photo_provider', icon: 'camera', enabled: true };
|
||||
|
||||
function seedAddons(addons: Array<Record<string, unknown>>) {
|
||||
useAddonStore.setState({ addons: addons as never, loaded: true });
|
||||
}
|
||||
|
||||
function useConnectedImmich() {
|
||||
server.use(
|
||||
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
|
||||
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({
|
||||
assets: [{ id: 'asset-1', takenAt: '2026-03-15T09:00:00.000Z', mediaType: 'image' }],
|
||||
hasMore: false,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
describe('MJourneyEntrySheet external photos', () => {
|
||||
beforeEach(() => {
|
||||
toastSpy.mockClear();
|
||||
window.__addToast = toastSpy;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast;
|
||||
useAddonStore.setState({ addons: [], loaded: false });
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-041: hides the button while no provider is connected', async () => {
|
||||
let probes = 0;
|
||||
seedAddons([immichAddon]);
|
||||
server.use(http.get('/api/integrations/memories/immich/status', () => {
|
||||
probes += 1;
|
||||
return HttpResponse.json({ connected: false });
|
||||
}));
|
||||
mountSheet(buildEntry({ id: 5 }));
|
||||
|
||||
await waitFor(() => expect(probes).toBe(1));
|
||||
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-042: opens the embedded picker for a connected provider', async () => {
|
||||
seedAddons([immichAddon, { id: 'budget', name: 'Budget', type: 'feature', icon: 'wallet', enabled: true }]);
|
||||
useConnectedImmich();
|
||||
const user = userEvent.setup();
|
||||
mountSheet(buildEntry({ id: 5, location_lat: 41.9, location_lng: 12.5, location_name: 'Rome' }));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'External photos' }));
|
||||
|
||||
expect(await screen.findByTestId('journey-provider-picker-embedded')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nearby photos first · Rome')).toBeInTheDocument();
|
||||
// A single provider needs no chip row.
|
||||
expect(screen.queryByTestId('journey-external-provider-immich')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-043: stays out of the quick capture sheet', async () => {
|
||||
seedAddons([immichAddon]);
|
||||
useConnectedImmich();
|
||||
Object.defineProperty(navigator, 'geolocation', { configurable: true, value: undefined });
|
||||
mountSheet(buildEntry(), { quickCapture: true });
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Gallery' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-044: queues a picked photo and sends it after the save', async () => {
|
||||
seedAddons([immichAddon]);
|
||||
useConnectedImmich();
|
||||
const user = userEvent.setup();
|
||||
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry({ id: 5 }));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'External photos' }));
|
||||
await user.click(await screen.findByAltText(''));
|
||||
await user.click(screen.getByRole('button', { name: 'Add (1)' }));
|
||||
|
||||
const queued = await screen.findByRole('button', { name: /1 queued · Clear/ });
|
||||
expect(queued).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(onAddProviderPhotos).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({ provider: 'immich', assetIds: ['asset-1'] }),
|
||||
));
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-045: drops the queue again from the panel header', async () => {
|
||||
seedAddons([immichAddon]);
|
||||
useConnectedImmich();
|
||||
const user = userEvent.setup();
|
||||
const { onAddProviderPhotos } = mountSheet(buildEntry({ id: 5 }));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'External photos' }));
|
||||
await user.click(await screen.findByAltText(''));
|
||||
await user.click(screen.getByRole('button', { name: 'Add (1)' }));
|
||||
await user.click(await screen.findByRole('button', { name: /1 queued · Clear/ }));
|
||||
|
||||
expect(screen.queryByRole('button', { name: /queued/ })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(onAddProviderPhotos).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-046: a failed provider add keeps the queue and does not create a second entry', async () => {
|
||||
seedAddons([immichAddon]);
|
||||
useConnectedImmich();
|
||||
const user = userEvent.setup();
|
||||
// A brand new entry: the first save creates it, the provider write then fails.
|
||||
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry());
|
||||
onAddProviderPhotos.mockRejectedValueOnce(new Error('immich down'));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'External photos' }));
|
||||
await user.click(await screen.findByAltText(''));
|
||||
await user.click(screen.getByRole('button', { name: 'Add (1)' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 photo groups failed — save again to retry', 'error', undefined)
|
||||
);
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
expect(onSave.mock.calls[0][1]).toBeUndefined();
|
||||
|
||||
// Retry: the sheet hands back the id it already persisted, so the caller
|
||||
// updates that entry instead of creating a second one.
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1));
|
||||
expect(onSave).toHaveBeenCalledTimes(2);
|
||||
expect(onSave.mock.calls[1][1]).toBe(42);
|
||||
expect(onAddProviderPhotos).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-MOB-JENTRY-047: a skeleton with only provider photos is promoted to an entry', async () => {
|
||||
seedAddons([immichAddon]);
|
||||
useConnectedImmich();
|
||||
const user = userEvent.setup();
|
||||
const { onSave } = mountSheet(buildEntry({ id: 9, type: 'skeleton', title: 'Venice' }));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'External photos' }));
|
||||
await user.click(await screen.findByAltText(''));
|
||||
await user.click(screen.getByRole('button', { name: 'Add (1)' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({ type: 'entry' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,21 +3,34 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, waitFor } from '../../../helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store';
|
||||
import { buildSettings } from '../../../helpers/factories';
|
||||
import { buildAdmin, buildSettings, buildUser } from '../../../helpers/factories';
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore';
|
||||
import { useAuthStore } from '../../../../src/store/authStore';
|
||||
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
||||
import type { Settings } from '../../../../src/types';
|
||||
import MLlmConnectionSection from '../../../../src/mobile/screens/settings/MLlmConnectionSection';
|
||||
|
||||
let loadSettings = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
function seedLlm(over: Partial<Settings> = {}, updateSettings = vi.fn().mockResolvedValue(undefined)) {
|
||||
loadSettings = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ language: 'en', ...over }),
|
||||
isLoaded: true,
|
||||
updateSettings,
|
||||
loadSettings,
|
||||
});
|
||||
return updateSettings;
|
||||
}
|
||||
|
||||
function seedRole(role: 'user' | 'admin') {
|
||||
seedStore(useAuthStore, {
|
||||
user: role === 'admin' ? buildAdmin() : buildUser(),
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
function renderSection() {
|
||||
return render(
|
||||
<>
|
||||
@@ -30,30 +43,29 @@ function renderSection() {
|
||||
describe('MLlmConnectionSection', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedRole('user');
|
||||
seedLlm();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-001: defaults to the local provider with a base URL and no key field', () => {
|
||||
it('FE-MOB-SETLLM-001: defaults to OpenAI with a key field and no endpoint of its own', () => {
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByText('AI parsing')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Local \(Ollama\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('http://localhost:11434')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Stored encrypted. Leave blank to keep the current key.')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
|
||||
expect(screen.getByText('Stored encrypted. Leave blank to keep the current key.')).toBeInTheDocument();
|
||||
// The endpoint is instance configuration since #1772, so it has no row here.
|
||||
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('A self-hosted (Ollama) endpoint is set up once for the whole instance in the admin settings. You can still use your own OpenAI or Anthropic key here.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-002: hydrates provider, model, base URL and the multimodal switch', () => {
|
||||
seedLlm({
|
||||
llm_provider: 'openai',
|
||||
llm_model: 'gpt-4o-mini',
|
||||
llm_base_url: 'https://api.openai.com/v1',
|
||||
llm_multimodal: true,
|
||||
});
|
||||
it('FE-MOB-SETLLM-002: hydrates provider, model and the multimodal switch', () => {
|
||||
seedLlm({ llm_provider: 'openai', llm_model: 'gpt-4o-mini', llm_multimodal: true });
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('gpt-4o-mini')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('https://api.openai.com/v1')).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch', { name: 'Send documents as images' })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
@@ -64,7 +76,7 @@ describe('MLlmConnectionSection', () => {
|
||||
});
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByRole('button', { name: /Local \(Ollama\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -82,41 +94,39 @@ describe('MLlmConnectionSection', () => {
|
||||
expect(screen.getByPlaceholderText('API key')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-006: picking Anthropic hides the base URL and reveals the key field', async () => {
|
||||
it('FE-MOB-SETLLM-006: the key row stays through a provider change, since both are hosted', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSection();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local \(Ollama\)/ }));
|
||||
await user.click(screen.getByRole('button', { name: /OpenAI/ }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Anthropic' }));
|
||||
|
||||
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Stored encrypted. Leave blank to keep the current key.')).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-007: picking OpenAI keeps the base URL and adds the key field', async () => {
|
||||
it('FE-MOB-SETLLM-007: the picker offers exactly the two hosted providers', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSection();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local \(Ollama\)/ }));
|
||||
await user.click(await screen.findByRole('button', { name: 'OpenAI' }));
|
||||
await user.click(screen.getByRole('button', { name: /OpenAI/ }));
|
||||
|
||||
expect(screen.getByPlaceholderText('http://localhost:11434')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('API key')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Anthropic' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Local \(Ollama\)/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-008: saving trims the inputs and omits the key when none was typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = seedLlm({ llm_model: '', llm_base_url: '' });
|
||||
const updateSettings = seedLlm({ llm_model: '' });
|
||||
renderSection();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('qwen3:8b'), ' qwen3:8b ');
|
||||
await user.type(screen.getByPlaceholderText('http://localhost:11434'), ' http://ollama.local ');
|
||||
await user.type(screen.getByPlaceholderText('qwen3:8b'), ' gpt-4o-mini ');
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({
|
||||
llm_provider: 'local',
|
||||
llm_model: 'qwen3:8b',
|
||||
llm_base_url: 'http://ollama.local',
|
||||
llm_provider: 'openai',
|
||||
llm_model: 'gpt-4o-mini',
|
||||
llm_base_url: '',
|
||||
llm_multimodal: false,
|
||||
});
|
||||
await screen.findByText('AI settings saved');
|
||||
@@ -134,7 +144,7 @@ describe('MLlmConnectionSection', () => {
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('••••••••')).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-010: a non-local provider never persists a base URL', async () => {
|
||||
it('FE-MOB-SETLLM-010: a stored base URL is never resent', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = seedLlm({ llm_provider: 'anthropic', llm_base_url: 'http://leftover' });
|
||||
renderSection();
|
||||
@@ -162,4 +172,54 @@ describe('MLlmConnectionSection', () => {
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
await screen.findByText('Could not save AI settings');
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-013: a stored local provider falls back to OpenAI without saving anything', () => {
|
||||
const updateSettings = seedLlm({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://192.168.1.5:11434' });
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('http://192.168.1.5:11434')).not.toBeInTheDocument();
|
||||
expect(updateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-014: saving clears the leftover base URL instead of resending it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = seedLlm({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://192.168.1.5:11434' });
|
||||
renderSection();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({
|
||||
llm_provider: 'openai',
|
||||
llm_model: 'nuextract',
|
||||
llm_base_url: '',
|
||||
llm_multimodal: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-SETLLM-015: a refused save pulls the stored settings back in', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedLlm({ llm_provider: 'anthropic' }, vi.fn().mockRejectedValue(new Error('Admin access required')));
|
||||
renderSection();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
await screen.findByText('Could not save AI settings');
|
||||
expect(loadSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The point of #1772: an instance has one endpoint, so this surface is the same
|
||||
// for the person who runs it. The admin sets it on the addon, not here.
|
||||
it('FE-MOB-SETLLM-016: an admin sees the same two providers and no endpoint row', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedRole('admin');
|
||||
seedLlm({ llm_provider: 'local', llm_model: 'qwen3:8b', llm_base_url: 'http://localhost:11434' });
|
||||
renderSection();
|
||||
|
||||
expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /OpenAI/ }));
|
||||
expect(screen.queryByRole('button', { name: /Local \(Ollama\)/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user