Merge branch 'dev' into feat/orm

This commit is contained in:
jubnl
2026-09-20 17:28:02 +02:00
711 changed files with 52693 additions and 2082 deletions
+2
View File
@@ -26,3 +26,5 @@
*.eot binary
*.pdf binary
*.zip binary
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
server/tests/fixtures/gpx/bom-crlf.gpx -text
+12
View File
@@ -51,6 +51,12 @@ on:
- 'package-lock.json'
- 'sonar-project.properties'
- '.github/workflows/test.yml'
# The image's start-up shape is pinned by a unit test
# (server/tests/unit/docker-entrypoint.test.ts), so a change to the
# Dockerfile or to the entrypoint script has to run the suite — that is
# exactly the change the test exists to catch.
- 'Dockerfile'
- 'server/scripts/**'
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
# neither the tests nor the scan — no test reads checked-in markdown
# and SonarCloud has no markdown analyzer. Negations must stay last.
@@ -69,6 +75,12 @@ on:
- 'package-lock.json'
- 'sonar-project.properties'
- '.github/workflows/test.yml'
# The image's start-up shape is pinned by a unit test
# (server/tests/unit/docker-entrypoint.test.ts), so a change to the
# Dockerfile or to the entrypoint script has to run the suite — that is
# exactly the change the test exists to catch.
- 'Dockerfile'
- 'server/scripts/**'
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
# neither the tests nor the scan — no test reads checked-in markdown
# and SonarCloud has no markdown analyzer. Negations must stay last.
+12 -6
View File
@@ -62,7 +62,8 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
chown -R node:node /app
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
# gosu rebuilt with a current Go toolchain (stage 0) — the entrypoint script uses
# it to drop to node.
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
ENV XDG_CACHE_HOME=/tmp/kf6-cache
@@ -117,9 +118,14 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
# Start-up lives in a script, not an inline `sh -c` string: container management
# UIs re-tokenise Config.Cmd when you edit a container in place, and a command
# carrying quotes does not survive that round-trip (the container then dies with
# a shell syntax error before Node starts). A CMD of one bare token does.
COPY server/scripts/entrypoint.sh /usr/local/bin/trek-entrypoint
# Root-owned on purpose — this is what calls gosu, so it runs before the drop to
# node. chmod is explicit because a checkout on Windows carries no exec bit.
RUN chmod 0755 /usr/local/bin/trek-entrypoint
ENTRYPOINT ["dumb-init", "--"]
# Preflight: if the app code is missing, a volume was almost certainly mounted
# over /app (it hides the image's node_modules + dist). Fail with actionable
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
CMD ["/usr/local/bin/trek-entrypoint"]
+1
View File
@@ -49,6 +49,7 @@ See `values.yaml` for more options.
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
- Set `env.ALLOW_LINK_LOCAL_IPS` to a single link-local address (e.g. `"169.254.1.2"`) only when a service TREK needs sits behind one, such as a host gateway. Cloud metadata addresses (`169.254.169.x`, `169.254.170.x`) cannot be listed.
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 4.2.0
version: 4.3.0
description: Minimal Helm chart for TREK app
appVersion: "4.2.0"
appVersion: "4.3.0"
+15
View File
@@ -43,6 +43,9 @@ data:
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
{{- end }}
{{- if .Values.env.ALLOW_LINK_LOCAL_IPS }}
ALLOW_LINK_LOCAL_IPS: {{ .Values.env.ALLOW_LINK_LOCAL_IPS | quote }}
{{- end }}
{{- if .Values.env.OIDC_ISSUER }}
OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }}
{{- end }}
@@ -82,3 +85,15 @@ data:
{{- if .Values.env.OVERPASS_TIMEOUT_MS }}
OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }}
{{- end }}
{{- /* Tested for presence rather than truth: the one value this switch gets set to is
false, and an unquoted false (or --set env.TREK_PLACES_ENABLED=false) would
otherwise drop the key and leave the index on. */}}
{{- if not (kindIs "invalid" .Values.env.TREK_PLACES_ENABLED) }}
TREK_PLACES_ENABLED: {{ .Values.env.TREK_PLACES_ENABLED | quote }}
{{- end }}
{{- if .Values.env.TREK_PLACES_URL }}
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
{{- end }}
{{- if .Values.env.AMAP_API_BASE }}
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
{{- end }}
+12
View File
@@ -75,6 +75,18 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: UNSPLASH_ACCESS_KEY
optional: true
- name: AMAP_API_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: AMAP_API_KEY
optional: true
- name: AMAP_API_SECRET
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: AMAP_API_SECRET
optional: true
volumeMounts:
- name: data
mountPath: /app/data
+12
View File
@@ -20,6 +20,12 @@ data:
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_KEY }}
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_SECRET }}
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | b64enc | quote }}
{{- end }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
@@ -50,4 +56,10 @@ stringData:
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_KEY }}
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_SECRET }}
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET }}
{{- end }}
{{- end }}
+30 -2
View File
@@ -54,6 +54,9 @@ env:
# ALLOW_INTERNAL_NETWORK: "false"
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
# ALLOW_LINK_LOCAL_IPS: ""
# Comma-separated single link-local addresses TREK may reach after all, e.g. "169.254.1.2" for a host gateway.
# 169.254.169.x and 169.254.170.x (cloud metadata) cannot be listed; an invalid entry aborts startup.
# OIDC_ISSUER: ""
# OpenID Connect provider URL.
# OIDC_CLIENT_ID: ""
@@ -87,8 +90,24 @@ env:
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
# OVERPASS_TIMEOUT_MS: "12000"
# Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
# OVERPASS_TIMEOUT_MS: "25000"
# Per-endpoint timeout (ms) for Overpass POI requests. The query itself gives Overpass 20 seconds of work, so a
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
# Defaults to 25000.
# TREK_PLACES_ENABLED: "false"
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads)
# asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on.
# TREK_PLACES_URL: ""
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
# Defaults to https://places.liketrek.com.
# AMAP_API_BASE: ""
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
# Admin > Settings > API Keys. Defaults to https://restapi.amap.com.
# Secret environment variables stored in a Kubernetes Secret.
@@ -114,6 +133,15 @@ secretEnv:
# Get a free key at https://unsplash.com/developers. Can also be set per-admin
# in Admin > Settings; this value overrides that. Leave empty to disable.
UNSPLASH_ACCESS_KEY: ""
# Optional Amap (高德地图) Web 服务 key for place search and geocoding inside mainland
# China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is
# a different credential and is rejected with INVALID_USER_KEY. Leave empty to use the
# key from the admin panel, or none.
AMAP_API_KEY: ""
# The private secret (数字签名) of that key. Required exactly when the key was created
# with signing enabled, because such a key rejects every unsigned request. Leave empty
# otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE.
AMAP_API_SECRET: ""
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "4.2.0",
"version": "4.3.0",
"private": true,
"type": "module",
"scripts": {
+51 -1
View File
@@ -1,4 +1,4 @@
// FE-APISURF-001 to FE-APISURF-054
// FE-APISURF-001 to FE-APISURF-057
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { AxiosResponse } from 'axios'
import { http, HttpResponse } from 'msw'
@@ -10,6 +10,7 @@ import {
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi,
docsyncApi, DOCSYNC_RUN_TIMEOUT_MS, DOCSYNC_UPSTREAM_TIMEOUT_MS,
} from './client'
interface Recorded { method: string; url: string; body: unknown }
@@ -665,6 +666,12 @@ describe('client > request payloads', () => {
expect(rec.method).toBe('DELETE')
expect(rec.body).toEqual({ password: 'hunter2' })
})
it('FE-APISURF-056: docsyncApi.createScope names the connection in the path only', async () => {
const rec = await traceOne(() => docsyncApi.createScope(1, 5, 'Norway'))
expect(`${rec.method} ${rec.url}`).toBe('POST /api/trips/1/docsync/connections/5/scopes')
expect(rec.body).toEqual({ name: 'Norway' })
})
})
describe('client > query parameters', () => {
@@ -895,4 +902,47 @@ describe('client > multipart uploads', () => {
expect(call[2]).toMatchObject({ timeout: 40000 })
}
})
it('FE-APISURF-057: every document-sync call that reaches the store outlives the 8s global timeout', async () => {
const post = spyPost()
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse)
const del = vi.spyOn(apiClient, 'delete').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse)
// The server allows each request to a store 15 s, and a run waits for the
// whole listing and every transfer; cut off at 8 s the browser reported a
// failure while the server carried on and finished.
await docsyncApi.testConnection(1, { providerId: 'paperless' })
await docsyncApi.createScope(1, 5, 'Norway')
await docsyncApi.createLink(1, { connectionId: 5 })
await docsyncApi.listScopes(1, 5)
await docsyncApi.deleteLink(1, 9)
expect(post.mock.calls.map(c => c[0])).toEqual([
'/trips/1/docsync/connections/test',
'/trips/1/docsync/connections/5/scopes',
'/trips/1/docsync/links',
])
for (const call of post.mock.calls) {
expect(call[2]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS })
}
expect(get.mock.calls[0][0]).toBe('/trips/1/docsync/connections/5/scopes')
expect(get.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS })
expect(del.mock.calls[0][0]).toBe('/trips/1/docsync/links/9')
expect(del.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS })
expect(DOCSYNC_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(8000)
// A run, and a conflict choice that runs the binding afterwards.
post.mockClear()
await docsyncApi.syncNow(1, 9)
await docsyncApi.resolve(1, 4, 'trek')
expect(post.mock.calls.map(c => c[0])).toEqual([
'/trips/1/docsync/links/9/sync',
'/trips/1/docsync/items/4/resolve',
])
for (const call of post.mock.calls) {
expect(call[2]).toMatchObject({ timeout: DOCSYNC_RUN_TIMEOUT_MS })
}
expect(DOCSYNC_RUN_TIMEOUT_MS).toBeGreaterThan(DOCSYNC_UPSTREAM_TIMEOUT_MS)
})
})
+62 -1
View File
@@ -1050,7 +1050,10 @@ const MEMORIES_TIMEOUT = 0
export const memoriesApi = {
status: (provider: string): Promise<{ connected: boolean }> =>
apiClient.get(`/integrations/memories/${provider}/status`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
search: (provider: string, body: { from: string; to: string; page: number; size: number }, signal?: AbortSignal) =>
// utc_offset_minutes says which 24 hours from/to name. It is NOT the Synology
// `offset`, which is rows to skip: one body goes to whichever provider, so the
// two names have to stay apart (#2336).
search: (provider: string, body: { from: string; to: string; page: number; size: number; utc_offset_minutes?: number }, signal?: AbortSignal) =>
apiClient.post(`/integrations/memories/${provider}/search`, body, { timeout: MEMORIES_TIMEOUT, signal }).then(r => r.data),
albums: (provider: string) =>
apiClient.get(`/integrations/memories/${provider}/albums`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
@@ -1358,6 +1361,64 @@ export const filesApi = {
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
}
/**
* How long the browser waits for a document-sync call that makes the server go
* and ask the store.
*
* The shared 8 s on `apiClient` is right for TREK's own routes and wrong for
* these, for the reason `dawarich.ts` gives: the server allows each request to
* a store 15 s by itself (20 s for Papra), a connection test may probe more
* than once, and binding or unbinding first registers or removes a webhook at
* the store. Cut off at 8 s, the browser reported a failure while the server
* carried on and finished, so a listing that took twelve seconds read as a
* broken store.
*/
export const DOCSYNC_UPSTREAM_TIMEOUT_MS = 60_000
/**
* A run is longer again. The server answers only once the whole of it is
* through: a listing of up to 40 pages, then up to 25 transfers at up to 120 s
* each, and Paperless-ngx holds every push until its consume task has run.
* Resolving a conflict runs the binding afterwards, so it waits just as long.
* Ten minutes covers a full transfer budget of ordinary documents; a run that
* outlasts it still finishes on the server, and the `docsync:changed` ping
* refreshes the dialog when it does.
*/
export const DOCSYNC_RUN_TIMEOUT_MS = 600_000
const docsyncUpstream = { timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS }
const docsyncRun = { timeout: DOCSYNC_RUN_TIMEOUT_MS }
/**
* Document sync: one provider connection per trip, shared by every member.
*
* Trip-scoped rather than user-scoped on purpose: a per-user connection would
* make a document's visibility depend on whose credentials fetched it, which is
* the opposite of what a shared trip needs.
*/
export const docsyncApi = {
providers: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/providers`).then(r => r.data),
status: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/status`).then(r => r.data),
listConnections: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/connections`).then(r => r.data),
saveConnection: (tripId: number | string, data: unknown) => apiClient.put(`/trips/${tripId}/docsync/connections`, data).then(r => r.data),
testConnection: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/connections/test`, data, docsyncUpstream).then(r => r.data),
deleteConnection: (tripId: number | string, connectionId: number) => apiClient.delete(`/trips/${tripId}/docsync/connections/${connectionId}`).then(r => r.data),
listScopes: (tripId: number | string, connectionId: number, q?: string) =>
apiClient.get(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { params: q ? { q } : {}, ...docsyncUpstream }).then(r => r.data),
createScope: (tripId: number | string, connectionId: number, name: string) =>
apiClient.post(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { name }, docsyncUpstream).then(r => r.data),
listLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/links`).then(r => r.data),
createLink: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/links`, data, docsyncUpstream).then(r => r.data),
updateLink: (tripId: number | string, linkId: number, data: unknown) => apiClient.patch(`/trips/${tripId}/docsync/links/${linkId}`, data).then(r => r.data),
deleteLink: (tripId: number | string, linkId: number) => apiClient.delete(`/trips/${tripId}/docsync/links/${linkId}`, docsyncUpstream).then(r => r.data),
syncNow: (tripId: number | string, linkId: number, full = false) =>
apiClient.post(`/trips/${tripId}/docsync/links/${linkId}/sync`, { full }, docsyncRun).then(r => r.data),
items: (tripId: number | string, state?: string) =>
apiClient.get(`/trips/${tripId}/docsync/items`, { params: state ? { state } : {} }).then(r => r.data),
resolve: (tripId: number | string, itemId: number, keep: 'trek' | 'provider' | 'both') =>
apiClient.post(`/trips/${tripId}/docsync/items/${itemId}/resolve`, { keep }, docsyncRun).then(r => r.data),
}
export const reservationsApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data),
+20 -1
View File
@@ -1,4 +1,4 @@
// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-032
// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-034
//
// The Collections addon wrapper is thin, but every method encodes a URL, a verb and a
// request-body shape that the server contract depends on. These tests drive each method
@@ -342,4 +342,23 @@ describe('collectionsApi', () => {
expect(requestBody).toEqual({ label_ids: [3], place_ids: [10] })
expect(res.changed).toBe(1)
})
it('FE-API-COLLECTIONS-033: exportGpx() reads the GPX of a list by id', async () => {
server.use(http.get(`${BASE}/:id/export/gpx`, record({ name: 'Tokyo', gpx: '<gpx/>', waypoints: 0, omitted: 2 })))
const res = await collectionsApi.exportGpx(1)
expect(requestUrl).toContain(`${BASE}/1/export/gpx`)
expect(res).toEqual({ name: 'Tokyo', gpx: '<gpx/>', waypoints: 0, omitted: 2 })
})
it('FE-API-COLLECTIONS-034: readGpx() posts the document and its file name', async () => {
const file = { format: 'trek.collection', version: 1, name: 'Tokyo', places: [] }
server.use(http.post(`${BASE}/gpx/read`, record({ file, skipped: 0, track_points: 12 })))
const res = await collectionsApi.readGpx({ gpx: '<gpx/>', file_name: 'tokyo.gpx' })
expect(requestBody).toEqual({ gpx: '<gpx/>', file_name: 'tokyo.gpx' })
expect(res).toEqual({ file, skipped: 0, track_points: 12 })
})
})
+13
View File
@@ -12,7 +12,11 @@ import type {
CollectionImportablesResponse,
CollectionFile,
CollectionImportRequest,
CollectionImportIntoRequest,
CollectionImportResult,
CollectionGpxExport,
CollectionGpxReadRequest,
CollectionGpxReadResult,
CollectionPlaceUpdateRequest,
CollectionCopyToTripRequest,
CollectionInviteRequest,
@@ -62,6 +66,15 @@ export const collectionsApi = {
ax.get(`${base}/${id}/export`).then((r: AxiosResponse) => r.data),
importFile: (body: CollectionImportRequest): Promise<CollectionImportResult> =>
ax.post(`${base}/import`, body satisfies CollectionImportRequest).then((r: AxiosResponse) => r.data),
/** The same file into a list that already exists: it only ever adds to it. */
importFileInto: (id: number, body: CollectionImportIntoRequest): Promise<CollectionImportResult> =>
ax.post(`${base}/${id}/import`, body satisfies CollectionImportIntoRequest).then((r: AxiosResponse) => r.data),
// The same list as GPX (#2301). A GPX is read by the server into a list file,
// which then goes through importFile above like any other.
exportGpx: (id: number): Promise<CollectionGpxExport> =>
ax.get(`${base}/${id}/export/gpx`).then((r: AxiosResponse) => r.data),
readGpx: (body: CollectionGpxReadRequest): Promise<CollectionGpxReadResult> =>
ax.post(`${base}/gpx/read`, body satisfies CollectionGpxReadRequest).then((r: AxiosResponse) => r.data),
// Both answer with the bare collection, not a { collection } envelope — the
// controller returns the service's `Collection` straight through, and the e2e
// suite pins that shape. Declaring the envelope made createCollection() resolve
+6
View File
@@ -81,6 +81,12 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// who has the book open, and where their pointers are)
'journey:book:peers',
'journey:book:cursor',
// Document sync: components/Files/docsync/useDocSync re-reads the bindings
// and their counts, and components/Files/docsync/useDocSyncOffered asks
// again whether the Files screen offers the sync button. Content-free like
// the bag ping: what a run changed is per-binding server state, and the
// files it moved arrive as file:* events.
'docsync:changed',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress',
'import:done',
@@ -1,4 +1,4 @@
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-037
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
@@ -418,7 +418,102 @@ describe('AddonManager', () => {
expect(isOn(addonToggle('Todo List'))).toBe(true);
});
it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => {
it('FE-ADMIN-ADDON-033: document providers hang off Documents with their glyphs and stay out of the count', async () => {
server.use(addonsRoute([
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }),
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
buildAddon({ id: 'paperless', name: 'Paperless-ngx', description: 'Document archive', type: 'document_provider', enabled: true }),
buildAddon({ id: 'nextcloud', name: 'Nextcloud', description: 'WebDAV files', type: 'document_provider', enabled: false }),
]));
render(<AddonManager />);
await screen.findByText('Paperless-ngx');
const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement;
expect(paperlessRow.querySelector('svg')).toBeInTheDocument();
expect(isOn(subToggle('Paperless-ngx'))).toBe(true);
expect(isOn(subToggle('Nextcloud'))).toBe(false);
// Documents and Journey are the addons; an enabled provider is a shelf row
// and must not turn 1/2 into 2/2.
expect(screen.getByText('1/2')).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-034: document providers stay hidden while Documents itself is off', async () => {
server.use(addonsRoute([
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: false }),
buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: true }),
]));
render(<AddonManager />);
await screen.findByText('Documents');
expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-035: switching Documents off and on again shows the cascaded providers as off', async () => {
const user = userEvent.setup();
let loads = 0;
let documentsOn = true;
let paperlessOn = true;
server.use(
http.get('/api/admin/addons', () => {
loads += 1;
return HttpResponse.json({
addons: [
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: documentsOn }),
buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: paperlessOn }),
],
});
}),
http.put('/api/admin/addons/documents', async ({ request }) => {
documentsOn = (await request.json() as { enabled: boolean }).enabled;
if (!documentsOn) paperlessOn = false;
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Paperless-ngx');
await user.click(addonToggle('Documents'));
await screen.findByText('Addon updated');
expect(loads).toBe(2);
expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument();
await user.click(addonToggle('Documents'));
await screen.findByText('Paperless-ngx');
expect(isOn(subToggle('Paperless-ngx'))).toBe(false);
});
it('FE-ADMIN-ADDON-036: toggling a document provider persists it without re-reading the list', async () => {
const user = userEvent.setup();
let loads = 0;
let body: unknown = null;
server.use(
http.get('/api/admin/addons', () => {
loads += 1;
return HttpResponse.json({
addons: [
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }),
buildAddon({ id: 'papra', name: 'Papra', type: 'document_provider', enabled: false }),
],
});
}),
http.put('/api/admin/addons/papra', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Papra');
await user.click(subToggle('Papra'));
await screen.findByText('Addon updated');
expect(body).toEqual({ enabled: true });
expect(loads).toBe(1);
expect(isOn(subToggle('Papra'))).toBe(true);
});
it('FE-ADMIN-ADDON-018:the collab sub-features render their state and report the toggled key', async () => {
const user = userEvent.setup();
const onToggleCollabFeature = vi.fn();
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
@@ -448,6 +543,22 @@ describe('AddonManager', () => {
expect(screen.queryByText('Polls')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-037: the links row describes the feature, not an empty list', async () => {
// The row's tooltip is its description. The links tab's empty state read as a
// status here, and a status that never changes reads as a broken feature.
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
<AddonManager
collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }}
onToggleCollabFeature={vi.fn()}
/>,
);
const links = await screen.findByText('Links');
expect(links).toHaveAttribute('title');
expect(links.getAttribute('title')).not.toBe('No shared links yet');
});
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
render(<AddonManager />);
+40 -3
View File
@@ -9,6 +9,7 @@ import CustomSelect from '../shared/CustomSelect'
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons'
import AddonTile from './AddonTile'
import AddonSubRow from './AddonSubRow'
@@ -41,6 +42,9 @@ function SynologyIcon({ size = 14 }: { size?: number }) {
const PROVIDER_ICONS: Record<string, ComponentType<{ size?: number }>> = {
immich: ImmichIcon,
synologyphotos: SynologyIcon,
// The document providers' own marks, single-colour so they read as glyphs in
// a row of lucide icons and invert with the theme.
...DOCUMENT_PROVIDER_ICONS,
}
interface Addon {
@@ -95,7 +99,7 @@ interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls
const COLLAB_SUB_FEATURES = [
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'collab.links.empty' },
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'admin.collab.links.subtitle' },
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
] as const
@@ -137,7 +141,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
// Journey off disables every photo provider with it, and the response carries
// only Journey. Without re-reading, switching Journey back on brings the shelf
// up with providers the database has long since turned off.
if (addon.id === 'journey') await loadAddons()
// Same for Documents: switching it off disables every document provider in
// the database, and the response carries only the addon itself.
if (addon.id === 'journey' || addon.id === 'documents') await loadAddons()
toast.success(t('admin.addons.toast.updated'))
}
@@ -145,12 +151,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
return addon.type === 'photo_provider'
}
const isDocumentProviderAddon = (addon: Addon) => {
return addon.type === 'document_provider'
}
const isPhotosAddon = (addon: Addon) => {
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
}
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
const documentProviderAddons = addons.filter(isDocumentProviderAddon)
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
const globalAddons = addons.filter(a => a.type === 'global')
const integrationAddons = addons.filter(a => a.type === 'integration')
@@ -161,6 +172,13 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
enabled: provider.enabled,
toggle: () => handleToggle(provider),
}))
const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({
key: provider.id,
label: provider.name,
description: provider.description,
enabled: provider.enabled,
toggle: () => handleToggle(provider),
}))
if (loading) {
return (
@@ -200,6 +218,25 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
})
}
// Document providers are the Documents tile's shelf, the way photo providers
// are Journey's. Unlike those they carry no credential form here: a document
// connection belongs to a trip, not to a user, so it is entered in the trip's
// file manager. The admin decides only whether a provider may be offered.
if (addon.id === 'documents' && addon.enabled && documentProviderOptions.length > 0) {
return documentProviderOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
return (
<AddonSubRow
key={provider.key}
icon={ProviderIcon ? <ProviderIcon size={14} /> : undefined}
title={provider.label}
description={provider.description}
enabled={provider.enabled}
onToggle={provider.toggle}
/>
)
})
}
if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) {
return providerOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
@@ -248,7 +285,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{ key: 'global', addons: globalAddons },
{ key: 'integration', addons: integrationAddons },
].filter(g => g.addons.length > 0)
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.enabled).length
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
return (
@@ -708,4 +708,31 @@ describe('CollabChat', () => {
{ timeout: 3000 }
);
});
// The strip shows the four pictures that will go out; the two the cap turned away
// vanish without a word otherwise, and the sender believes all six were sent.
it('FE-COMP-CHAT-037: picking more images than fit on one message says so, and says it once', async () => {
const addToast = vi.fn();
window.__addToast = addToast as unknown as typeof window.__addToast;
let minted = 0;
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:mock/${++minted}`);
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
try {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const files = Array.from({ length: 6 }, (_, i) => new File([new Uint8Array(8)], `${i}.png`, { type: 'image/png' }));
fireEvent.change(input, { target: { files } });
await waitFor(() => expect(document.querySelectorAll('img[src^="blob:mock/"]')).toHaveLength(4));
expect(addToast).toHaveBeenCalledTimes(1);
const [message, type] = addToast.mock.calls[0];
expect(type).toBe('error');
expect(message).not.toBe('Only JPEG, PNG, GIF and WebP images up to 10 MB are allowed');
} finally {
delete window.__addToast;
createObjURL.mockRestore(); revokeObjURL.mockRestore();
}
});
});
+3 -3
View File
@@ -48,8 +48,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
</div>
)}
{imagePreviews.length > 0 && <div style={{ display: 'flex', gap: 8, marginBottom: 8, overflowX: 'auto' }}>{imagePreviews.map((url, i) => <div key={url} style={{ position: 'relative' }}><img src={url} alt="" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 8 }} /><button type="button" onClick={() => removeImage(i)} aria-label="Remove image" style={{ position: 'absolute', top: -6, right: -6, border: 0, borderRadius: '50%', background: 'var(--text-primary)', color: 'var(--bg-primary)', width: 18, height: 18, cursor: 'pointer' }}>×</button></div>)}</div>}
{uploadProgress > 0 && uploadProgress < 100 && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>Uploading {uploadProgress}%</div>}
{imagePreviews.length > 0 && <div style={{ display: 'flex', gap: 8, marginBottom: 8, overflowX: 'auto' }}>{imagePreviews.map((url, i) => <div key={url} style={{ position: 'relative' }}><img src={url} alt="" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 8 }} /><button type="button" onClick={() => removeImage(i)} aria-label={t('places.removeImage')} style={{ position: 'absolute', top: -6, right: -6, border: 0, borderRadius: '50%', background: 'var(--text-primary)', color: 'var(--bg-primary)', width: 18, height: 18, cursor: 'pointer' }}>×</button></div>)}</div>}
{uploadProgress > 0 && uploadProgress < 100 && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>{t('collab.chat.uploading', { percent: uploadProgress })}</div>}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
{/* Emoji button */}
{canEdit && (
@@ -63,7 +63,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
</button>
)}
{canEdit && <><input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden onChange={e => { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} /><button type="button" onClick={() => imageInputRef.current?.click()} aria-label="Attach images" style={{ width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'transparent', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0 }}><ImagePlus size={19} /></button></>}
{canEdit && <><input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden onChange={e => { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} /><button type="button" onClick={() => imageInputRef.current?.click()} aria-label={t('collab.chat.attachImages')} style={{ width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'transparent', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0 }}><ImagePlus size={19} /></button></>}
<textarea
ref={textareaRef}
@@ -1,10 +1,13 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { useTranslation } from '../../i18n'
export function CollabChatAttachment({ attachment }: { attachment: { url: string; original_name?: string; mime_type?: string } }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const alt = attachment.original_name || t('collab.chat.attachedImage')
return <>
<img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={() => setOpen(true)} style={{ display: 'block', width: 180, maxWidth: '100%', maxHeight: 220, objectFit: 'cover', borderRadius: 10, cursor: 'zoom-in', marginTop: 4 }} />
{open && <div role="dialog" onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}><button aria-label="Close image" onClick={() => setOpen(false)} style={{ position: 'fixed', top: 18, right: 18, background: 'rgba(255,255,255,.15)', color: '#fff', border: 0, borderRadius: '50%', width: 36, height: 36 }}><X size={20} /></button><img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={e => e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} /></div>}
<img src={attachment.url} alt={alt} onClick={() => setOpen(true)} style={{ display: 'block', width: 180, maxWidth: '100%', maxHeight: 220, objectFit: 'cover', borderRadius: 10, cursor: 'zoom-in', marginTop: 4 }} />
{open && <div role="dialog" onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}><button aria-label={t('collab.chat.closeImage')} onClick={() => setOpen(false)} style={{ position: 'fixed', top: 18, right: 18, background: 'rgba(255,255,255,.15)', color: '#fff', border: 0, borderRadius: '50%', width: 36, height: 36 }}><X size={20} /></button><img src={attachment.url} alt={alt} onClick={e => e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} /></div>}
</>
}
@@ -1,5 +1,5 @@
/**
* FE-CHATIMG-001..006 — the pictures pinned to a chat message before it is sent.
* FE-CHATIMG-001..009: the pictures pinned to a chat message before it is sent.
*
* The reason this file exists: the hook used to create object URLs and enqueue
* the preview state from inside a `setFiles` updater. React invokes an updater
@@ -11,7 +11,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { StrictMode } from 'react'
import { act, renderHook } from '@testing-library/react'
import { useChatImages, MAX_CHAT_IMAGES } from './useChatImages'
import { useChatImages, MAX_CHAT_IMAGES, type ChatImageDrop } from './useChatImages'
let nextUrl = 0
const revoked: string[] = []
@@ -92,4 +92,44 @@ describe('useChatImages', () => {
expect(result.current.files).toEqual([])
expect(revoked.sort()).toEqual(['blob:mock/1', 'blob:mock/2'])
})
// The strip shows what will be sent, so a picture the cap turned away is as gone as
// one of the wrong kind. The sender hears about both, and hears which it was.
it('FE-CHATIMG-007: a pick past the cap is reported as an overflow and is not a clean add', () => {
const { result } = strict()
const drops: ChatImageDrop[] = []
let ok = true
act(() => {
ok = result.current.add(Array.from({ length: MAX_CHAT_IMAGES + 2 }, (_, i) => img(`${i}.png`)), d => drops.push(d))
})
expect(ok).toBe(false)
expect(drops).toEqual([{ rejected: 0, overflow: 2 }])
expect(result.current.files).toHaveLength(MAX_CHAT_IMAGES)
})
it('FE-CHATIMG-008: a wrong kind and an overflow in one pick are told apart', () => {
const { result } = strict()
act(() => { result.current.add([img('a.png'), img('b.png'), img('c.png')]) })
const drops: ChatImageDrop[] = []
act(() => {
result.current.add([new File(['x'], 'notes.pdf', { type: 'application/pdf' }), img('d.png'), img('e.png')], d => drops.push(d))
})
expect(result.current.files.map(f => f.name)).toEqual(['a.png', 'b.png', 'c.png', 'd.png'])
expect(drops).toEqual([{ rejected: 1, overflow: 1 }])
})
it('FE-CHATIMG-009: a pick that fits is a clean add and reports nothing', () => {
const { result } = strict()
const onDrop = vi.fn()
let ok = false
act(() => { ok = result.current.add([img('a.png')], onDrop) })
expect(ok).toBe(true)
expect(onDrop).not.toHaveBeenCalled()
})
})
+21 -4
View File
@@ -28,11 +28,24 @@ interface PickedImage {
url: string
}
/**
* What an add() left out: files of the wrong type or over the size limit, and
* valid files past the cap of MAX_CHAT_IMAGES. Either can be zero, both cannot.
*/
export interface ChatImageDrop {
rejected: number
overflow: number
}
export interface ChatImages {
files: File[]
previews: string[]
/** Returns false when something was dropped, so the caller can say why. */
add: (incoming: FileList | File[]) => boolean
/**
* Returns false when something was dropped, so the caller can say why. The
* optional callback tells it what was dropped, because a picture past the cap
* is a different message from a picture of the wrong kind.
*/
add: (incoming: FileList | File[], onDrop?: (drop: ChatImageDrop) => void) => boolean
remove: (index: number) => void
clear: () => void
}
@@ -52,7 +65,7 @@ export function useChatImages(): ChatImages {
itemsRef.current = []
}, [])
const add = useCallback((incoming: FileList | File[]) => {
const add = useCallback((incoming: FileList | File[], onDrop?: (drop: ChatImageDrop) => void) => {
const all = Array.from(incoming)
const valid = all.filter(f => CHAT_IMAGE_TYPES.includes(f.type) && f.size <= MAX_CHAT_IMAGE_BYTES)
const room = MAX_CHAT_IMAGES - itemsRef.current.length
@@ -61,7 +74,11 @@ export function useChatImages(): ChatImages {
// Only the new files get a URL; the ones already in the list keep theirs.
commit([...itemsRef.current, ...added.map(file => ({ file, url: URL.createObjectURL(file) }))])
}
return valid.length === all.length
// A picture the cap turned away is as gone as one of the wrong kind: the strip
// shows what will be sent, and the sender deserves to hear why it is short.
const drop = { rejected: all.length - valid.length, overflow: valid.length - added.length }
if (drop.rejected || drop.overflow) onDrop?.(drop)
return drop.rejected === 0 && drop.overflow === 0
}, [commit])
const remove = useCallback((index: number) => {
@@ -5,7 +5,7 @@ import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useChatImages } from './useChatImages'
import { useChatImages, MAX_CHAT_IMAGES } from './useChatImages'
import { useToast } from '../shared/Toast'
export function useCollabChat(tripId: any, currentUser: any) {
@@ -122,7 +122,10 @@ export function useCollabChat(tripId: any, currentUser: any) {
/* ── send ── */
const addImageFiles = useCallback((incoming: File[] | FileList) => {
if (!images.add(incoming)) toast.error(t('collab.chat.imageRejected'))
images.add(incoming, ({ rejected, overflow }) => {
if (rejected) toast.error(t('collab.chat.imageRejected'))
if (overflow) toast.error(t('collab.chat.imageLimit', { max: MAX_CHAT_IMAGES }))
})
}, [images, toast, t])
const removeImage = images.remove
const handlePaste = useCallback((e: ClipboardEvent) => { if (e.clipboardData.files.length) addImageFiles(e.clipboardData.files) }, [addImageFiles])
@@ -0,0 +1,120 @@
// FE-COMP-COLEXPORT-001 to FE-COMP-COLEXPORT-008
import React from 'react';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useTranslation } from '../../i18n/TranslationContext';
import CollectionExportMenu from './CollectionExportMenu';
type MenuProps = Omit<React.ComponentProps<typeof CollectionExportMenu>, 't'>;
function Harness(props: MenuProps): React.ReactElement {
const { t } = useTranslation();
return (
<>
<CollectionExportMenu {...props} t={t} />
<button type="button">Somewhere else</button>
</>
);
}
function renderMenu(overrides: Partial<MenuProps> = {}) {
const props: MenuProps = { onExport: vi.fn(), ...overrides };
render(<Harness {...props} />);
return props;
}
const trigger = () => screen.getByRole('button', { name: 'Export' });
const items = () => screen.getAllByRole('menuitem');
describe('CollectionExportMenu (#2301)', () => {
it('FE-COMP-COLEXPORT-001: is a closed menu button until pressed', () => {
renderMenu();
expect(trigger()).toHaveAttribute('aria-haspopup', 'menu');
expect(trigger()).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('menu')).toBeNull();
});
it('FE-COMP-COLEXPORT-002: offers both formats, each with its extension and what it is for', async () => {
renderMenu();
await userEvent.click(trigger());
const menu = screen.getByRole('menu', { name: 'Download this list as a file' });
expect(trigger()).toHaveAttribute('aria-expanded', 'true');
expect(trigger()).toHaveAttribute('aria-controls', menu.id);
expect(items().map(item => item.textContent)).toEqual([
'TREK list.trekcollection.jsonFor another TREK, with labels and status',
'GPX.gpxWaypoints for OsmAnd, Organic Maps, Garmin and other map apps',
]);
});
it('FE-COMP-COLEXPORT-003: exports the picked format, closes and gives focus back', async () => {
const props = renderMenu();
await userEvent.click(trigger());
await userEvent.click(screen.getByRole('menuitem', { name: /GPX/ }));
expect(props.onExport).toHaveBeenCalledWith('gpx');
expect(screen.queryByRole('menu')).toBeNull();
expect(trigger()).toHaveFocus();
});
it('FE-COMP-COLEXPORT-004: moves focus into the menu and walks it with the arrow keys, Home and End', async () => {
renderMenu();
await userEvent.click(trigger());
await waitFor(() => expect(items()[0]).toHaveFocus());
await userEvent.keyboard('{ArrowDown}');
expect(items()[1]).toHaveFocus();
// Past the end wraps round, as it does in every menu.
await userEvent.keyboard('{ArrowDown}');
expect(items()[0]).toHaveFocus();
await userEvent.keyboard('{ArrowUp}');
expect(items()[1]).toHaveFocus();
await userEvent.keyboard('{Home}');
expect(items()[0]).toHaveFocus();
await userEvent.keyboard('{End}');
expect(items()[1]).toHaveFocus();
});
it('FE-COMP-COLEXPORT-005: opens from the keyboard and picks with Enter', async () => {
const props = renderMenu();
trigger().focus();
await userEvent.keyboard('{ArrowDown}');
await waitFor(() => expect(items()[0]).toHaveFocus());
await userEvent.keyboard('{Enter}');
expect(props.onExport).toHaveBeenCalledWith('trek');
});
it('FE-COMP-COLEXPORT-006: closes on Escape without exporting, and gives focus back', async () => {
const props = renderMenu();
await userEvent.click(trigger());
await waitFor(() => expect(items()[0]).toHaveFocus());
await userEvent.keyboard('{Escape}');
expect(screen.queryByRole('menu')).toBeNull();
expect(trigger()).toHaveFocus();
expect(props.onExport).not.toHaveBeenCalled();
});
it('FE-COMP-COLEXPORT-007: closes on a click anywhere else, and on Tab', async () => {
renderMenu();
await userEvent.click(trigger());
await userEvent.click(screen.getByRole('button', { name: 'Somewhere else' }));
expect(screen.queryByRole('menu')).toBeNull();
await userEvent.click(trigger());
await waitFor(() => expect(items()[0]).toHaveFocus());
await userEvent.keyboard('{Tab}');
expect(screen.queryByRole('menu')).toBeNull();
});
it('FE-COMP-COLEXPORT-008: cannot be opened while an export is still running', async () => {
renderMenu({ exporting: true });
expect(trigger()).toBeDisabled();
await userEvent.click(trigger());
expect(screen.queryByRole('menu')).toBeNull();
});
});
@@ -0,0 +1,168 @@
import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Download, FileJson, Loader2, Route, type LucideIcon } from 'lucide-react'
import type { TranslationFn } from '../../types'
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
import { COLLECTION_FILE_EXTENSION, COLLECTION_GPX_EXTENSION, type CollectionExportFormat } from './collectionFile'
interface CollectionExportMenuProps {
onExport: (format: CollectionExportFormat) => void
exporting?: boolean
t: TranslationFn
}
interface FormatOption {
format: CollectionExportFormat
icon: LucideIcon
label: string
extension: string
hint: string
}
/** Wide enough for the longest hint on one or two lines at the default text size. */
const MENU_WIDTH = 300
/**
* Export in the list hero: a menu of the two formats a list can leave in (#2301).
*
* The TREK file carries everything and is for another TREK; GPX carries the
* places as waypoints and is for every other map app. Choosing is the whole
* interaction, so it is a menu rather than a dialog.
*
* Portalled, because the hero clips its overflow, and placed against the
* trigger's right edge, where the button sits. A menu in the WAI-ARIA sense:
* focus moves into it on open, the arrow keys, Home and End walk it, Escape
* closes it and gives focus back to the button, and Tab leaves it closed.
*/
export default function CollectionExportMenu({ onExport, exporting, t }: CollectionExportMenuProps): React.ReactElement {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const menuId = useId()
const anchored = useAnchoredPosition(triggerRef, open, { estimatedHeight: 150, offset: 6 })
// Focusable only once positioned: until then the menu is hidden, and a hidden
// element refuses focus.
const placed = open && anchored !== null
const options: FormatOption[] = [
{
format: 'trek', icon: FileJson, label: t('collections.file.formatTrek'),
extension: COLLECTION_FILE_EXTENSION, hint: t('collections.file.formatTrekHint'),
},
{
format: 'gpx', icon: Route, label: 'GPX',
extension: COLLECTION_GPX_EXTENSION, hint: t('collections.file.formatGpxHint'),
},
]
const items = () => Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])
const close = useCallback((refocus: boolean) => {
setOpen(false)
if (refocus) triggerRef.current?.focus()
}, [])
useEffect(() => {
if (placed) menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus()
}, [placed])
useEffect(() => {
if (!open) return
const onPointerDown = (e: MouseEvent) => {
const target = e.target as Node
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return
close(false)
}
document.addEventListener('mousedown', onPointerDown)
return () => document.removeEventListener('mousedown', onPointerDown)
}, [open, close])
const onMenuKeyDown = (e: React.KeyboardEvent) => {
const all = items()
const at = all.indexOf(document.activeElement as HTMLElement)
const focus = (index: number) => {
e.preventDefault()
all[(index + all.length) % all.length]?.focus()
}
if (e.key === 'ArrowDown') focus(at + 1)
else if (e.key === 'ArrowUp') focus(at - 1)
else if (e.key === 'Home') focus(0)
else if (e.key === 'End') focus(all.length - 1)
else if (e.key === 'Escape') {
e.preventDefault()
close(true)
} else if (e.key === 'Tab') close(false)
}
const pick = (format: CollectionExportFormat) => {
close(true)
onExport(format)
}
// Pinned by its right edge, under the button, so it grows leftwards into the hero.
const right = anchored ? Math.max(8, window.innerWidth - anchored.left - anchored.width) : 8
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(o => !o)}
onKeyDown={e => {
if (e.key === 'ArrowDown' && !open) {
e.preventDefault()
setOpen(true)
}
}}
disabled={exporting}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
aria-label={t('collections.file.export')}
title={t('collections.file.exportTitle')}
className="col-glass-btn"
>
{exporting ? <Loader2 size={15} className="animate-spin" /> : <Download size={15} />}
<span className="txt">{t('collections.file.export')}</span>
</button>
{open && createPortal(
<div
ref={menuRef}
id={menuId}
role="menu"
aria-label={t('collections.file.exportTitle')}
onKeyDown={onMenuKeyDown}
className="trek-popover-enter fixed z-[var(--z-toast)] p-1.5 rounded-xl border border-edge bg-surface-card shadow-dropdown"
style={{
...(anchored?.flipped ? { bottom: anchored.bottom } : { top: anchored?.top ?? 0 }),
right,
width: MENU_WIDTH,
maxWidth: 'calc(100vw - 16px)',
visibility: anchored ? 'visible' : 'hidden',
}}
>
{options.map(option => (
<button
key={option.format}
type="button"
role="menuitem"
tabIndex={-1}
onClick={() => pick(option.format)}
className="w-full flex items-start gap-3 px-3 py-2.5 rounded-lg text-left transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
>
<option.icon size={16} className="mt-0.5 shrink-0 text-content-faint" aria-hidden />
<span className="flex-1 min-w-0">
<span className="flex items-baseline justify-between gap-3">
<span className="text-body font-semibold text-content">{option.label}</span>
<span className="text-caption text-content-faint">{option.extension}</span>
</span>
<span className="block text-caption text-content-muted">{option.hint}</span>
</span>
</button>
))}
</div>,
document.body,
)}
</>
)
}
@@ -172,8 +172,10 @@ describe('CollectionHero', () => {
const all = [...document.querySelectorAll('.col-hero-actions button')];
expect(all).toEqual(actions);
// Export asks which format first (#2301).
await userEvent.click(actions[1]);
expect(onExport).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole('menuitem', { name: /TREK list/ }));
expect(onExport).toHaveBeenCalledWith('trek');
});
it('FE-COMP-COLHERO-013: has no Export button where there is no list to export', () => {
@@ -186,8 +188,9 @@ describe('CollectionHero', () => {
renderHero({ canEdit: false, canShare: false, isOwner: false, onExport });
await userEvent.click(screen.getByRole('button', { name: 'Export' }));
await userEvent.click(screen.getByRole('menuitem', { name: /GPX/ }));
expect(onExport).toHaveBeenCalledTimes(1);
expect(onExport).toHaveBeenCalledWith('gpx');
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
});
@@ -1,8 +1,10 @@
import React from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { Share2, Users, Link2, Pencil, Download, Loader2 } from 'lucide-react'
import { Share2, Users, Link2, Pencil } from 'lucide-react'
import type { CollectionMember, CollectionLink } from '@trek/shared'
import type { TranslationFn } from '../../types'
import CollectionExportMenu from './CollectionExportMenu'
import type { CollectionExportFormat } from './collectionFile'
const AV_COLORS = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#3b82f6', '#ef4444', '#22c55e']
@@ -27,7 +29,7 @@ interface CollectionHeroProps {
shareMemberCount: number
onShare: () => void
/** Absent on the "All saved" pseudo-list, which is a view rather than a list. */
onExport?: () => void
onExport?: (format: CollectionExportFormat) => void
exporting?: boolean
t: TranslationFn
}
@@ -98,19 +100,7 @@ export default function CollectionHero({
<span className="txt">{t('common.edit')}</span>
</button>
)}
{onExport && (
<button
type="button"
onClick={onExport}
disabled={exporting}
aria-label={t('collections.file.export')}
title={t('collections.file.exportTitle')}
className="col-glass-btn"
>
{exporting ? <Loader2 size={15} className="animate-spin" /> : <Download size={15} />}
<span className="txt">{t('collections.file.export')}</span>
</button>
)}
{onExport && <CollectionExportMenu onExport={onExport} exporting={exporting} t={t} />}
{canShare && (
<button
type="button"
@@ -1,4 +1,4 @@
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-043
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-048
import React from 'react';
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -602,3 +602,89 @@ describe('CollectionPlaceDetail: editable address', () => {
expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('Somewhere');
});
});
// ── Cover controls sit beside the close button ───────────────────────────────
describe('CollectionPlaceDetail: cover controls', () => {
it('FE-COMP-COLDETAIL-044: the camera sits in the top right row, just left of the close button', async () => {
renderDetail({ canEdit: true, onUploadImage: vi.fn() });
const camera = await screen.findByRole('button', { name: 'Upload image' });
const close = screen.getByRole('button', { name: 'Close' });
const row = close.parentElement as HTMLElement;
expect(camera.parentElement).toBe(row);
expect(camera.nextElementSibling).toBe(close);
// Same class as the close button, so the same round look, hover and phone size.
expect(camera).toHaveClass('col-detail-close');
// The category chip keeps the top left corner to itself.
const chip = screen.getByText('Food').closest('.col-detail-cover-cat') as HTMLElement;
expect(row.contains(chip)).toBe(false);
});
it('FE-COMP-COLDETAIL-045: with a custom cover the row reads remove, change, close and change still opens the picker', async () => {
const user = userEvent.setup();
renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: { ...place, image_url: '/uploads/places/mock.jpg' } });
const change = await screen.findByRole('button', { name: 'Change image' });
const remove = screen.getByRole('button', { name: 'Remove image' });
const close = screen.getByRole('button', { name: 'Close' });
expect(Array.from((close.parentElement as HTMLElement).querySelectorAll('button'))).toEqual([remove, change, close]);
expect(remove).toHaveClass('col-detail-close');
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const clicked = vi.fn();
input.addEventListener('click', clicked);
await user.click(change);
expect(clicked).toHaveBeenCalledTimes(1);
});
it('FE-COMP-COLDETAIL-046: while an upload runs the camera spins, ignores clicks and the remove control steps aside', async () => {
const user = userEvent.setup();
const onUploadImage = vi.fn(() => new Promise<void>(() => {}));
renderDetail({ canEdit: true, onUploadImage, place: { ...place, image_url: '/uploads/places/mock.jpg' } });
await screen.findByRole('button', { name: 'Change image' });
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [new File(['x'], 'cover.png', { type: 'image/png' })] } });
const change = screen.getByRole('button', { name: 'Change image' });
await waitFor(() => expect(change.querySelector('.animate-spin')).not.toBeNull());
expect(screen.queryByRole('button', { name: 'Remove image' })).not.toBeInTheDocument();
expect(change.nextElementSibling).toBe(screen.getByRole('button', { name: 'Close' }));
const clicked = vi.fn();
input.addEventListener('click', clicked);
await user.click(change);
expect(clicked).not.toHaveBeenCalled();
});
it('FE-COMP-COLDETAIL-047: a long category shares the top bar with the controls and ellipsizes instead of running under them', async () => {
const long = 'Restaurants, cafes and late night bars';
renderDetail({
canEdit: true,
onUploadImage: vi.fn(),
place: { ...place, image_url: '/uploads/places/mock.jpg', category: { id: 1, name: long, color: '#f00', icon: null } },
});
const controls = (await screen.findByRole('button', { name: 'Close' })).parentElement as HTMLElement;
const text = screen.getByText(long);
const chip = text.closest('.col-detail-cover-cat') as HTMLElement;
const bar = controls.parentElement as HTMLElement;
expect(controls.querySelectorAll('button')).toHaveLength(3);
// Chip first, controls last in one flex bar: the chip only gets what the buttons leave.
expect(Array.from(bar.children)).toEqual([chip, controls]);
expect(bar).toHaveClass('absolute', 'flex', 'left-[14px]', 'right-[12px]', 'top-[12px]');
expect(controls).toHaveClass('ms-auto', 'flex-none');
expect(chip).toHaveClass('min-w-0');
expect(text).toHaveClass('truncate');
});
it('FE-COMP-COLDETAIL-048: without a category the controls still hold the end of the bar', async () => {
renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: { ...place, category: undefined } });
const controls = (await screen.findByRole('button', { name: 'Close' })).parentElement as HTMLElement;
expect(document.querySelector('.col-detail-cover-cat')).toBeNull();
expect(Array.from((controls.parentElement as HTMLElement).children)).toEqual([controls]);
expect(controls).toHaveClass('ms-auto');
});
});
@@ -180,39 +180,45 @@ export default function CollectionPlaceDetail({
<div className="col-detail-cover" style={banner ? undefined : { backgroundImage: entityGradient(place.id) }}>
{banner && <img src={banner} alt="" />}
<div className="col-detail-cover-scrim" />
{place.category?.name && (
<span className="col-detail-cover-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={12} /> {place.category.name}
</span>
)}
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
{canEdit && onUploadImage && (
<div style={{ position: 'absolute', top: 10, left: 10, display: 'flex', gap: 6, zIndex: 2 }}>
<Tooltip label={place.image_url ? t('places.changeImage') : t('places.uploadImage')} placement="bottom">
<button
type="button"
onClick={() => { if (!imgBusy) coverInputRef.current?.click() }}
aria-label={place.image_url ? t('places.changeImage') : t('places.uploadImage')}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 8, border: 'none', cursor: imgBusy ? 'default' : 'pointer', background: 'rgba(0,0,0,0.55)', color: '#fff', backdropFilter: 'blur(4px)' }}
>
{imgBusy ? <Loader2 size={15} className="animate-spin" /> : <Camera size={15} />}
</button>
</Tooltip>
{place.image_url && !imgBusy && (
<Tooltip label={t('places.removeImage')} placement="bottom">
<button
type="button"
onClick={handleImageRemove}
aria-label={t('places.removeImage')}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 8, border: 'none', cursor: 'pointer', background: 'rgba(0,0,0,0.55)', color: '#fff', backdropFilter: 'blur(4px)' }}
>
<Trash2 size={15} />
</button>
</Tooltip>
{/* Chip and cover controls share the top bar. The controls keep the end and
never shrink, so a long category name ellipsizes instead of sliding under
them, however many buttons there are and whatever size phones give them. */}
<div className="absolute left-[14px] right-[12px] top-[12px] z-[2] flex items-center gap-[8px]">
{place.category?.name && (
<span className="col-detail-cover-cat min-w-0" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
<CatIcon size={12} className="flex-none" />
<span className="truncate">{place.category.name}</span>
</span>
)}
{/* The cover controls wear the close button's class, so they get its look,
hover and phone size. */}
<div className="ms-auto flex flex-none gap-[6px]">
{canEdit && onUploadImage && (
<>
<input ref={coverInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleCoverPick} />
{place.image_url && !imgBusy && (
<Tooltip label={t('places.removeImage')} placement="bottom">
<button type="button" className="col-detail-close" onClick={handleImageRemove} aria-label={t('places.removeImage')}>
<Trash2 size={16} />
</button>
</Tooltip>
)}
<Tooltip label={place.image_url ? t('places.changeImage') : t('places.uploadImage')} placement="bottom">
<button
type="button"
className="col-detail-close"
style={imgBusy ? { cursor: 'default' } : undefined}
onClick={() => { if (!imgBusy) coverInputRef.current?.click() }}
aria-label={place.image_url ? t('places.changeImage') : t('places.uploadImage')}
>
{imgBusy ? <Loader2 size={16} className="animate-spin" /> : <Camera size={16} />}
</button>
</Tooltip>
</>
)}
<input ref={coverInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleCoverPick} />
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
</div>
)}
</div>
<div className="col-detail-head">
{editing
? <input value={name} onChange={e => setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} />
@@ -1,9 +1,10 @@
// FE-COMP-COLLIMPORT-001 to FE-COMP-COLLIMPORT-010
// FE-COMP-COLLIMPORT-001 to FE-COMP-COLLIMPORT-026
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { CollectionFile } from '@trek/shared';
import type { Collection, CollectionFile } from '@trek/shared';
import { useTranslation } from '../../i18n/TranslationContext';
import ImportCollectionModal from './ImportCollectionModal';
import type { GpxReader } from './collectionFile';
// The modal takes `t` as a prop, so this harness forwards the real English
// translator and the assertions read as the strings a person sees.
@@ -23,6 +24,7 @@ const listFile: CollectionFile = {
function renderModal(overrides: Partial<React.ComponentProps<typeof ImportCollectionModal>> = {}) {
const props = {
onImport: vi.fn().mockResolvedValue(undefined),
onReadGpx: vi.fn().mockRejectedValue(new Error('no GPX expected here')),
onClose: vi.fn(),
...overrides,
};
@@ -33,7 +35,7 @@ function renderModal(overrides: Partial<React.ComponentProps<typeof ImportCollec
/** Put a file into the hidden input the way a file picker would. */
async function choose(content: string, name = 'list.trekcollection.json') {
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File([content], name, { type: 'application/json' });
const file = new File([content], name, { type: name.endsWith('.gpx') ? 'application/gpx+xml' : 'application/json' });
// jsdom's File has no usable text(); the component awaits it.
Object.defineProperty(file, 'text', { value: () => Promise.resolve(content) });
await userEvent.upload(input, file);
@@ -45,7 +47,6 @@ describe('ImportCollectionModal (#2198)', () => {
it('FE-COMP-COLLIMPORT-001: opens asking for a file, with Import not yet possible', () => {
renderModal();
expect(screen.getByText('Choose a list file')).toBeInTheDocument();
expect(screen.getByText('.trekcollection.json')).toBeInTheDocument();
expect(importButton()).toBeDisabled();
});
@@ -142,3 +143,188 @@ describe('ImportCollectionModal (#2198)', () => {
expect(props.onImport).not.toHaveBeenCalled();
});
});
describe('ImportCollectionModal with a GPX (#2301)', () => {
const gpx = '<?xml version="1.0"?><gpx version="1.1"><wpt lat="1" lon="2"><name>Pena Palace</name></wpt></gpx>';
const gpxFile: CollectionFile = {
format: 'trek.collection', version: 1, name: 'Sintra loop',
places: [{ name: 'Pena Palace' }, { name: 'Moorish Castle' }],
} as CollectionFile;
const refusal = (code: string) => Object.assign(new Error('Request failed'), { response: { status: 400, data: { error: 'no', code } } });
it('FE-COMP-COLLIMPORT-011: says which files it takes', () => {
renderModal();
expect(screen.getByText('.trekcollection.json · .gpx')).toBeInTheDocument();
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input.accept).toContain('.gpx');
});
it('FE-COMP-COLLIMPORT-012: shows what a GPX holds, and what of it will not become a place', async () => {
const onReadGpx = vi.fn().mockResolvedValue({ file: gpxFile, skipped: 2, track_points: 8 });
const props = renderModal({ onReadGpx });
await choose(gpx, 'sintra.gpx');
await waitFor(() => expect(screen.getByDisplayValue('Sintra loop')).toBeInTheDocument());
expect(onReadGpx).toHaveBeenCalledWith(gpx, 'sintra.gpx');
expect(screen.getByText('2 places')).toBeInTheDocument();
expect(screen.getByText('2 waypoints without usable coordinates are left out.')).toBeInTheDocument();
expect(screen.getByText('This file also has 8 track points. Tracks are not imported, only waypoints.')).toBeInTheDocument();
// The note about ratings and members is about TREK files; a GPX never had any.
expect(screen.queryByText(/Ratings, members/)).toBeNull();
await userEvent.click(importButton());
await waitFor(() => expect(props.onImport).toHaveBeenCalledWith(gpxFile, undefined));
});
it('FE-COMP-COLLIMPORT-013: imports a GPX under a name the person typed', async () => {
const props = renderModal({ onReadGpx: vi.fn().mockResolvedValue({ file: gpxFile, skipped: 0, track_points: 0 }) });
await choose(gpx, 'sintra.gpx');
await waitFor(() => expect(screen.getByDisplayValue('Sintra loop')).toBeInTheDocument());
await userEvent.clear(screen.getByRole('textbox'));
await userEvent.type(screen.getByRole('textbox'), 'Sintra with the kids');
await userEvent.click(importButton());
await waitFor(() => expect(props.onImport).toHaveBeenCalledWith(gpxFile, 'Sintra with the kids'));
expect(screen.queryByText(/track points/)).toBeNull();
});
it('FE-COMP-COLLIMPORT-014: will not make an empty list of a GPX that only has a track', async () => {
const trackOnly = { ...gpxFile, places: [] };
renderModal({ onReadGpx: vi.fn().mockResolvedValue({ file: trackOnly, skipped: 0, track_points: 120 }) });
await choose(gpx, 'track.gpx');
await waitFor(() => expect(screen.getByText('This GPX file has no waypoints, so there is nothing to import.')).toBeInTheDocument());
expect(screen.getByText('This file also has 120 track points. Tracks are not imported, only waypoints.')).toBeInTheDocument();
expect(importButton()).toBeDisabled();
});
it('FE-COMP-COLLIMPORT-015: says in its own words why a GPX was refused', async () => {
const onReadGpx = vi.fn()
.mockRejectedValueOnce(refusal('not-gpx'))
.mockRejectedValueOnce(refusal('too-many-places'))
.mockRejectedValueOnce(refusal('unreadable'));
renderModal({ onReadGpx });
await choose(gpx, 'a.gpx');
await waitFor(() => expect(screen.getByText('That is not a GPX file.')).toBeInTheDocument());
await choose(gpx, 'b.gpx');
await waitFor(() => expect(screen.getByText(
'That file has more than 1000 places. Split it and import the parts one at a time.',
)).toBeInTheDocument());
await choose(gpx, 'c.gpx');
await waitFor(() => expect(screen.getByText('That file could not be read.')).toBeInTheDocument());
expect(importButton()).toBeDisabled();
});
it('FE-COMP-COLLIMPORT-016: says it is reading while the server reads, and why it could not', async () => {
let fail: ((err: unknown) => void) | null = null;
const onReadGpx = vi.fn<GpxReader>(() => new Promise((_, reject) => { fail = reject; }));
renderModal({ onReadGpx });
await choose(gpx, 'slow.gpx');
await waitFor(() => expect(screen.getByText('Reading the file…')).toBeInTheDocument());
fail!(Object.assign(new Error('x'), { response: { status: 503, data: { error: 'The server is busy' } } }));
await waitFor(() => expect(screen.getByText('The server is busy')).toBeInTheDocument());
expect(screen.getByText('Choose a list file')).toBeInTheDocument();
});
});
// ── The same file into a list that is already there (#2301 follow-up) ────────
describe('ImportCollectionModal into an existing list', () => {
const lists = [
{ id: 7, owner_id: 1, name: 'Lisbon 2027', color: '#22c55e', place_count: 4 },
{ id: 9, owner_id: 1, name: 'Porto', color: '#3b82f6', place_count: 2 },
] as Collection[];
const intoProps = (over: Partial<React.ComponentProps<typeof ImportCollectionModal>> = {}) =>
renderModal({ onImportInto: vi.fn().mockResolvedValue(undefined), lists, ...over });
it('FE-COMP-COLLIMPORT-020: offers the choice only once a file has been read', async () => {
intoProps();
expect(screen.queryByText('Add to a list')).not.toBeInTheDocument();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
expect(screen.getByText('New list')).toBeInTheDocument();
// A new list stays the default, so nothing changes for anyone who ignores it.
expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument();
expect(importButton()).toBeEnabled();
});
it('FE-COMP-COLLIMPORT-021: keeps to a new list when there is nothing to add to', async () => {
renderModal({ onImportInto: vi.fn(), lists: [] });
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument());
expect(screen.queryByText('Add to a list')).not.toBeInTheDocument();
});
it('FE-COMP-COLLIMPORT-022: adds the file to the list that was picked, and asks for no name', async () => {
const props = intoProps();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add to a list'));
expect(screen.queryByDisplayValue('Lisbon')).not.toBeInTheDocument();
await userEvent.click(screen.getByText('Porto'));
await userEvent.click(screen.getByRole('button', { name: 'Add to list' }));
await waitFor(() => expect(props.onImportInto).toHaveBeenCalledWith(expect.objectContaining({ name: 'Lisbon' }), 9));
expect(props.onImport).not.toHaveBeenCalled();
});
it('FE-COMP-COLLIMPORT-023: starts on the list that is open, whatever order they come in', async () => {
const props = intoProps({ defaultListId: 9 });
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add to a list'));
await userEvent.click(screen.getByRole('button', { name: 'Add to list' }));
await waitFor(() => expect(props.onImportInto).toHaveBeenCalledWith(expect.anything(), 9));
});
it('FE-COMP-COLLIMPORT-024: says what adding to a list does and does not do', async () => {
intoProps();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add to a list'));
expect(screen.getByText(/Places the list already has stay as they are/)).toBeInTheDocument();
});
it('FE-COMP-COLLIMPORT-025: keeps the dialog open and says why when adding fails', async () => {
const onImportInto = vi.fn().mockRejectedValue(
Object.assign(new Error('x'), { response: { status: 403, data: { error: 'You have read-only access to this list' } } }),
);
intoProps({ onImportInto });
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add to a list'));
await userEvent.click(screen.getByRole('button', { name: 'Add to list' }));
await waitFor(() => expect(screen.getByText('You have read-only access to this list')).toBeInTheDocument());
});
it('FE-COMP-COLLIMPORT-026: goes back to a new list with the name it started with', async () => {
const props = intoProps();
await choose(JSON.stringify(listFile));
await waitFor(() => expect(screen.getByText('Add to a list')).toBeInTheDocument());
await userEvent.click(screen.getByText('Add to a list'));
await userEvent.click(screen.getByText('New list'));
await userEvent.click(importButton());
await waitFor(() => expect(props.onImport).toHaveBeenCalledWith(expect.objectContaining({ name: 'Lisbon' }), undefined));
expect(props.onImportInto).not.toHaveBeenCalled();
});
});
@@ -1,19 +1,152 @@
import React, { useRef, useState } from 'react'
import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle } from 'lucide-react'
import React, { useMemo, useRef, useState } from 'react'
import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle, Route, Info, Plus, FolderInput, Check, Search, Bookmark } from 'lucide-react'
import Modal from '../shared/Modal'
import type { CollectionFile } from '@trek/shared'
import { MAX_COLLECTION_FILE_PLACES, type Collection, type CollectionFile } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { getApiErrorMessage } from '../../types'
import { readCollectionFile, type CollectionFileError, COLLECTION_FILE_EXTENSION } from './collectionFile'
import {
readCollectionFile,
type CollectionFileError,
type GpxLeftovers,
type GpxReader,
COLLECTION_FILE_EXTENSION,
COLLECTION_GPX_EXTENSION,
} from './collectionFile'
interface ImportCollectionModalProps {
onImport: (file: CollectionFile, name?: string) => Promise<void>
/** Adds the file to a list that is already there. Without it the dialog only makes new ones. */
onImportInto?: (file: CollectionFile, collectionId: number) => Promise<void>
/** The lists this person may add to; the server applies the same rule again. */
lists?: Collection[]
/** The list that is open, so the obvious target is the one already selected. */
defaultListId?: number | null
/** Reads a GPX into a list file (#2301); the server does the parsing. */
onReadGpx: GpxReader
onClose: () => void
t: TranslationFn
}
/** Keyed by the error type, so a new way for a file to fail does not build without its words. */
const ERROR_KEYS: Record<CollectionFileError, string> = {
'too-large': 'collections.file.errorTooLarge',
unreadable: 'collections.file.errorUnreadable',
'not-a-collection': 'collections.file.errorNotACollection',
'not-gpx': 'collections.file.errorNotGpx',
'too-many-places': 'collections.file.errorTooManyPlaces',
}
/** What a GPX held besides its places, said before anything is imported. */
function GpxNotes({ leftovers, placeCount, t }: { leftovers: GpxLeftovers; placeCount: number; t: TranslationFn }) {
const notes = [
placeCount === 0 && t('collections.file.gpxEmpty'),
leftovers.skipped > 0 && t('collections.file.gpxSkipped', { count: leftovers.skipped }),
leftovers.trackPoints > 0 && t('collections.file.gpxTrack', { count: leftovers.trackPoints }),
].filter(Boolean)
if (notes.length === 0) return null
return (
<ul className="flex flex-col gap-1">
{notes.map(note => (
<li key={note as string} className="flex items-start gap-2 text-[12px] text-content-muted">
<Info size={13} className="shrink-0 mt-0.5 text-content-faint" />
<span>{note}</span>
</li>
))}
</ul>
)
}
/** One of the two answers to "where do these places go", as a card you can tap. */
function TargetCard({ active, icon: Icon, title, hint, onClick }: {
active: boolean
icon: typeof Plus
title: string
hint: string
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`relative flex flex-col items-start gap-1.5 px-3 py-3 rounded-xl border text-left transition-colors ${
active ? 'border-accent bg-surface-hover' : 'border-edge hover:bg-surface-hover'
}`}
>
<span className={`w-8 h-8 rounded-lg flex items-center justify-center ${active ? 'bg-accent text-accent-text' : 'bg-surface-input text-content-muted'}`}>
<Icon size={15} />
</span>
<span className="text-[13px] font-semibold text-content">{title}</span>
<span className="text-[11.5px] text-content-faint leading-snug">{hint}</span>
{active && (
<span className="absolute top-2 right-2 w-4 h-4 rounded-full bg-accent text-accent-text flex items-center justify-center">
<Check size={11} strokeWidth={3} />
</span>
)}
</button>
)
}
/** The lists the file may be added to, the open one first. */
function ListChoice({ lists, selectedId, onSelect, t }: {
lists: Collection[]
selectedId: number | null
onSelect: (id: number) => void
t: TranslationFn
}) {
const [search, setSearch] = useState('')
const shown = useMemo(() => {
const q = search.trim().toLowerCase()
return q ? lists.filter(l => l.name.toLowerCase().includes(q)) : lists
}, [lists, search])
return (
<div className="flex flex-col gap-2">
{lists.length > 5 && (
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.file.searchLists')}
className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</div>
)}
<div className="flex flex-col gap-1 max-h-[34vh] overflow-y-auto -mx-1 px-1">
{shown.map(list => {
const active = list.id === selectedId
return (
<button
key={list.id}
type="button"
onClick={() => onSelect(list.id)}
aria-pressed={active}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-colors ${
active ? 'border-accent bg-surface-hover' : 'border-edge bg-surface-card hover:bg-surface-hover'
}`}
>
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: list.color || '#6366f1' }}>
<Bookmark size={15} />
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-semibold text-content truncate">{list.name}</span>
<span className="block text-[11.5px] text-content-faint">{t('collections.placeCount', { count: list.place_count ?? 0 })}</span>
</span>
{active && <Check size={16} className="shrink-0 text-accent" />}
</button>
)
})}
{shown.length === 0 && (
<p className="text-center text-[13px] text-content-faint py-6">{t('collections.noOtherLists')}</p>
)}
</div>
</div>
)
}
/**
* Read a list file and make a list of it (#2198).
* Read a list file or a GPX and make a list of it (#2198, #2301).
*
* Two steps on purpose. A file from somebody else is an unknown quantity, so
* it is read and shown first — how many places, which labels, what the list is
@@ -22,27 +155,58 @@ interface ImportCollectionModalProps {
* "Lisbon (from Ana)" on the way in, and renaming it afterwards means finding
* the list editor.
*
* The file never leaves the browser as a file: it is parsed here, and what
* goes to the server is the parsed list, through the same contract the server
* validates against.
* A list file never leaves the browser as a file: it is parsed here. A GPX is
* read by the server into the same kind of list file, and from then on the two
* are one path: what goes to the import is a list file, through the same
* contract the server validates against.
*
* The file can go into a list that is already there instead of a new one. That
* is a choice rather than the default, because the two do different things: a
* new list is the file as it stands, while adding to a list leaves everything
* in it alone and skips the places it already has.
*/
export default function ImportCollectionModal({ onImport, onClose, t }: ImportCollectionModalProps): React.ReactElement {
export default function ImportCollectionModal({
onImport, onImportInto, lists, defaultListId, onReadGpx, onClose, t,
}: ImportCollectionModalProps): React.ReactElement {
const inputRef = useRef<HTMLInputElement>(null)
const [file, setFile] = useState<CollectionFile | null>(null)
const [leftovers, setLeftovers] = useState<GpxLeftovers | null>(null)
const [name, setName] = useState('')
const [error, setError] = useState<CollectionFileError | 'failed' | null>(null)
const [failedMessage, setFailedMessage] = useState<string | null>(null)
const [reading, setReading] = useState(false)
const [busy, setBusy] = useState(false)
const [dragging, setDragging] = useState(false)
const [target, setTarget] = useState<'new' | 'existing'>('new')
// The open list first: it is the one somebody importing from inside a list means.
const targets = useMemo(() => {
const all = lists ?? []
const open = all.find(l => l.id === defaultListId)
return open ? [open, ...all.filter(l => l.id !== open.id)] : all
}, [lists, defaultListId])
const [listId, setListId] = useState<number | null>(() => targets[0]?.id ?? null)
const canChoose = !!onImportInto && targets.length > 0
const intoExisting = canChoose && target === 'existing'
const take = async (chosen: File | undefined) => {
if (!chosen) return
if (!chosen || reading) return
setError(null)
setFailedMessage(null)
const result = await readCollectionFile(chosen)
setFile(result.file)
setError(result.error)
if (result.file) setName(result.file.name)
setReading(true)
try {
const result = await readCollectionFile(chosen, onReadGpx)
setFile(result.file)
setLeftovers(result.gpx ?? null)
setError(result.error)
if (result.file) setName(result.file.name)
} catch (err) {
setFile(null)
setLeftovers(null)
setError('failed')
setFailedMessage(getApiErrorMessage(err, t('common.error')))
} finally {
setReading(false)
}
}
const onDrop = (e: React.DragEvent) => {
@@ -57,8 +221,12 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
setError(null)
setFailedMessage(null)
try {
const trimmed = name.trim()
await onImport(file, trimmed && trimmed !== file.name ? trimmed : undefined)
if (intoExisting && listId != null) {
await onImportInto!(file, listId)
} else {
const trimmed = name.trim()
await onImport(file, trimmed && trimmed !== file.name ? trimmed : undefined)
}
} catch (err) {
setError('failed')
setFailedMessage(getApiErrorMessage(err, t('common.error')))
@@ -69,10 +237,10 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
const errorText = error === 'failed'
? (failedMessage ?? t('common.error'))
: error === 'too-large' ? t('collections.file.errorTooLarge')
: error === 'unreadable' ? t('collections.file.errorUnreadable')
: error === 'not-a-collection' ? t('collections.file.errorNotACollection')
: null
: error ? t(ERROR_KEYS[error], { count: MAX_COLLECTION_FILE_PLACES }) : null
// A GPX of nothing but a track would make an empty list, which is never what was meant.
const nothingToImport = !!leftovers && !!file && file.places.length === 0
return (
<Modal
@@ -88,11 +256,11 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
<button
type="button"
onClick={submit}
disabled={!file || busy || !name.trim()}
disabled={!file || busy || nothingToImport || (intoExisting ? listId == null : !name.trim())}
className="px-4 py-2 rounded-lg text-[13px] font-semibold bg-accent text-accent-text disabled:opacity-50 transition-opacity inline-flex items-center gap-2"
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('collections.file.confirm')}
{intoExisting ? t('collections.file.confirmInto') : t('collections.file.confirm')}
</button>
</div>
}
@@ -101,7 +269,7 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
<input
ref={inputRef}
type="file"
accept=".json,application/json"
accept=".json,application/json,.gpx,application/gpx+xml"
className="hidden"
onChange={e => { void take(e.target.files?.[0]); e.target.value = '' }}
/>
@@ -113,19 +281,23 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
disabled={reading}
aria-busy={reading}
className={`flex flex-col items-center justify-center gap-2 px-4 py-10 rounded-xl border border-dashed transition-colors ${
dragging ? 'border-accent bg-surface-hover' : 'border-edge hover:bg-surface-hover'
}`}
>
<Upload size={22} className="text-content-faint" />
<span className="text-[13px] font-medium text-content">{t('collections.file.choose')}</span>
<span className="text-[11.5px] text-content-faint">{COLLECTION_FILE_EXTENSION}</span>
{reading ? <Loader2 size={22} className="text-content-faint animate-spin" /> : <Upload size={22} className="text-content-faint" />}
<span className="text-[13px] font-medium text-content">
{reading ? t('collections.file.reading') : t('collections.file.choose')}
</span>
<span className="text-[11.5px] text-content-faint">{COLLECTION_FILE_EXTENSION} · {COLLECTION_GPX_EXTENSION}</span>
</button>
) : (
<>
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card">
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: file.color || '#6366f1' }}>
<FileJson size={15} />
{leftovers ? <Route size={15} /> : <FileJson size={15} />}
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-semibold text-content truncate">{file.name}</span>
@@ -145,17 +317,47 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
<p className="text-[12px] text-content-muted whitespace-pre-wrap">{file.description}</p>
)}
<label className="flex flex-col gap-1.5">
<span className="text-[12px] font-medium text-content-muted">{t('collections.listName')}</span>
<input
value={name}
onChange={e => setName(e.target.value)}
maxLength={120}
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</label>
{leftovers && <GpxNotes leftovers={leftovers} placeCount={file.places.length} t={t} />}
<p className="text-[11.5px] text-content-faint">{t('collections.file.hint')}</p>
{canChoose && (
<div className="grid grid-cols-2 gap-2">
<TargetCard
active={target === 'new'}
icon={Plus}
title={t('collections.file.targetNew')}
hint={t('collections.file.targetNewHint')}
onClick={() => setTarget('new')}
/>
<TargetCard
active={intoExisting}
icon={FolderInput}
title={t('collections.file.targetExisting')}
hint={t('collections.file.targetExistingHint')}
onClick={() => setTarget('existing')}
/>
</div>
)}
{intoExisting ? (
<>
<ListChoice lists={targets} selectedId={listId} onSelect={setListId} t={t} />
<p className="text-[11.5px] text-content-faint">{t('collections.file.intoHint')}</p>
</>
) : (
<>
<label className="flex flex-col gap-1.5">
<span className="text-[12px] font-medium text-content-muted">{t('collections.listName')}</span>
<input
value={name}
onChange={e => setName(e.target.value)}
maxLength={120}
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</label>
{!leftovers && <p className="text-[11.5px] text-content-faint">{t('collections.file.hint')}</p>}
</>
)}
</>
)}
@@ -1,6 +1,14 @@
// FE-COLL-FILE-001 to FE-COLL-FILE-012
// FE-COLL-FILE-001 to FE-COLL-FILE-020
import { describe, it, expect, vi, afterEach } from 'vitest'
import { collectionFileName, downloadCollectionFile, readCollectionFile, COLLECTION_FILE_EXTENSION } from './collectionFile'
import {
collectionFileName,
downloadCollectionFile,
downloadCollectionGpx,
readCollectionFile,
COLLECTION_FILE_EXTENSION,
COLLECTION_GPX_EXTENSION,
type GpxReader,
} from './collectionFile'
import type { CollectionFile } from '@trek/shared'
const file = (over: Partial<CollectionFile> = {}): CollectionFile => ({
@@ -8,10 +16,16 @@ const file = (over: Partial<CollectionFile> = {}): CollectionFile => ({
} as CollectionFile)
/** A File whose text() the jsdom build does not provide on its own. */
function asFile(content: string, size = content.length): File {
return { size, text: () => Promise.resolve(content) } as unknown as File
function asFile(content: string, size = content.length, name = 'list.trekcollection.json'): File {
return { size, name, text: () => Promise.resolve(content) } as unknown as File
}
/** A reader that must not be reached: a list file is never sent to the server. */
const noGpx: GpxReader = () => { throw new Error('a list file went to the GPX reader') }
/** A rejection the way axios delivers the server's refusal. */
const refused = (code: string) => Object.assign(new Error('Request failed'), { response: { status: 400, data: { error: 'no', code } } })
describe('collectionFileName', () => {
it('FE-COLL-FILE-001: turns a list name into a short ascii file name', () => {
expect(collectionFileName('Lisbon')).toBe('lisbon' + COLLECTION_FILE_EXTENSION)
@@ -23,69 +37,94 @@ describe('collectionFileName', () => {
const name = collectionFileName('a'.repeat(200))
expect(name).toBe('a'.repeat(40) + COLLECTION_FILE_EXTENSION)
})
it('FE-COLL-FILE-013: names a GPX the same way, with its own extension', () => {
expect(collectionFileName('Wochenende in Rom!', COLLECTION_GPX_EXTENSION)).toBe('wochenende-in-rom.gpx')
})
})
describe('downloadCollectionFile', () => {
afterEach(() => { vi.restoreAllMocks() })
describe('downloads', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('FE-COLL-FILE-003: hands the browser a named JSON blob and releases it afterwards', () => {
/** Captures what would be downloaded, and under which name. */
function captureDownload() {
vi.useFakeTimers()
const revoke = vi.fn()
const created: Blob[] = []
const names: string[] = []
vi.stubGlobal('URL', {
createObjectURL: (b: Blob) => { created.push(b); return 'blob:x' },
revokeObjectURL: revoke,
})
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
expect(this.download).toBe('lisbon' + COLLECTION_FILE_EXTENSION)
expect(this.href).toContain('blob:')
names.push(this.download)
})
return { revoke, created, names, click }
}
it('FE-COLL-FILE-003: hands the browser a named JSON blob and releases it afterwards', () => {
const { revoke, created, names, click } = captureDownload()
downloadCollectionFile(file())
expect(click).toHaveBeenCalledTimes(1)
expect(names).toEqual(['lisbon' + COLLECTION_FILE_EXTENSION])
expect(created[0].type).toBe('application/json')
expect(document.querySelector('a')).toBeNull()
expect(revoke).not.toHaveBeenCalled()
vi.runAllTimers()
expect(revoke).toHaveBeenCalledWith('blob:x')
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('FE-COLL-FILE-014: hands the browser a GPX under the list name', async () => {
const { created, names } = captureDownload()
downloadCollectionGpx('Lisbon', '<gpx/>')
expect(names).toEqual(['lisbon.gpx'])
expect(created[0].type).toBe('application/gpx+xml')
expect(await created[0].text()).toBe('<gpx/>')
})
})
describe('readCollectionFile', () => {
it('FE-COLL-FILE-004: reads one of ours', async () => {
const result = await readCollectionFile(asFile(JSON.stringify(file())))
const result = await readCollectionFile(asFile(JSON.stringify(file())), noGpx)
expect(result.error).toBeNull()
expect(result.file?.name).toBe('Lisbon')
expect(result.gpx).toBeUndefined()
})
it('FE-COLL-FILE-005: refuses a file too large to be one, without reading it', async () => {
const text = vi.fn()
const huge = { size: 1024 * 1024 + 1, text } as unknown as File
const result = await readCollectionFile(huge)
const result = await readCollectionFile(huge, noGpx)
expect(result).toEqual({ file: null, error: 'too-large' })
expect(text).not.toHaveBeenCalled()
})
it('FE-COLL-FILE-006: says a file is unreadable when it is not JSON', async () => {
expect(await readCollectionFile(asFile('not json at all'))).toEqual({ file: null, error: 'unreadable' })
expect(await readCollectionFile(asFile('not json at all'), noGpx)).toEqual({ file: null, error: 'unreadable' })
})
it('FE-COLL-FILE-007: says a JSON that is not a list is not a list', async () => {
for (const content of ['{}', '[]', 'null', '"a string"', JSON.stringify({ format: 'something.else', version: 1, name: 'x', places: [] })]) {
expect(await readCollectionFile(asFile(content)), content).toEqual({ file: null, error: 'not-a-collection' })
expect(await readCollectionFile(asFile(content), noGpx), content).toEqual({ file: null, error: 'not-a-collection' })
}
})
it('FE-COLL-FILE-008: refuses a list with no name, since the list would have none', async () => {
const result = await readCollectionFile(asFile(JSON.stringify({ format: 'trek.collection', version: 1, name: '', places: [] })))
const result = await readCollectionFile(asFile(JSON.stringify({ format: 'trek.collection', version: 1, name: '', places: [] })), noGpx)
expect(result.error).toBe('not-a-collection')
})
it('FE-COLL-FILE-009: reads an empty list, which is a list somebody has not filled yet', async () => {
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: [] }))))
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: [] }))), noGpx)
expect(result.error).toBeNull()
expect(result.file?.places).toEqual([])
})
@@ -94,20 +133,66 @@ describe('readCollectionFile', () => {
// The envelope counts places; each one is validated where it is written.
const result = await readCollectionFile(asFile(JSON.stringify(file({
places: [{ name: 'Fine' }, { nonsense: true }] as never,
}))))
}))), noGpx)
expect(result.error).toBeNull()
expect(result.file?.places).toHaveLength(2)
})
it('FE-COLL-FILE-011: refuses a file carrying more places than the contract allows', async () => {
const tooMany = Array.from({ length: 1001 }, (_, i) => ({ name: `Place ${i}` }))
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: tooMany as never }))))
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: tooMany as never }))), noGpx)
expect(result.error).toBe('not-a-collection')
})
it('FE-COLL-FILE-012: reads a file from a later version, since its additions are optional', async () => {
const result = await readCollectionFile(asFile(JSON.stringify({ ...file(), version: 99, somethingNew: 'x' })))
const result = await readCollectionFile(asFile(JSON.stringify({ ...file(), version: 99, somethingNew: 'x' })), noGpx)
expect(result.error).toBeNull()
expect(result.file?.version).toBe(99)
})
})
describe('readCollectionFile with a GPX (#2301)', () => {
const gpx = '<?xml version="1.0"?><gpx version="1.1"><wpt lat="1" lon="2"><name>A</name></wpt></gpx>'
it('FE-COLL-FILE-015: sends a GPX to the reader with its file name and keeps what did not become a place', async () => {
const read = vi.fn<GpxReader>().mockResolvedValue({ file: file({ name: 'Favourites' }), skipped: 2, track_points: 40 })
const result = await readCollectionFile(asFile(gpx, gpx.length, 'favourites.gpx'), read)
expect(read).toHaveBeenCalledWith(gpx, 'favourites.gpx')
expect(result).toEqual({ file: expect.objectContaining({ name: 'Favourites' }), error: null, gpx: { skipped: 2, trackPoints: 40 } })
})
it('FE-COLL-FILE-016: knows a GPX by what it is, not by what it is called', async () => {
const read = vi.fn<GpxReader>().mockResolvedValue({ file: file(), skipped: 0, track_points: 0 })
// A byte-order mark and a blank line before the declaration, under a .xml name.
await readCollectionFile(asFile(String.fromCharCode(0xfeff) + '\n ' + gpx, 100, 'export.xml'), read)
expect(read).toHaveBeenCalledTimes(1)
})
it('FE-COLL-FILE-017: holds a GPX to the same size limit, before reading it', async () => {
const read = vi.fn<GpxReader>()
expect(await readCollectionFile(asFile(gpx, 1024 * 1024 + 1, 'big.gpx'), read)).toEqual({ file: null, error: 'too-large' })
expect(read).not.toHaveBeenCalled()
})
it('FE-COLL-FILE-018: turns the reason the server refused a GPX into the dialog\'s own', async () => {
for (const code of ['unreadable', 'not-gpx', 'too-many-places', 'too-large'] as const) {
const read = vi.fn<GpxReader>().mockRejectedValue(refused(code))
expect(await readCollectionFile(asFile(gpx), read), code).toEqual({ file: null, error: code, gpx: null })
}
})
it('FE-COLL-FILE-019: passes on a failure that was not about the file', async () => {
const offline = vi.fn<GpxReader>().mockRejectedValue(new Error('Network Error'))
await expect(readCollectionFile(asFile(gpx), offline)).rejects.toThrow('Network Error')
const unknownCode = vi.fn<GpxReader>().mockRejectedValue(refused('something-new'))
await expect(readCollectionFile(asFile(gpx), unknownCode)).rejects.toThrow('Request failed')
})
it('FE-COLL-FILE-020: says a file is unreadable when its text cannot be had at all', async () => {
const broken = { size: 10, name: 'x.gpx', text: () => Promise.reject(new Error('gone')) } as unknown as File
expect(await readCollectionFile(broken, noGpx)).toEqual({ file: null, error: 'unreadable' })
})
})
@@ -1,34 +1,46 @@
import { collectionFileSchema, MAX_COLLECTION_FILE_BYTES, type CollectionFile } from '@trek/shared'
import {
COLLECTION_GPX_PROBLEMS,
collectionFileSchema,
MAX_COLLECTION_FILE_BYTES,
type CollectionFile,
type CollectionGpxProblem,
type CollectionGpxReadResult,
} from '@trek/shared'
/**
* The browser half of list export and import (#2198).
* The browser half of list export and import (#2198, #2301).
*
* The file itself is built by the server, which is also where the decision
* about what may leave the instance lives (`collection-file.schema.ts`). This
* module only hands the JSON to the browser as a download, and reads a chosen
* file back into something the import request will accept.
* Both files are built by the server, which is also where the decision about
* what may leave the instance lives (`collection-file.schema.ts`). This module
* only hands one to the browser as a download, and reads a chosen file back
* into something the import request will accept.
*
* A file that arrives here is a stranger's JSON and is treated as one: read
* locally, never uploaded, parsed rather than evaluated, size-capped before
* parsing, and put through the shared contract so a field this build does not
* know is dropped instead of travelling on to the server.
* A file that arrives here is a stranger's and is treated as one. A list file
* is read locally, never uploaded, parsed rather than evaluated, size-capped
* before parsing, and put through the shared contract so a field this build
* does not know is dropped instead of travelling on. A GPX is size-capped the
* same way and then sent as text to the server, which reads it with the XML
* library the trip importer already uses and hands back a list file: one side
* that parses XML, and it is the one that enforces the limits.
*/
export const COLLECTION_FILE_EXTENSION = '.trekcollection.json'
export const COLLECTION_GPX_EXTENSION = '.gpx'
/** The two ways a list can leave: whole, for another TREK, or as waypoints for everything else. */
export type CollectionExportFormat = 'trek' | 'gpx'
/** A file name from a list name: lowercase, ascii-ish, short. */
export function collectionFileName(name: string): string {
export function collectionFileName(name: string, extension = COLLECTION_FILE_EXTENSION): string {
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40)
return `${slug || 'collection'}${COLLECTION_FILE_EXTENSION}`
return `${slug || 'collection'}${extension}`
}
/** Hand the file to the browser as a download. */
export function downloadCollectionFile(file: CollectionFile): void {
const json = JSON.stringify(file, null, 2)
const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }))
function download(content: string, type: string, fileName: string): void {
const url = URL.createObjectURL(new Blob([content], { type }))
const a = document.createElement('a')
a.href = url
a.download = collectionFileName(file.name)
a.download = fileName
document.body.appendChild(a)
a.click()
a.remove()
@@ -37,12 +49,30 @@ export function downloadCollectionFile(file: CollectionFile): void {
setTimeout(() => URL.revokeObjectURL(url), 0)
}
export type CollectionFileError = 'too-large' | 'unreadable' | 'not-a-collection'
/** Hand the file to the browser as a download. */
export function downloadCollectionFile(file: CollectionFile): void {
download(JSON.stringify(file, null, 2), 'application/json', collectionFileName(file.name))
}
/** Hand a list's GPX to the browser as a download, named like its list file would be. */
export function downloadCollectionGpx(listName: string, gpx: string): void {
download(gpx, 'application/gpx+xml', collectionFileName(listName, COLLECTION_GPX_EXTENSION))
}
export type CollectionFileError = 'not-a-collection' | CollectionGpxProblem
/** What a GPX held that did not become a place. */
export interface GpxLeftovers {
/** Waypoints without usable coordinates. */
skipped: number
/** Track points and unnamed route points: lines, not places. */
trackPoints: number
}
/**
* Exactly one of the two is set.
* Exactly one of `file` and `error` is set; `gpx` only when the file was one.
*
* Two nullable fields rather than a discriminated union on an `ok` flag: the
* Nullable fields rather than a discriminated union on an `ok` flag: the
* client compiles with `strict: false`, and without `strictNullChecks` a
* `{ok: true} | {ok: false}` union does not narrow, so every read of the
* failure branch would need a cast.
@@ -50,20 +80,57 @@ export type CollectionFileError = 'too-large' | 'unreadable' | 'not-a-collection
export interface ParsedCollectionFile {
file: CollectionFile | null
error: CollectionFileError | null
gpx?: GpxLeftovers | null
}
/** Reads a GPX document into a list file. The server does that, so the caller supplies it. */
export type GpxReader = (gpx: string, fileName: string) => Promise<CollectionGpxReadResult>
/** A list file is JSON and JSON never opens with `<`, so this is all the sniffing a GPX needs. */
function looksLikeXml(text: string): boolean {
const withoutBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
return withoutBom.trimStart().startsWith('<')
}
/** The reason the server gave for refusing a GPX, when it gave one this build knows. */
function gpxProblemOf(err: unknown): CollectionGpxProblem | null {
const code: unknown = (err as { response?: { data?: { code?: unknown } } } | null)?.response?.data?.code
return COLLECTION_GPX_PROBLEMS.find(problem => problem === code) ?? null
}
async function readGpx(text: string, fileName: string, reader: GpxReader): Promise<ParsedCollectionFile> {
try {
const result = await reader(text, fileName)
return { file: result.file, error: null, gpx: { skipped: result.skipped, trackPoints: result.track_points } }
} catch (err) {
const problem = gpxProblemOf(err)
// No code means the failure was not the file's (offline, a 500), and that
// is for the dialog to report as it reports a failed import.
if (!problem) throw err
return { file: null, error: problem, gpx: null }
}
}
/**
* Read a chosen file into a list file, or say why it is not one.
*
* The three failures are kept apart because they are three different things a
* person can do something about: a file too big to be one of ours, a file that
* is not JSON at all, and a JSON that is not a TREK list.
* The failures are kept apart because they are different things a person can
* do something about: a file too big to be one of ours, a file that is not
* readable at all, a JSON that is not a TREK list, an XML that is not a GPX,
* and a GPX with more places than a list may hold.
*/
export async function readCollectionFile(file: File): Promise<ParsedCollectionFile> {
export async function readCollectionFile(file: File, readGpxFile: GpxReader): Promise<ParsedCollectionFile> {
if (file.size > MAX_COLLECTION_FILE_BYTES) return { file: null, error: 'too-large' }
let text: string
try {
text = await file.text()
} catch {
return { file: null, error: 'unreadable' }
}
if (looksLikeXml(text)) return readGpx(text, file.name ?? '', readGpxFile)
let raw: unknown
try {
raw = JSON.parse(await file.text())
raw = JSON.parse(text)
} catch {
return { file: null, error: 'unreadable' }
}
@@ -1,4 +1,4 @@
// FE-DAWARICH-ACCEPT-001 to FE-DAWARICH-ACCEPT-024
// FE-DAWARICH-ACCEPT-001 to FE-DAWARICH-ACCEPT-026
/**
* The review step between a detector's guess and a row in somebody's trip.
*
@@ -11,7 +11,8 @@
* pinning, and it is the one a refactor breaks first.
*
* The rest is the shape of the three targets. `place` needs a trip and offers a
* day, `journal` needs a journey and asks for a story, `bucket_list` needs a
* day (and no date: the day IS where it lands, and the server reads no date for
* a place), `journal` needs a journey, a date and a story, `bucket_list` needs a
* matched wish and hides the clock entirely because ticking a wish off uses the
* recorded timestamp and nothing else. Each of the three can be in a state
* where there is nothing to write to (no trips, no journeys, no matched wish),
@@ -173,20 +174,20 @@ describe('DawarichAcceptDialog', () => {
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Café Reichard am Dom' } })
fireEvent.change(screen.getByLabelText('Notes'), { target: { value: 'Two hours and a lot of cake.' } })
typeDate('2026-09-12')
fireEvent.change(screen.getByLabelText('Arrived'), { target: { value: '09:00' } })
fireEvent.change(screen.getByLabelText('Left'), { target: { value: '11:45' } })
pick('Not on a day yet', 'Day 1')
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
// The day is the only calendar fact a place carries: there is no date in
// this body because there is no date field on this rail.
expect(onConfirm).toHaveBeenCalledWith({
target: 'place',
tripId: 31,
dayId: 401,
name: 'Café Reichard am Dom',
notes: 'Two hours and a lot of cake.',
date: '2026-09-12',
time: '09:00',
endTime: '11:45',
})
@@ -381,10 +382,11 @@ describe('DawarichAcceptDialog', () => {
it('FE-DAWARICH-ACCEPT-018: a recording with no readable clock or date sends neither', () => {
// Dawarich has shipped date-only and non-ISO timestamps; the clock parser
// answers with an empty string rather than a guess, and an empty string must
// not reach the body as `time: ""`.
// not reach the body as `time: ""` or `date: ""`. On the journal rail,
// because that is the one rail that shows the date beside the clock.
const { onConfirm } = open({
trips: TRIPS,
daysForTrip,
target: 'journal',
journals: JOURNALS,
suggestion: stay({ startedAt: '2026-09-10', endedAt: '2026-09-10', localDate: '' }),
})
@@ -392,8 +394,8 @@ describe('DawarichAcceptDialog', () => {
expect(screen.getByLabelText('Left')).toHaveValue('')
expect(screen.getByRole('button', { name: 'Date' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31 })
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
expect(onConfirm).toHaveBeenCalledWith({ target: 'journal', journalId: 71 })
})
it('FE-DAWARICH-ACCEPT-019: the fields follow the stay, and survive a re-render of the same one', () => {
@@ -524,4 +526,43 @@ describe('DawarichAcceptDialog', () => {
endTime: '12:40',
})
})
it('FE-DAWARICH-ACCEPT-025: the place rail offers no date, because the day picker is where a place lands', () => {
open({ trips: TRIPS, daysForTrip })
// A place has no date of its own: the server reads a date for a journal
// entry and for nothing else, so a date field on this rail was one whose
// change moved the place nowhere while the toast still said it landed.
// The clock stays, the server does read that.
expect(screen.queryByText('Date')).toBeNull()
expect(screen.queryByRole('button', { name: 'Enter date manually' })).toBeNull()
expect(screen.getByLabelText('Arrived')).toHaveValue('10:15')
expect(screen.getByLabelText('Left')).toHaveValue('12:40')
expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument()
})
it('FE-DAWARICH-ACCEPT-026: a corrected date travels with a journal entry, and never with a place', () => {
// Both rails on one stay, so the dialog holds the corrected date in state
// across the switch: the same shape as switching rails with a note typed.
const { onConfirm, update } = open({ target: 'journal', journals: JOURNALS, trips: TRIPS, daysForTrip })
expect(screen.getByText('Date')).toBeInTheDocument()
typeDate('2026-09-12')
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
expect(onConfirm).toHaveBeenLastCalledWith({
target: 'journal',
journalId: 71,
date: '2026-09-12',
time: '10:15',
endTime: '12:40',
})
// Same stay, now as a place. The corrected date is still in state and would
// be silently dropped by the server; it must not be in the body at all,
// so what the dialog claims to send is what is actually written.
update({ target: 'place' })
expect(screen.queryByText('Date')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
expect(onConfirm).toHaveBeenLastCalledWith({ target: 'place', tripId: 31, time: '10:15', endTime: '12:40' })
})
})
@@ -114,7 +114,10 @@ export default function DawarichAcceptDialog({
}
if (name.trim() && name.trim() !== suggestion.name) body.name = name.trim()
if (notes.trim()) body.notes = notes.trim()
if (date && date !== suggestion.localDate) body.date = date
// Only an entry carries a date. A place lands on the day chosen below and
// the server reads nothing else for it, so a date corrected on the journal
// rail must not ride along when the same stay is then added as a place.
if (target === 'journal' && date && date !== suggestion.localDate) body.date = date
if (time) body.time = time
if (endTime) body.endTime = endTime
onConfirm(body)
@@ -199,15 +202,27 @@ export default function DawarichAcceptDialog({
itself from the OS locale and a native `type="time"` ignores the 12h/24h
setting outright (#2067) — and neither follows the colour scheme. The
date column is the wide one because a localized date is long and a
clock never is. */}
clock never is.
The date is the journal rail's alone. A place has no date of its own:
the day picker below is where it lands, and a date field beside that
picker was a second answer to the same question, one the server never
read. The two clocks then share the row the way they do on the place
form. */}
{target !== 'bucket_list' && (
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_124px_124px] gap-2">
<div>
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.date')}
</span>
<CustomDatePicker value={date} onChange={setDate} />
</div>
<div
className={`grid grid-cols-1 gap-2 ${
target === 'journal' ? 'sm:grid-cols-[minmax(0,1fr)_124px_124px]' : 'sm:grid-cols-2'
}`}
>
{target === 'journal' && (
<div>
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.date')}
</span>
<CustomDatePicker value={date} onChange={setDate} />
</div>
)}
<div>
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.accept.from')}
@@ -1,10 +1,11 @@
// FE-COMP-FILEMANAGER-001 to FE-COMP-FILEMANAGER-012
// FE-COMP-FILEMANAGER-001 to FE-COMP-FILEMANAGER-038
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useDocSyncOfferStore } from '../../store/docSyncOfferStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import type { TripFile } from '../../types';
@@ -104,6 +105,15 @@ beforeEach(() => {
}),
);
// Document sync, as a fresh install has it: every provider off, nothing bound.
useDocSyncOfferStore.setState({ bound: {}, providers: null });
server.use(
http.get('/api/trips/:tripId/docsync/providers', () => HttpResponse.json([])),
http.get('/api/trips/:tripId/docsync/links', () => HttpResponse.json([])),
http.get('/api/trips/:tripId/docsync/connections', () => HttpResponse.json([])),
http.get('/api/trips/:tripId/docsync/status', () => HttpResponse.json({ links: [], items: {} })),
);
// Stub window.confirm
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
@@ -631,4 +641,60 @@ describe('FileManager', () => {
expect(call[0]).toBeInstanceOf(FormData);
});
});
describe('document sync button', () => {
const paperless = { id: 'paperless', name: 'Paperless-ngx', description: null, icon: 'paperless', available: true, fields: [] };
const trashButton = () => screen.getByText('Trash').closest('button') as HTMLButtonElement;
it('FE-COMP-FILEMANAGER-036: with no provider on and nothing bound there is no sync button, and the trash sits flush right', async () => {
const asked: string[] = [];
server.use(
http.get('/api/trips/:tripId/docsync/links', () => {
asked.push('links');
return HttpResponse.json([]);
}),
);
render(<FileManager {...defaultProps} />);
await waitFor(() => expect(asked).toContain('links'));
expect(screen.queryByTitle('Document sync')).not.toBeInTheDocument();
expect(trashButton()).toHaveStyle({ marginLeft: 'auto' });
});
it('FE-COMP-FILEMANAGER-037: somebody who may bind the trip gets it once a provider is on, and it opens the panel', async () => {
server.use(http.get('/api/trips/:tripId/docsync/providers', () => HttpResponse.json([paperless])));
render(<FileManager {...defaultProps} />);
const user = userEvent.setup();
const button = await screen.findByTitle('Document sync');
expect(button).toHaveStyle({ marginLeft: 'auto' });
expect(trashButton().style.marginLeft).toBe('');
await user.click(button);
expect(await screen.findByText('Connect a provider')).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-038: a member sees it on a bound trip only', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 999, role: 'user' }), isAuthenticated: true });
server.use(
http.get('/api/trips/:tripId/docsync/providers', () => HttpResponse.json([paperless])),
http.get('/api/trips/:tripId/docsync/links', () => HttpResponse.json([{ id: 1, providerId: 'paperless' }])),
);
const { unmount } = render(<FileManager {...defaultProps} />);
expect(await screen.findByTitle('Document sync')).toBeInTheDocument();
unmount();
useDocSyncOfferStore.setState({ bound: {}, providers: null });
const asked: string[] = [];
server.use(
http.get('/api/trips/:tripId/docsync/links', () => {
asked.push('links');
return HttpResponse.json([]);
}),
);
render(<FileManager {...defaultProps} />);
await waitFor(() => expect(asked).toContain('links'));
expect(screen.queryByTitle('Document sync')).not.toBeInTheDocument();
});
});
});
@@ -7,6 +7,7 @@ import { isMarkdown } from './FileManager.helpers'
import { FileManagerToolbar } from './FileManagerToolbar'
import { TrashView } from './FileManagerTrashView'
import { FilesView } from './FileManagerFilesView'
import DocSyncPanel from './docsync/DocSyncPanel'
export default function FileManager(props: FileManagerProps) {
const S = useFileManager(props)
@@ -24,6 +25,19 @@ export default function FileManager(props: FileManagerProps) {
? <MarkdownPreviewModal {...S} />
: <PdfPreviewModal {...S} />)}
{/* Document sync opens as its own dialog: it is configuration for the
documents on this screen, so it belongs here rather than in settings,
but it is a task with a beginning and an end and does not belong
inside the list it configures. */}
{S.showDocSync && (
<DocSyncPanel
tripId={props.tripId}
tripTitle={S.trip?.title}
canManage={S.canManageSync}
onClose={() => S.setShowDocSync(false)}
/>
)}
{/* Toolbar */}
<FileManagerToolbar {...S} />
@@ -1,8 +1,8 @@
import { Star, Trash2 } from 'lucide-react'
import { FolderSync, Star, Trash2 } from 'lucide-react'
import type { FileManagerState } from './useFileManager'
export function FileManagerToolbar(S: FileManagerState) {
const { showTrash, t, files, filterType, setFilterType, toggleTrash } = S
const { showTrash, t, files, filterType, setFilterType, toggleTrash, setShowDocSync, docSyncOffered } = S
return (
<div style={{ padding: '24px 28px 0', flexShrink: 0 }} className="max-md:!px-4 max-md:!pt-4">
<div style={{
@@ -63,12 +63,29 @@ export function FileManagerToolbar(S: FileManagerState) {
</>
)}
{/* Opens the sync panel. Placed next to the trash rather than in a menu
because the question it answers, "where do these documents live",
belongs on the same screen as the documents. Only where there is
something behind it: see useDocSyncOffered. */}
{docSyncOffered && (
<button type="button" onClick={() => setShowDocSync(true)} title={t('docsync.title')} style={{
appearance: 'none', border: '1px solid var(--edge)', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 12px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
background: 'transparent', color: 'var(--content-muted)', flexShrink: 0, marginLeft: 'auto',
}}>
<FolderSync size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('docsync.title')}</span>
</button>
)}
{/* The first of the two takes the free space, so without the sync
button the trash sits flush right the way it always has. */}
<button type="button" onClick={toggleTrash} style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)',
flexShrink: 0, marginLeft: 'auto',
flexShrink: 0, marginLeft: docSyncOffered ? undefined : 'auto',
opacity: showTrash ? 1 : 0.88,
transition: 'opacity 0.15s ease',
}}
@@ -0,0 +1,423 @@
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-030
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'
import DocSyncBinding from './DocSyncBinding'
import type { DocSyncLink, useDocSync } from './useDocSync'
type Sync = ReturnType<typeof useDocSync>
const syncNow = vi.fn(async (_linkId: number) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }))
const updateLink = vi.fn(async (_linkId: number, _patch: Record<string, unknown>) => {})
const removeLink = vi.fn(async (_linkId: number) => {})
const WEBHOOK = 'https://trek.example/api/trips/3/docsync/hooks/abc123'
function makeLink(overrides: Partial<DocSyncLink> = {}): DocSyncLink {
return {
id: 4,
connectionId: 1,
providerId: 'paperless',
scopeKey: 'trek-trip-3',
remoteLabel: 'Lisbon papers',
remoteRootPath: '/TREK/Lisbon',
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
lastSyncAt: null,
lastSyncState: 'ok',
lastSyncError: null,
webhookUrl: null,
holdings: { inTrek: 3, atProvider: 2, paired: 2, missing: 1 },
...overrides,
}
}
function makeSync(overrides: Partial<Sync> = {}): Sync {
return {
providers: [],
connections: [],
links: [],
itemCounts: {},
loading: false,
busy: null,
error: null,
connectionFor: () => null,
load: vi.fn(),
saveConnection: vi.fn(),
testConnection: vi.fn(),
loadScopes: vi.fn(),
createScope: vi.fn(),
createLink: vi.fn(),
updateLink,
removeLink,
syncNow,
resolveConflict: vi.fn(),
lastRunFor: () => ({ pulled: 0, pushed: 0 }),
...overrides,
} as unknown as Sync
}
function renderCard(
opts: {
link?: Partial<DocSyncLink>
sync?: Partial<Sync>
canManage?: boolean
providerName?: string
onReconnect?: () => void
} = {},
) {
const utils = render(
<DocSyncBinding
link={makeLink(opts.link)}
providerName={opts.providerName ?? 'Paperless'}
sync={makeSync(opts.sync)}
canManage={opts.canManage ?? true}
onReconnect={opts.onReconnect}
/>,
)
const header = utils.container.querySelector('header') as HTMLElement
return { ...utils, header }
}
const openSettings = () => fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
const rowFor = (label: string) => screen.getByText(label).closest('div') as HTMLElement
const UNLINK_QUESTION = 'Documents stay in TREK and at the store. Only the pairing between them goes.'
/** The confirmation the disconnect button opens; it renders in a portal. */
const unlinkDialog = () => within(screen.getByText(UNLINK_QUESTION).closest('.trek-modal-enter') as HTMLElement)
/** Named through the same fallback chain `t()` uses, so the label can land in the locales later. */
const reconnectButton = () =>
screen.queryByRole('button', { name: (en as unknown as Record<string, string>)['docsync.binding.reconnect'] ?? 'docsync.binding.reconnect' })
beforeEach(() => {
vi.clearAllMocks()
})
describe('DocSyncBinding: running a sync', () => {
it('FE-DOCSYNC-BIND-001: "Sync now" asks the hook to run this binding', () => {
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Sync now' }))
expect(syncNow).toHaveBeenCalledWith(4)
})
it('FE-DOCSYNC-BIND-002: while this binding runs the button reads "Syncing" and refuses another click', () => {
renderCard({ sync: { busy: 'sync-4' } })
const button = screen.getByRole('button', { name: 'Syncing' })
expect(button).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument()
fireEvent.click(button)
expect(syncNow).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-BIND-003: a run on another binding leaves this card ready', () => {
renderCard({ sync: { busy: 'sync-9' } })
expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled()
})
it('FE-DOCSYNC-BIND-004: the disconnect button asks first and unlinks once that is confirmed', () => {
// As the phone does. The button sits beside "Sync now", and a binding
// cannot be put back: rebinding the same folder starts from nothing.
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
expect(removeLink).not.toHaveBeenCalled()
expect(screen.getByText(UNLINK_QUESTION)).toBeInTheDocument()
fireEvent.click(unlinkDialog().getByRole('button', { name: 'Disconnect' }))
expect(removeLink).toHaveBeenCalledWith(4)
expect(screen.queryByText(UNLINK_QUESTION)).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-027: cancelling the question leaves the binding as it was', () => {
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
fireEvent.click(unlinkDialog().getByRole('button', { name: 'Cancel' }))
expect(removeLink).not.toHaveBeenCalled()
expect(screen.queryByText(UNLINK_QUESTION)).not.toBeInTheDocument()
})
})
describe('DocSyncBinding: a refused credential', () => {
it('FE-DOCSYNC-BIND-028: offers to sign in again beside the notice, and hands the click to the panel', () => {
// The sidebar opens the credential form only for a store with no
// connection yet, so a rotated token had no way back in from the app.
const onReconnect = vi.fn()
renderCard({ link: { lastSyncState: 'needs_reauth', lastSyncError: 'unauthorized' }, onReconnect })
expect(screen.getByText('Sign in again · The credentials were refused.')).toBeInTheDocument()
fireEvent.click(reconnectButton() as HTMLElement)
expect(onReconnect).toHaveBeenCalledTimes(1)
})
it('FE-DOCSYNC-BIND-029: a refusal recorded as a failed run offers it too', () => {
const onReconnect = vi.fn()
renderCard({ link: { lastSyncState: 'failed', lastSyncError: 'unauthorized' }, onReconnect })
expect(reconnectButton()).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-030: is not offered to a member, for another failure, or while the provider is switched off', () => {
const onReconnect = vi.fn()
const refused = { lastSyncState: 'needs_reauth', lastSyncError: 'unauthorized' } as const
const member = renderCard({ link: refused, onReconnect, canManage: false })
expect(reconnectButton()).not.toBeInTheDocument()
member.unmount()
const other = renderCard({ link: { lastSyncState: 'failed', lastSyncError: 'unreachable' }, onReconnect })
expect(reconnectButton()).not.toBeInTheDocument()
other.unmount()
// An admin has switched the store off: the paused notice stands in front,
// and a new credential would change nothing until it is back on.
const paused = renderCard({ link: { ...refused, providerOff: true }, onReconnect })
expect(reconnectButton()).not.toBeInTheDocument()
paused.unmount()
// The panel could not find the provider's form: nothing to open.
renderCard({ link: refused })
expect(reconnectButton()).not.toBeInTheDocument()
expect(onReconnect).not.toHaveBeenCalled()
})
})
describe('DocSyncBinding: what it says about itself', () => {
it('FE-DOCSYNC-BIND-005: a healthy binding shows its state as a dot, not as a word', () => {
renderCard()
expect(screen.queryByText('In sync')).not.toBeInTheDocument()
const dot = screen.getByTitle('In sync')
expect(dot).toBeInTheDocument()
expect(dot).toBeEmptyDOMElement()
})
it('FE-DOCSYNC-BIND-006: a failing binding spells the state out and appends the reason', () => {
renderCard({ link: { lastSyncState: 'failed', lastSyncError: 'unauthorized' } })
expect(screen.getByText('Failed')).toBeInTheDocument()
expect(screen.getByText('Failed · The credentials were refused.')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-007: a failing binding without a reason code shows the state alone', () => {
renderCard({ link: { lastSyncState: 'failed', lastSyncError: null } })
// once as the header badge, once as the banner underneath it
expect(screen.getAllByText('Failed')).toHaveLength(2)
})
it('FE-DOCSYNC-BIND-008: a partly synced binding also gets the banner', () => {
renderCard({ link: { lastSyncState: 'partial', lastSyncError: 'too_large' } })
expect(screen.getByText('Partly synced · The file is larger than the provider accepts.')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-009: a binding that never ran names its state but raises no alarm', () => {
renderCard({ link: { lastSyncState: 'never', lastSyncAt: null } })
expect(screen.getAllByText('Not synced yet')).toHaveLength(1)
expect(screen.getByText('not run yet')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-026: a binding whose provider an admin switched off says it is paused, not what the last run said', () => {
// The server leaves the binding's own state as the last run wrote it, so
// without this the card went on reporting an old failure, or nothing.
renderCard({ link: { providerOff: true, lastSyncState: 'failed', lastSyncError: 'unauthorized' } })
expect(screen.getByText('Paused: an administrator has switched this provider off. Syncing resumes once it is back on.')).toBeInTheDocument()
expect(screen.queryByText('Failed · The credentials were refused.')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-010: a paused binding is badged, an active one is not', () => {
const { unmount } = renderCard({ link: { syncEnabled: false } })
expect(screen.getByText('Paused')).toBeInTheDocument()
unmount()
renderCard({ link: { syncEnabled: true } })
expect(screen.queryByText('Paused')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-011: the last run is shown as elapsed time once there is one', () => {
renderCard({ link: { lastSyncAt: new Date(Date.now() - 2 * 3600_000).toISOString() } })
expect(screen.getByText(/hours ago/)).toBeInTheDocument()
expect(screen.queryByText('not run yet')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-012: the folder falls back to the path, then to the scope key', () => {
const { unmount } = renderCard({ link: { remoteLabel: '' } })
expect(screen.getByText('/TREK/Lisbon')).toBeInTheDocument()
unmount()
renderCard({ link: { remoteLabel: '', remoteRootPath: null } })
expect(screen.getByText('trek-trip-3')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-013: names the store it is handed in the header and at the far end of the flow', () => {
const { header } = renderCard({ providerName: 'Paperless-ngx' })
expect(within(header).getByText('Paperless-ngx')).toBeInTheDocument()
expect(screen.getAllByText('Paperless-ngx')).toHaveLength(2)
expect(screen.queryByText('paperless')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-014: the flow bar gets the holdings, and zeros when there are none', () => {
const { unmount } = renderCard()
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
unmount()
renderCard({ link: { holdings: undefined } })
expect(screen.getAllByText('0')).toHaveLength(2)
})
})
describe('DocSyncBinding: the settings section', () => {
it('FE-DOCSYNC-BIND-015: the settings stay folded away until they are opened', () => {
renderCard()
const toggle = screen.getByRole('button', { name: 'Settings' })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
expect(screen.queryByText('When a document is deleted')).not.toBeInTheDocument()
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Sync automatically')).toBeInTheDocument()
expect(screen.getByText('When a document is deleted')).toBeInTheDocument()
fireEvent.click(toggle)
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-016: a member gets no settings section and no disconnect button', () => {
renderCard({ canManage: false })
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
expect(screen.queryByText('When a document is deleted')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Disconnect' })).not.toBeInTheDocument()
// a member may still trigger a run
expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled()
})
it('FE-DOCSYNC-BIND-017: turning "sync automatically" off patches the link', () => {
renderCard()
openSettings()
fireEvent.click(within(rowFor('Sync automatically')).getByRole('button'))
expect(updateLink).toHaveBeenCalledWith(4, { syncEnabled: false })
})
it('FE-DOCSYNC-BIND-018: turning it back on patches the other way', () => {
renderCard({ link: { syncEnabled: false } })
openSettings()
const toggle = within(rowFor('Sync automatically')).getByRole('button')
expect(toggle).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(toggle)
expect(updateLink).toHaveBeenCalledWith(4, { syncEnabled: true })
})
it('FE-DOCSYNC-BIND-019: choosing another delete policy patches the link', () => {
renderCard()
openSettings()
const trigger = within(rowFor('When a document is deleted')).getByRole('button')
expect(trigger).toHaveTextContent('Keep both copies')
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: 'Move to recycle bin' }))
expect(updateLink).toHaveBeenCalledWith(4, { deletePolicy: 'trash' })
})
})
describe('DocSyncBinding: the webhook URL', () => {
const realClipboard = navigator.clipboard
afterEach(() => {
Object.defineProperty(navigator, 'clipboard', { value: realClipboard, configurable: true, writable: true })
})
const stubClipboard = (writeText: (text: string) => Promise<void>) => {
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true })
}
it('FE-DOCSYNC-BIND-020: a binding without a webhook shows no webhook section', () => {
renderCard({ link: { webhookUrl: null } })
openSettings()
expect(screen.queryByText('Instant updates')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Copy' })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-021: a binding with a webhook shows the URL for pasting', () => {
renderCard({ link: { webhookUrl: WEBHOOK } })
openSettings()
expect(screen.getByText('Instant updates')).toBeInTheDocument()
expect(screen.getByText(WEBHOOK)).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-022: copying the webhook puts it on the clipboard and confirms', async () => {
const writeText = vi.fn(async (_text: string) => {})
stubClipboard(writeText)
renderCard({ link: { webhookUrl: WEBHOOK } })
openSettings()
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(writeText).toHaveBeenCalledWith(WEBHOOK))
expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-023: a blocked clipboard leaves the URL on screen and the button unchanged', async () => {
const writeText = vi.fn(async (_text: string) => { throw new Error('not allowed') })
stubClipboard(writeText)
renderCard({ link: { webhookUrl: WEBHOOK } })
openSettings()
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(writeText).toHaveBeenCalled())
expect(screen.queryByRole('button', { name: 'Copied' })).not.toBeInTheDocument()
expect(screen.getByText(WEBHOOK)).toBeInTheDocument()
})
})
describe('DocSyncBinding: the direction lanes', () => {
it('FE-DOCSYNC-BIND-024: switching a lane off patches the direction', () => {
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Out to the store' }))
expect(updateLink).toHaveBeenCalledWith(4, { direction: 'pull' })
})
it('FE-DOCSYNC-BIND-025: a member cannot change the direction', () => {
renderCard({ canManage: false })
const lane = screen.getByRole('button', { name: 'Out to the store' })
expect(lane).toBeDisabled()
fireEvent.click(lane)
expect(updateLink).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,258 @@
import { useState } from 'react'
import {
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, Link2Off, RefreshCw,
} from 'lucide-react'
import ConfirmDialog from '../../shared/ConfirmDialog'
import CustomSelect from '../../shared/CustomSelect'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import Tooltip from '../../shared/Tooltip'
import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import DocSyncFlow, { type SyncDirection } from './DocSyncFlow'
import { Badge, CONFLICT_POLICIES, conflictPolicyKey, LastRun, StateBadge } from './DocSyncBits'
import { bindingNotice, needsReauth, type DocSyncLink, type useDocSync } from './useDocSync'
/**
* One binding: where this trip's documents live, which way they move, and what
* happened last time.
*
* The flow bar carries the direction because that is the one thing this feature
* is about. Everything a person sets once and forgets (the delete rule, the
* webhook URL) is folded away behind "Settings" so the card stays a status
* card rather than a form.
*/
export default function DocSyncBinding({
link,
providerName,
sync,
canManage,
onReconnect,
}: {
link: DocSyncLink
providerName: string
sync: ReturnType<typeof useDocSync>
canManage: boolean
/**
* Opens the credential form for this binding's store. Only the panel can,
* because the form needs the provider's field list, which the card does
* not carry; left out, a refused credential is reported but not curable.
*/
onReconnect?: () => void
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [confirmUnlink, setConfirmUnlink] = useState(false)
const Icon = DOCUMENT_PROVIDER_ICONS[link.providerId]
const busy = sync.busy === `sync-${link.id}`
const notice = bindingNotice(link, t)
// Not while the provider is switched off: the paused notice stands in front
// of the refusal then, and a new credential would change nothing until an
// admin turns the provider back on.
const reconnect = canManage && onReconnect && !link.providerOff && needsReauth(link) ? onReconnect : null
const copyWebhook = async () => {
if (!link.webhookUrl) return
try {
await navigator.clipboard.writeText(link.webhookUrl)
setCopied(true)
setTimeout(() => setCopied(false), 1600)
} catch {
// Clipboard is blocked outside a secure context; the URL is selectable.
}
}
return (
<article className="overflow-hidden rounded-2xl border border-edge bg-surface shadow-card">
<header className="flex flex-wrap items-start gap-x-3 gap-y-3 px-4 py-3.5">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl border border-edge bg-surface-secondary">
{Icon ? <Icon className="h-5 w-5 text-content" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="truncate text-subtitle font-semibold text-content">
{providerName}
</span>
{/* A healthy binding needs no word for it: the dot is the whole
message, and the states that do need words get them below. */}
<StateBadge state={link.lastSyncState} compact={link.lastSyncState === 'ok'} />
{!link.syncEnabled && <Badge tone="neutral">{t('docsync.binding.autoOff')}</Badge>}
</span>
{/* Where and when, as two facts rather than a sentence. They are what
a member checks before asking why a file has not turned up. */}
<span className="mt-1.5 flex flex-wrap items-center gap-1.5">
<Badge tone="neutral" icon={<FolderOpen size={12} />} title={link.remoteRootPath || undefined}>
<span className="max-w-[12rem] truncate">
{link.remoteLabel || link.remoteRootPath || link.scopeKey}
</span>
</Badge>
<Badge tone="neutral" icon={<Clock size={12} />}>
<LastRun at={link.lastSyncAt} />
</Badge>
</span>
</span>
{/* Both controls are h-9 so the square one cannot sit a pixel off the
other: the two together read as one segmented control. */}
<span className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => void sync.syncNow(link.id)}
disabled={busy}
className="flex h-9 items-center gap-2 rounded-lg border border-edge bg-surface px-3 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-60"
>
<RefreshCw size={14} className={busy ? 'animate-spin' : ''} />
{/* Taken out of the layout below sm rather than out of the document:
`hidden` left a screen reader announcing a bare "button" there,
and an aria-label would have frozen the name while the visible
text still changes to "Syncing". */}
<span className="sr-only sm:not-sr-only">{busy ? t('docsync.syncing') : t('docsync.syncNow')}</span>
</button>
{/* Asked first, as the phone does: the button sits a hand's width
from "Sync now", and a binding cannot be put back. Rebinding the
same folder starts from nothing and copies both sides over
again. */}
{canManage && (
<Tooltip label={t('docsync.unlink')}>
<button
type="button"
onClick={() => setConfirmUnlink(true)}
className="grid h-9 w-9 place-items-center rounded-lg border border-edge bg-surface text-content-muted transition-colors hover:bg-danger-soft hover:text-danger"
aria-label={t('docsync.unlink')}
>
<Link2Off size={14} />
</button>
</Tooltip>
)}
</span>
</header>
{notice && (
<p className="mx-4 mb-3 flex items-start gap-2 rounded-lg bg-warning-soft px-3 py-2 text-caption text-warning">
<AlertTriangle size={13} className="mt-px shrink-0" />
<span className="min-w-0 flex-1">{notice}</span>
{/* The one state a person can act on from here. Without this the
only way past a rotated token was the API: the sidebar opens the
form for a store that has no connection yet, and this one has. */}
{reconnect && (
<button
type="button"
onClick={reconnect}
className="shrink-0 rounded-lg border border-edge bg-surface px-2.5 py-1 text-caption font-medium text-content transition-colors hover:bg-surface-hover"
>
{t('docsync.binding.reconnect')}
</button>
)}
</p>
)}
<ConfirmDialog
isOpen={confirmUnlink}
onClose={() => setConfirmUnlink(false)}
onConfirm={() => void sync.removeLink(link.id)}
title={t('docsync.unlink')}
message={t('docsync.confirmUnlink')}
confirmLabel={t('docsync.unlink')}
/>
<div className="px-4 pb-3">
<DocSyncFlow
direction={link.direction}
providerId={link.providerId}
providerName={providerName}
running={busy}
holdings={link.holdings ?? { inTrek: 0, atProvider: 0, paired: 0, missing: 0 }}
disabled={!canManage}
onChange={(next: SyncDirection) => void sync.updateLink(link.id, { direction: next })}
/>
</div>
{canManage && (
<>
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
className="flex w-full items-center gap-1.5 border-t border-edge-faint px-4 py-2.5 text-left text-caption text-content-muted transition-colors hover:bg-surface-hover"
>
<ChevronDown size={13} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
{t('docsync.binding.settings')}
</button>
{open && (
<div className="space-y-3 border-t border-edge-faint bg-surface-secondary px-4 py-3">
<Row label={t('docsync.deletePolicy')} hint={t('docsync.binding.deleteHint')}>
<CustomSelect
size="sm"
value={link.deletePolicy}
onChange={v => void sync.updateLink(link.id, { deletePolicy: String(v) })}
options={[
{ value: 'unlink', label: t('docsync.deleteUnlink') },
{ value: 'trash', label: t('docsync.deleteTrash') },
]}
/>
</Row>
<Row label={t('docsync.conflictPolicy')} hint={t('docsync.binding.conflictHint')}>
<CustomSelect
size="sm"
value={link.conflictPolicy}
onChange={v => void sync.updateLink(link.id, { conflictPolicy: String(v) })}
options={CONFLICT_POLICIES.map(p => ({ value: p, label: t(conflictPolicyKey(p)) }))}
/>
</Row>
<Row label={t('docsync.syncEnabled')} hint={t('docsync.binding.autoHint')}>
<span className="flex justify-end">
<ToggleSwitch
on={link.syncEnabled}
onToggle={() => void sync.updateLink(link.id, { syncEnabled: !link.syncEnabled })}
/>
</span>
</Row>
{/* Only where TREK could not subscribe itself. Papra's webhook CRUD
is closed to API keys and Nextcloud's needs admin rights, so the
person pastes this in by hand; polling carries it either way. */}
{link.webhookUrl && (
<div>
{/* Same tier as the two rows above it: this is the third
setting, not a footnote to the second. */}
<span className="block text-body text-content">{t('docsync.binding.webhookTitle')}</span>
<p className="mt-1 text-caption text-content-muted">{t('docsync.webhookHint')}</p>
<div className="mt-1.5 flex items-stretch gap-1.5">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-caption text-content-secondary">
{link.webhookUrl}
</code>
<button
type="button"
onClick={() => void copyWebhook()}
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-edge px-2.5 text-caption text-content-secondary transition-colors hover:bg-surface-hover"
>
{copied ? <Check size={13} className="text-success" /> : <Copy size={13} />}
{copied ? t('docsync.binding.copied') : t('docsync.binding.copy')}
</button>
</div>
</div>
)}
</div>
)}
</>
)}
</article>
)
}
function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4">
<span className="min-w-0">
<span className="block text-body text-content">{label}</span>
{hint && <span className="mt-0.5 block text-caption text-content-muted">{hint}</span>}
</span>
<span className="w-56 shrink-0">{children}</span>
</div>
)
}
@@ -0,0 +1,107 @@
import type { ReactNode } from 'react'
import { useTranslation } from '../../../i18n/TranslationContext'
import { relativeTime } from '../../../utils/relativeTime'
/**
* The small shared pieces of the document-sync dialog.
*
* They live together because the panel, the store list and the binding card all
* label the same five sync states, and three hand-written copies of that mapping
* is how one of them ends up saying something different from the other two.
*/
export type Tone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger'
const TONES: Record<Tone, string> = {
neutral: 'bg-surface-secondary text-content-secondary',
accent: 'bg-accent-subtle text-accent-on',
success: 'bg-success-soft text-success',
warning: 'bg-warning-soft text-warning',
danger: 'bg-danger-soft text-danger',
}
/** A count or a short label, on a tinted pill. */
export function Badge({
tone = 'neutral',
icon,
children,
title,
}: {
tone?: Tone
icon?: ReactNode
children: ReactNode
title?: string
}) {
return (
<span
title={title}
className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2 py-0.5 text-caption font-medium ${TONES[tone]}`}
>
{icon}
{children}
</span>
)
}
/** The tone each sync state carries, in one place. */
function toneForState(state: string): Tone {
if (state === 'ok') return 'success'
if (state === 'partial') return 'warning'
if (state === 'never') return 'neutral'
return 'danger'
}
/**
* A binding's state as a dot plus its word.
*
* A dot alone is a colour puzzle and a word alone is easy to skim past; the two
* together survive both colour blindness and a glance.
*/
export function StateBadge({ state, compact }: { state: string; compact?: boolean }) {
const { t } = useTranslation()
const tone = toneForState(state)
const dot =
tone === 'success' ? 'bg-success'
: tone === 'warning' ? 'bg-warning'
: tone === 'danger' ? 'bg-danger'
: 'bg-content-faint'
if (compact) return <span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} title={t(`docsync.linkState.${state}`)} />
return (
<Badge tone={tone} icon={<span className={`h-1.5 w-1.5 rounded-full ${dot}`} />}>
{t(`docsync.linkState.${state}`)}
</Badge>
)
}
/** "2h ago", or "never" when a binding has not run yet. */
export function LastRun({ at }: { at: string | null }) {
const { t, language, locale } = useTranslation()
if (!at) return <span className="text-content-faint">{t('docsync.binding.neverRun')}</span>
const ms = Date.parse(at)
if (Number.isNaN(ms)) return null
return <span title={new Date(ms).toLocaleString(locale)}>{relativeTime(ms, language)}</span>
}
/**
* The three answers to "both sides changed", in the order they are offered.
*
* Shared because the phone offers them as one cycling button and the desktop as
* a dropdown: two lists would drift, and a binding would then say one thing on
* a laptop and another on a phone.
*/
export const CONFLICT_POLICIES = ['manual', 'trek_wins', 'provider_wins'] as const
export type ConflictPolicy = (typeof CONFLICT_POLICIES)[number]
/** The next answer in that ring, for the phone's single button. */
export function nextConflictPolicy(current: string): ConflictPolicy {
const at = (CONFLICT_POLICIES as readonly string[]).indexOf(current)
return CONFLICT_POLICIES[(at + 1) % CONFLICT_POLICIES.length]
}
/** The label key for one answer. */
export function conflictPolicyKey(policy: string): string {
return `docsync.onConflict.${(CONFLICT_POLICIES as readonly string[]).includes(policy) ? policy : 'manual'}`
}
@@ -0,0 +1,494 @@
// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-027
import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor, act, within } from '../../../../tests/helpers/render'
import type { DocSyncConnection, DocSyncProvider } from './useDocSync'
/** What the app really shows: `en` is bundled, so `t()` resolves for real here. */
const tx = (key: string): string => (en as unknown as Record<string, string>)[key] ?? key
const providers = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const listConnections = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const listLinks = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const status = vi.fn(async (_tripId: number | string): Promise<unknown> => ({ items: {}, links: [] }))
const saveConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ id: 1 }))
const testConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
return {
...actual,
docsyncApi: {
...actual.docsyncApi,
providers: (tripId: number | string) => providers(tripId),
status: (tripId: number | string) => status(tripId),
listConnections: (tripId: number | string) => listConnections(tripId),
listLinks: (tripId: number | string) => listLinks(tripId),
saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data),
testConnection: (tripId: number | string, data: unknown) => testConnection(tripId, data),
},
}
})
import DocSyncConnectModal from './DocSyncConnectModal'
import { useDocSync } from './useDocSync'
const TRIP_ID = 42
const nextcloud: DocSyncProvider = {
id: 'nextcloud',
name: 'Nextcloud',
description: 'Two-way document sync with a Nextcloud folder over WebDAV',
icon: 'Cloud',
available: true,
fields: [
{ field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://cloud.example.com', hint: null, required: true, secret: false },
{ field_key: 'login_name', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: 'hintNextcloudLogin', required: true, secret: false },
{ field_key: 'app_password', label: 'providerAppPassword', input_type: 'password', placeholder: 'app password', hint: 'hintNextcloudAppPassword', required: true, secret: true },
{ field_key: 'base_path', label: 'providerBasePath', input_type: 'text', placeholder: '/TREK', hint: 'hintBasePath', required: false, secret: false },
{ field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: false, secret: false },
],
}
/** Synology is the one provider with a secret that is NOT a password box (the OTP). */
const synology: DocSyncProvider = {
id: 'synologydrive',
name: 'Synology Drive',
description: 'Two-way document sync with a folder on a Synology NAS',
icon: 'HardDrive',
available: true,
fields: [
{ field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://nas.example.com:5001', hint: 'hintSynologyUrl', required: true, secret: false },
{ field_key: 'password', label: 'providerPassword', input_type: 'password', placeholder: 'password', hint: null, required: true, secret: true },
{ field_key: 'otp_code', label: 'providerOTP', input_type: 'text', placeholder: '123456', hint: 'hintSynologyOtp', required: false, secret: true },
{ field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: false, secret: false },
],
}
const connection = (overrides: Partial<DocSyncConnection> = {}): DocSyncConnection => ({
id: 9,
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
settings: { login_name: 'maurice', base_path: '/Archive/TREK' },
secrets: { app_password: 'stored' },
allowInsecureTls: false,
lastProbeState: 'ok',
lastProbeError: null,
...overrides,
})
function Harness({
provider,
onClose,
onConnected,
waitForConnection,
}: {
provider: DocSyncProvider
onClose: () => void
onConnected: (providerId: string) => void
/**
* Hold the dialog back until the stored connection is in hand, the way the
* panel does: it only offers "connect" once it knows what is already there.
*/
waitForConnection?: boolean
}) {
const sync = useDocSync(TRIP_ID, true)
if (waitForConnection && !sync.connectionFor(provider.id)) return null
return <DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} />
}
/** Let the hook's initial load land before anything is asserted. */
async function flush() {
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
}
async function open(provider: DocSyncProvider = nextcloud, waitForConnection = false) {
const onClose = vi.fn()
const onConnected = vi.fn()
const utils = render(
<Harness provider={provider} onClose={onClose} onConnected={onConnected} waitForConnection={waitForConnection} />,
)
await flush()
return { ...utils, onClose, onConnected }
}
const testButton = () => screen.getByRole('button', { name: tx('docsync.test') })
const connectButton = () => screen.getByRole('button', { name: tx('docsync.connect.submit') })
function fillNextcloud() {
fireEvent.change(screen.getByPlaceholderText('https://cloud.example.com'), { target: { value: 'https://cloud.example.com' } })
fireEvent.change(screen.getByPlaceholderText('username'), { target: { value: 'maurice' } })
fireEvent.change(screen.getByPlaceholderText('app password'), { target: { value: 'app-pw' } })
}
beforeEach(() => {
vi.clearAllMocks()
providers.mockResolvedValue([])
listConnections.mockResolvedValue([])
listLinks.mockResolvedValue([])
status.mockResolvedValue({ items: {}, links: [] })
saveConnection.mockResolvedValue({ id: 1 })
testConnection.mockResolvedValue({ connected: true })
})
describe('DocSyncConnectModal form', () => {
it('FE-DOCSYNC-CONNECT-001: renders one labelled box per provider field and leaves the TLS field to the switch', async () => {
const { baseElement } = await open()
expect(screen.getByText(tx('docsync.providerUrl'))).toBeInTheDocument()
expect(screen.getByText(tx('docsync.providerUsername'))).toBeInTheDocument()
expect(screen.getByText(tx('docsync.providerAppPassword'))).toBeInTheDocument()
expect(screen.getByText(tx('docsync.providerBasePath'))).toBeInTheDocument()
// allow_insecure_tls is a switch, not a fifth text box
expect(baseElement.querySelectorAll('input')).toHaveLength(4)
expect(screen.getByText(tx('docsync.allowInsecureTls'))).toBeInTheDocument()
expect(screen.getByRole('button', { pressed: false })).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-002: a password field is masked and the other boxes are not', async () => {
await open()
expect(screen.getByPlaceholderText('app password')).toHaveAttribute('type', 'password')
expect(screen.getByPlaceholderText('https://cloud.example.com')).toHaveAttribute('type', 'text')
expect(screen.getByPlaceholderText('username')).toHaveAttribute('type', 'text')
expect(screen.getByPlaceholderText('/TREK')).toHaveAttribute('type', 'text')
})
it('FE-DOCSYNC-CONNECT-003: a secret the provider declares as plain text stays readable', async () => {
await open(synology)
expect(screen.getByPlaceholderText('password')).toHaveAttribute('type', 'password')
expect(screen.getByPlaceholderText('123456')).toHaveAttribute('type', 'text')
})
it('FE-DOCSYNC-CONNECT-004: only the optional fields carry the optional mark', async () => {
await open()
const optional = tx('docsync.connect.optional')
const basePath = screen.getByPlaceholderText('/TREK').closest('label') as HTMLElement
expect(within(basePath).getByText(optional)).toBeInTheDocument()
for (const placeholder of ['https://cloud.example.com', 'username', 'app password']) {
const field = screen.getByPlaceholderText(placeholder).closest('label') as HTMLElement
expect(within(field).queryByText(optional)).not.toBeInTheDocument()
}
expect(screen.getAllByText(optional)).toHaveLength(1)
})
it('FE-DOCSYNC-CONNECT-005: a field hint is translated, and a field without one gets no line', async () => {
await open()
const username = screen.getByPlaceholderText('username').closest('label') as HTMLElement
expect(within(username).getByText(tx('docsync.hintNextcloudLogin'))).toBeInTheDocument()
const baseUrl = screen.getByPlaceholderText('https://cloud.example.com').closest('label') as HTMLElement
expect(baseUrl.textContent?.trim()).toBe(tx('docsync.providerUrl'))
})
it('FE-DOCSYNC-CONNECT-006: the dialog is headed by the provider and explains what it will do', async () => {
await open()
expect(screen.getByRole('heading', { name: nextcloud.name })).toBeInTheDocument()
expect(screen.getByText(tx('docsync.connect.about.nextcloud'))).toBeInTheDocument()
expect(screen.getByText(tx('docsync.connect.insecureHint'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-007: escape closes the dialog without saving', async () => {
const { onClose, onConnected } = await open()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
expect(onConnected).not.toHaveBeenCalled()
expect(saveConnection).not.toHaveBeenCalled()
})
})
describe('DocSyncConnectModal required fields', () => {
it('FE-DOCSYNC-CONNECT-008: nothing can be saved or probed while a required field is empty', async () => {
await open()
expect(connectButton()).toBeDisabled()
expect(testButton()).toBeDisabled()
fireEvent.click(connectButton())
fireEvent.click(testButton())
expect(saveConnection).not.toHaveBeenCalled()
expect(testConnection).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-CONNECT-009: a missing password alone is enough to refuse the save', async () => {
await open()
fireEvent.change(screen.getByPlaceholderText('https://cloud.example.com'), { target: { value: 'https://cloud.example.com' } })
fireEvent.change(screen.getByPlaceholderText('username'), { target: { value: 'maurice' } })
expect(connectButton()).toBeDisabled()
fireEvent.change(screen.getByPlaceholderText('app password'), { target: { value: 'app-pw' } })
expect(connectButton()).toBeEnabled()
})
it('FE-DOCSYNC-CONNECT-010: an address of nothing but spaces does not count as filled', async () => {
await open()
fillNextcloud()
expect(connectButton()).toBeEnabled()
fireEvent.change(screen.getByPlaceholderText('https://cloud.example.com'), { target: { value: ' ' } })
expect(connectButton()).toBeDisabled()
expect(testButton()).toBeDisabled()
})
it('FE-DOCSYNC-CONNECT-011: an empty optional field does not hold the save back', async () => {
const { onConnected } = await open()
fillNextcloud()
fireEvent.click(connectButton())
await waitFor(() => expect(onConnected).toHaveBeenCalled())
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, {
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { base_url: 'https://cloud.example.com', login_name: 'maurice', app_password: 'app-pw' },
allowInsecureTls: false,
})
})
})
describe('DocSyncConnectModal probe verdict', () => {
it('FE-DOCSYNC-CONNECT-012: the probe carries the typed address, credentials and TLS choice', async () => {
await open()
fillNextcloud()
fireEvent.change(screen.getByPlaceholderText('/TREK'), { target: { value: '/Archive' } })
fireEvent.click(testButton())
await waitFor(() => expect(testConnection).toHaveBeenCalledWith(TRIP_ID, {
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: {
base_url: 'https://cloud.example.com',
login_name: 'maurice',
app_password: 'app-pw',
base_path: '/Archive',
},
allowInsecureTls: false,
}))
expect(saveConnection).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-CONNECT-013: a reachable provider names the account it signed in as', async () => {
testConnection.mockResolvedValueOnce({ connected: true, account: 'maurice' })
await open()
fillNextcloud()
fireEvent.click(testButton())
const line = tx('docsync.connect.okAs').replace('{account}', 'maurice')
expect(await screen.findByText(line)).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-014: a reachable provider without an account just reports the connection', async () => {
testConnection.mockResolvedValueOnce({ connected: true })
await open()
fillNextcloud()
fireEvent.click(testButton())
expect(await screen.findByText(tx('docsync.connected'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-015: a refused credential shows the translated reason', async () => {
testConnection.mockResolvedValueOnce({ connected: false, error: 'unauthorized' })
await open()
fillNextcloud()
fireEvent.click(testButton())
const line = await screen.findByText(tx('docsync.error.unauthorized'))
expect(line).toBeInTheDocument()
expect(screen.queryByText(tx('docsync.connected'))).not.toBeInTheDocument()
expect(line.parentElement?.querySelector('svg')?.getAttribute('class')).not.toMatch(/shield/)
})
it('FE-DOCSYNC-CONNECT-016: an address TREK itself blocked is marked with the shield, not the warning', async () => {
testConnection.mockResolvedValueOnce({ connected: false, error: 'ssrf_blocked' })
await open()
fillNextcloud()
fireEvent.click(testButton())
const line = await screen.findByText(tx('docsync.error.ssrf_blocked'))
expect(line.parentElement?.querySelector('svg')?.getAttribute('class')).toMatch(/shield/)
})
it('FE-DOCSYNC-CONNECT-017: a failure with no reason falls back to the unknown message', async () => {
testConnection.mockResolvedValueOnce({ connected: false })
await open()
fillNextcloud()
fireEvent.click(testButton())
expect(await screen.findByText(tx('docsync.error.unknown'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-018: a request that throws still lands as a verdict rather than an empty footer', async () => {
testConnection.mockRejectedValueOnce({ response: { data: { error: 'timeout' } } })
await open()
fillNextcloud()
fireEvent.click(testButton())
expect(await screen.findByText(tx('docsync.error.timeout'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-019: while the probe runs the dialog says so and the probe button is locked', async () => {
let settle: (value: unknown) => void = () => {}
testConnection.mockImplementationOnce(() => new Promise(resolve => { settle = resolve }))
await open()
fillNextcloud()
fireEvent.click(testButton())
expect(screen.getByText(tx('docsync.connect.testing'))).toBeInTheDocument()
expect(testButton()).toBeDisabled()
await act(async () => {
settle({ connected: true })
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(screen.queryByText(tx('docsync.connect.testing'))).not.toBeInTheDocument()
expect(screen.getByText(tx('docsync.connected'))).toBeInTheDocument()
expect(testButton()).toBeEnabled()
})
})
describe('DocSyncConnectModal saving', () => {
it('FE-DOCSYNC-CONNECT-020: a successful save reports the connected provider upward', async () => {
const { onConnected, onClose } = await open()
fillNextcloud()
fireEvent.click(connectButton())
await waitFor(() => expect(onConnected).toHaveBeenCalledWith('nextcloud'))
expect(onConnected).toHaveBeenCalledTimes(1)
expect(onClose).not.toHaveBeenCalled()
// the freshly stored connection is read back, not assumed
expect(listConnections).toHaveBeenCalledTimes(2)
})
it('FE-DOCSYNC-CONNECT-021: a rejected save reports nothing upward and leaves the form standing', async () => {
saveConnection.mockRejectedValueOnce({ response: { data: { error: 'unauthorized' } } })
const { onConnected, onClose } = await open()
fillNextcloud()
fireEvent.click(connectButton())
await waitFor(() => expect(saveConnection).toHaveBeenCalled())
expect(onConnected).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByPlaceholderText('app password')).toHaveValue('app-pw')
})
it('FE-DOCSYNC-CONNECT-022: the save button is locked while the write is in flight', async () => {
let settle: (value: unknown) => void = () => {}
saveConnection.mockImplementationOnce(() => new Promise(resolve => { settle = resolve }))
const { onConnected } = await open()
fillNextcloud()
fireEvent.click(connectButton())
expect(connectButton()).toBeDisabled()
await act(async () => {
settle({ id: 1 })
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(onConnected).toHaveBeenCalledWith('nextcloud')
})
it('FE-DOCSYNC-CONNECT-023: the self-signed switch starts off and travels with the request', async () => {
await open()
fillNextcloud()
fireEvent.click(screen.getByRole('button', { pressed: false }))
expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument()
fireEvent.click(connectButton())
await waitFor(() => expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ allowInsecureTls: true })))
})
})
describe('DocSyncConnectModal with a connection already stored', () => {
it('FE-DOCSYNC-CONNECT-024: the stored settings come back, the saved secret is only hinted at, and it saves untouched', async () => {
listConnections.mockResolvedValue([connection({ settings: { login_name: 'maurice', base_path: '/Archive/TREK', app_password: 'never-echo-me' } })])
const { onConnected } = await open(nextcloud, true)
expect(await screen.findByPlaceholderText('••••••••')).toBeInTheDocument()
expect(screen.getByPlaceholderText('username')).toHaveValue('maurice')
expect(screen.getByPlaceholderText('/TREK')).toHaveValue('/Archive/TREK')
// a secret is never echoed back into the box, whatever the settings blob holds
expect(screen.getByPlaceholderText('••••••••')).toHaveValue('')
// the stored secret counts as filled, so nothing has to be retyped
expect(connectButton()).toBeEnabled()
fireEvent.click(connectButton())
await waitFor(() => expect(onConnected).toHaveBeenCalledWith('nextcloud'))
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, {
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: {},
allowInsecureTls: false,
})
})
it('FE-DOCSYNC-CONNECT-025: the stored self-signed choice is the one the switch starts on', async () => {
listConnections.mockResolvedValue([connection({ allowInsecureTls: true })])
await open(nextcloud, true)
expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-026: a connection that lands after the dialog is open still sets the switch', async () => {
// The switch used to be seeded by a useState initialiser, so it only ever
// read the connection that existed at the moment the dialog mounted: a
// stored "allow self-signed" showed as off, and the next save wrote that
// back. A security switch turning itself off unasked is the bad version of
// a stale field, so it now reads through until somebody touches it.
listConnections.mockResolvedValue([connection({ allowInsecureTls: true })])
await open(nextcloud, false)
await screen.findByPlaceholderText('••••••••')
await waitFor(() => expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument())
})
it('FE-DOCSYNC-CONNECT-027: the stored address is shown as well as submitted', async () => {
listConnections.mockResolvedValue([connection()])
const { onConnected } = await open(nextcloud, true)
await screen.findByPlaceholderText('••••••••')
// The address is a column of its own rather than a settings entry. Reading
// it out of settings left the box empty over its placeholder while the form
// went on submitting the stored value, the field and the request quietly
// disagreeing about what the connection is.
await waitFor(() =>
expect(screen.getByPlaceholderText('https://cloud.example.com')).toHaveValue('https://cloud.example.com'),
)
fireEvent.click(connectButton())
await waitFor(() => expect(onConnected).toHaveBeenCalled())
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ baseUrl: 'https://cloud.example.com' }))
})
})
@@ -0,0 +1,189 @@
import { useState } from 'react'
import { AlertTriangle, Check, Loader2, ShieldAlert } from 'lucide-react'
import Modal from '../../shared/Modal'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useTranslation } from '../../../i18n/TranslationContext'
import { useConnectForm } from './useConnectForm'
import { Badge } from './DocSyncBits'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import type { DocSyncProvider, useDocSync } from './useDocSync'
/**
* Connecting a store, as a dialog rather than a panel that unfolds in place.
*
* Entering a URL and a token is a task with a beginning and an end, and doing it
* inside the list meant the list jumped around while someone typed. The dialog
* also gives the probe result somewhere to live that is not a line of text
* squeezed between two fields.
*/
export default function DocSyncConnectModal({
provider,
sync,
onClose,
onConnected,
}: {
provider: DocSyncProvider
sync: ReturnType<typeof useDocSync>
onClose: () => void
onConnected: (providerId: string) => void
}) {
const { t } = useTranslation()
const existing = sync.connectionFor(provider.id)
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
const form = useConnectForm(provider, existing, sync)
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
const test = async () => setVerdict(await form.probe())
const save = async () => {
if (await form.save()) onConnected(provider.id)
}
return (
<Modal
isOpen
onClose={onClose}
size="md"
title={
<span className="flex items-center gap-2.5">
{Icon && <Icon className="h-5 w-5 text-content" />}
<span>{provider.name}</span>
</span>
}
footer={
<div className="flex items-center justify-between gap-3">
<Verdict verdict={verdict} busy={sync.busy === 'test'} failure={sync.error} />
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => void test()}
disabled={!form.complete || sync.busy === 'test'}
className="rounded-lg border border-edge px-3.5 py-2 text-body text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50"
>
{t('docsync.test')}
</button>
<button
type="button"
onClick={() => void save()}
disabled={!form.complete || sync.busy === 'save'}
className="flex items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-body font-medium text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
>
{sync.busy === 'save' && <Loader2 size={14} className="animate-spin" />}
{t('docsync.connect.submit')}
</button>
</div>
</div>
}
>
<div className="space-y-4">
<p className="text-caption text-content-muted">{t(`docsync.connect.about.${provider.id}`)}</p>
{form.fields.map(f => (
<Field
key={f.field_key}
label={t(form.labelKey(f))}
hint={form.hintKey(f) ? t(form.hintKey(f) as string) : undefined}
required={f.required}
>
<input
type={f.input_type === 'password' ? 'password' : 'text'}
value={form.valueOf(f.field_key)}
placeholder={form.placeholderOf(f)}
onChange={e => form.setValue(f.field_key, e.target.value)}
className="w-full rounded-lg border border-edge bg-surface-input px-3 py-2.5 text-body text-content ring-accent transition-shadow placeholder:text-content-faint focus:outline-none focus:ring-2"
/>
</Field>
))}
<label className="flex items-start justify-between gap-4 rounded-lg border border-edge bg-surface-secondary px-3 py-2.5">
<span className="min-w-0">
<span className="block text-body text-content">{t('docsync.allowInsecureTls')}</span>
<span className="mt-0.5 block text-caption text-content-muted">
{t('docsync.connect.insecureHint')}
</span>
</span>
<span className="shrink-0 pt-0.5">
<ToggleSwitch on={form.insecureTls} onToggle={() => form.setInsecureTls(!form.insecureTls)} />
</span>
</label>
</div>
</Modal>
)
}
function Field({
label,
hint,
required,
children,
}: {
label: string
hint?: string
required?: boolean
children: React.ReactNode
}) {
const { t } = useTranslation()
return (
<label className="block">
<span className="mb-1.5 flex items-center gap-2">
<span className="text-body font-medium text-content">{label}</span>
{!required && <Badge tone="neutral">{t('docsync.connect.optional')}</Badge>}
</span>
{children}
{hint && <span className="mt-1.5 block text-caption text-content-muted">{hint}</span>}
</label>
)
}
/** The probe result, in the footer where it stays put while the form scrolls. */
function Verdict({
verdict,
busy,
failure,
}: {
verdict: { connected: boolean; account?: string; error?: string } | null
busy: boolean
/** A call that failed outside the connection test (saving, most of all). */
failure: string | null
}) {
const { t } = useTranslation()
if (busy) {
return (
<span className="flex min-w-0 items-center gap-2 text-caption text-content-muted">
<Loader2 size={14} className="animate-spin" />
{t('docsync.connect.testing')}
</span>
)
}
// A save that the server refused left the dialog open with the button back to
// normal and nothing else said, which reads as nothing having happened. The
// test verdict wins the space when there is one: it is the more specific
// answer and the one the person just asked for.
if (!verdict && failure) {
return (
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
<AlertTriangle size={14} />
<span className="truncate">{t(`docsync.error.${failure}`)}</span>
</span>
)
}
if (!verdict) return <span />
if (verdict.connected) {
return (
<span className="flex min-w-0 items-center gap-2 text-caption text-success">
<Check size={14} strokeWidth={2.5} />
<span className="truncate">
{verdict.account ? t('docsync.connect.okAs', { account: verdict.account }) : t('docsync.connected')}
</span>
</span>
)
}
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
return (
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
{blocked ? <ShieldAlert size={14} /> : <AlertTriangle size={14} />}
<span className="truncate">{t(`docsync.error.${verdict.error || 'unknown'}`)}</span>
</span>
)
}
@@ -0,0 +1,214 @@
// FE-DOCSYNC-FLOW-001 to FE-DOCSYNC-FLOW-013
/**
* The direction control of the document-sync dialog.
*
* Two lanes, each one a direction that is either carrying or idle. The rules
* that matter to somebody using it: what each side is holding is readable
* before a word is, switching direction means switching a lane off, and the
* last lane cannot be switched off, a binding with no direction syncs
* nothing, and a dialog that lets you build one has lied about what it does.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent } from '../../../../tests/helpers/render'
import DocSyncFlow, { type FlowHoldings, type SyncDirection } from './DocSyncFlow'
// The captions come from the locale rather than from English typed out here:
// the assertion is "the lane labelled as the outbound one", not the wording.
const S = en as unknown as Record<string, string>
const OUT = S['docsync.flow.toProvider']
const IN = S['docsync.flow.toTrek']
const onChange = vi.fn()
const holdings: FlowHoldings = { inTrek: 12, atProvider: 7, paired: 5, missing: 2 }
function renderFlow(
props: Partial<{
direction: SyncDirection
providerId: string
providerName: string
holdings: FlowHoldings
running: boolean
disabled: boolean
}> = {},
) {
return render(
<DocSyncFlow
direction="both"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
{...props}
/>,
)
}
const laneOut = () => screen.getByRole('button', { name: OUT })
const laneIn = () => screen.getByRole('button', { name: IN })
beforeEach(() => {
vi.clearAllMocks()
})
describe('DocSyncFlow ends', () => {
it('FE-DOCSYNC-FLOW-001: shows what each side is holding, next to the name of that side', () => {
renderFlow()
expect(screen.getByText('12')).toBeInTheDocument()
expect(screen.getByText('7')).toBeInTheDocument()
expect(screen.getByText(S['docsync.flow.trek'])).toBeInTheDocument()
expect(screen.getByText('Paperless')).toBeInTheDocument()
})
it('FE-DOCSYNC-FLOW-002: an empty side reads as zero rather than as nothing', () => {
// A healthy binding transfers nothing; the panel must still answer the
// question somebody opened it for.
renderFlow({ holdings: { inTrek: 0, atProvider: 0, paired: 0, missing: 0 } })
expect(screen.getAllByText('0')).toHaveLength(2)
})
it('FE-DOCSYNC-FLOW-003: an unrecognised store still names itself', () => {
renderFlow({ providerId: 'not-a-provider', providerName: 'Some Store' })
expect(screen.getByText('Some Store')).toBeInTheDocument()
expect(laneOut()).toBeInTheDocument()
})
})
describe('DocSyncFlow direction switching', () => {
it('FE-DOCSYNC-FLOW-004: switching the outbound lane off from both leaves inbound only', () => {
renderFlow({ direction: 'both' })
fireEvent.click(laneOut())
expect(onChange).toHaveBeenCalledWith('pull')
})
it('FE-DOCSYNC-FLOW-005: switching the inbound lane off from both leaves outbound only', () => {
renderFlow({ direction: 'both' })
fireEvent.click(laneIn())
expect(onChange).toHaveBeenCalledWith('push')
})
it('FE-DOCSYNC-FLOW-006: switching the idle inbound lane on from push gives both ways', () => {
renderFlow({ direction: 'push' })
fireEvent.click(laneIn())
expect(onChange).toHaveBeenCalledWith('both')
})
it('FE-DOCSYNC-FLOW-007: switching the idle outbound lane on from pull gives both ways', () => {
renderFlow({ direction: 'pull' })
fireEvent.click(laneOut())
expect(onChange).toHaveBeenCalledWith('both')
})
it('FE-DOCSYNC-FLOW-008: the last carrying lane refuses to switch off (push)', () => {
renderFlow({ direction: 'push' })
fireEvent.click(laneOut())
expect(onChange).not.toHaveBeenCalled()
expect(laneOut()).toHaveAttribute('aria-pressed', 'true')
})
it('FE-DOCSYNC-FLOW-009: the last carrying lane refuses to switch off (pull)', () => {
renderFlow({ direction: 'pull' })
fireEvent.click(laneIn())
expect(onChange).not.toHaveBeenCalled()
expect(laneIn()).toHaveAttribute('aria-pressed', 'true')
})
it('FE-DOCSYNC-FLOW-010: a member who may not edit the binding cannot move the lanes', () => {
renderFlow({ direction: 'both', disabled: true })
expect(laneOut()).toBeDisabled()
expect(laneIn()).toBeDisabled()
fireEvent.click(laneOut())
fireEvent.click(laneIn())
expect(onChange).not.toHaveBeenCalled()
})
})
describe('DocSyncFlow lane state', () => {
it('FE-DOCSYNC-FLOW-011: aria-pressed mirrors each lane in every direction', () => {
const { rerender } = renderFlow({ direction: 'both' })
expect(laneOut()).toHaveAttribute('aria-pressed', 'true')
expect(laneIn()).toHaveAttribute('aria-pressed', 'true')
rerender(
<DocSyncFlow
direction="push"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
/>,
)
expect(laneOut()).toHaveAttribute('aria-pressed', 'true')
expect(laneIn()).toHaveAttribute('aria-pressed', 'false')
rerender(
<DocSyncFlow
direction="pull"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
/>,
)
expect(laneOut()).toHaveAttribute('aria-pressed', 'false')
expect(laneIn()).toHaveAttribute('aria-pressed', 'true')
})
it('FE-DOCSYNC-FLOW-012: only the switched-off lane is hatched', () => {
// The hatch is the whole of "this road is not driven on", there is no
// other handle for it, so the class is the behaviour here.
const { rerender } = renderFlow({ direction: 'both' })
expect(laneOut()).not.toHaveClass('trek-docsync-lane-off')
expect(laneIn()).not.toHaveClass('trek-docsync-lane-off')
rerender(
<DocSyncFlow
direction="push"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
/>,
)
expect(laneOut()).not.toHaveClass('trek-docsync-lane-off')
expect(laneIn()).toHaveClass('trek-docsync-lane-off')
})
it('FE-DOCSYNC-FLOW-013: the hint tells an editor the lanes are tappable, a reader only what happens', () => {
const { rerender } = renderFlow({ direction: 'both' })
expect(screen.getByText(S['docsync.flow.summaryEditable.both'])).toBeInTheDocument()
rerender(
<DocSyncFlow
direction="both"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
disabled
onChange={onChange}
/>,
)
expect(screen.getByText(S['docsync.flow.summary.both'])).toBeInTheDocument()
expect(screen.queryByText(S['docsync.flow.summaryEditable.both'])).not.toBeInTheDocument()
})
})
@@ -0,0 +1,213 @@
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import TrekIcon from '../../shared/TrekIcon'
import { Badge } from './DocSyncBits'
export type SyncDirection = 'both' | 'pull' | 'push'
export interface FlowHoldings {
/** Documents this trip has in TREK. */
inTrek: number
/** How many of them the store is holding. */
atProvider: number
paired: number
missing: number
}
/**
* The direction control, drawn as what it actually is.
*
* A document sync has one characteristic picture (two places and things moving
* between them), and a dropdown reading "Both ways" hides it behind a word.
* Here the two ends carry what each side is holding, the lanes between them
* carry the direction, and switching direction means switching a lane off. It
* is the one loud element in the dialog; everything around it stays quiet.
*
* The numbers are standing counts, not the last run's transfers: a binding that
* is already in step transferred nothing, and a panel answering "0" on a
* healthy connection says nothing at the moment somebody looks at it.
*/
export default function DocSyncFlow({
direction,
providerId,
providerName,
holdings,
running,
disabled,
onChange,
}: {
direction: SyncDirection
providerId: string
providerName: string
holdings: FlowHoldings
/** A run is in flight: the active lanes move while it is. */
running?: boolean
disabled?: boolean
onChange: (next: SyncDirection) => void
}) {
const { t } = useTranslation()
const Icon = DOCUMENT_PROVIDER_ICONS[providerId]
const pushOn = direction === 'both' || direction === 'push'
const pullOn = direction === 'both' || direction === 'pull'
const hint = disabled
? t(`docsync.flow.summary.${direction}`, { provider: providerName })
: t(`docsync.flow.summaryEditable.${direction}`, { provider: providerName })
/** Turning a lane off leaves the other one; turning the last one off is refused. */
const toggle = (lane: 'push' | 'pull') => {
if (disabled) return
const nextPush = lane === 'push' ? !pushOn : pushOn
const nextPull = lane === 'pull' ? !pullOn : pullOn
if (!nextPush && !nextPull) return
onChange(nextPush && nextPull ? 'both' : nextPush ? 'push' : 'pull')
}
return (
<section className="rounded-2xl border border-edge bg-surface-secondary p-4 sm:p-6">
<div className="flex items-center gap-3 sm:gap-5">
<End
name={t('docsync.flow.trek')}
count={holdings.inTrek}
glyph={<TrekIcon className="h-6 w-6 text-content" />}
/>
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
<Lane
active={pushOn}
running={running}
disabled={disabled}
caption={t('docsync.flow.toProvider')}
onClick={() => toggle('push')}
>
<ArrowRight size={13} strokeWidth={2.75} />
</Lane>
<Lane
active={pullOn}
running={running}
reverse
disabled={disabled}
caption={t('docsync.flow.toTrek')}
onClick={() => toggle('pull')}
>
<ArrowLeft size={13} strokeWidth={2.75} />
</Lane>
{/* Under the lanes rather than under the whole bar: it explains what
the two buttons above it are doing, and sitting anywhere else
makes the reader look for what it refers to. */}
<p className="truncate px-1 text-center text-caption text-content-muted">{hint}</p>
</div>
<End
name={providerName}
count={holdings.atProvider}
glyph={Icon ? <Icon className="h-6 w-6 text-content" /> : null}
/>
</div>
</section>
)
}
/**
* One end of the flow: the mark, the count, the name.
*
* The count is the largest thing in the box on purpose: "is my stuff over
* there" is the question that makes somebody open this dialog, and it should be
* answered before they read a word.
*/
function End({ name, count, glyph }: { name: string; count: number; glyph: React.ReactNode }) {
const { t } = useTranslation()
return (
<div
className="flex w-24 shrink-0 flex-col items-center gap-2 rounded-2xl border border-edge bg-surface px-3 py-4 shadow-card sm:w-32"
>
<span className="grid h-10 w-10 place-items-center rounded-xl bg-surface-secondary">{glyph}</span>
<span className="flex flex-col items-center gap-1">
<span className="text-title font-semibold tabular-nums leading-none text-content">{count}</span>
<span className="text-caption uppercase leading-none tracking-wide text-content-faint">
{t('docsync.flow.documents')}
</span>
</span>
<Badge tone="neutral" title={name}>
<span className="max-w-[6.5rem] truncate">{name}</span>
</Badge>
</div>
)
}
/**
* One direction, as a track that is either carrying or idle.
*
* An idle track stays visible as a dashed line, so switching a direction off
* reads as a road nobody drives on rather than as something that disappeared.
*/
function Lane({
active,
running,
disabled,
caption,
reverse,
onClick,
children,
}: {
active: boolean
running?: boolean
disabled?: boolean
caption: string
reverse?: boolean
onClick: () => void
children: React.ReactNode
}) {
const moving = active && running
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-pressed={active}
className={[
// No border at all, not a transparent one: a border box across the
// diagonals still renders a hairline where it meets the corner radius,
// and that line reads as a broken frame rather than as a hatch.
'group relative flex h-12 items-center gap-3 overflow-hidden rounded-xl px-3 text-caption transition-all duration-200',
reverse ? 'flex-row-reverse' : '',
active
? 'bg-accent-subtle text-accent-on'
: 'trek-docsync-lane-off bg-surface text-content-faint',
disabled ? 'cursor-default' : active ? 'hover:brightness-[0.98]' : 'hover:text-content-muted',
].join(' ')}
>
<span
className={[
'relative grid h-7 w-7 shrink-0 place-items-center rounded-lg transition-transform duration-200',
active ? 'bg-surface shadow-sm' : 'bg-surface-secondary',
disabled ? '' : reverse ? 'group-hover:-translate-x-0.5' : 'group-hover:translate-x-0.5',
].join(' ')}
>
{children}
</span>
{/* Explicitly aligned to the arrow's side: the dialog inherits a centred
text-align, which would otherwise float the label away from it. */}
<span className={`relative min-w-0 flex-1 truncate font-medium ${reverse ? 'text-right' : 'text-left'}`}>
{caption}
</span>
{/* Only while a run is in flight. An idle lane needs no track (that it is
on or off is already in its fill), and a permanent line just asks the
reader what it means. */}
{moving && (
<span
aria-hidden
className={`trek-docsync-lane-run pointer-events-none absolute inset-x-3 bottom-1.5 h-0.5 rounded-full ${
reverse ? 'trek-docsync-lane-run-rev' : ''
}`}
/>
)}
</button>
)
}
@@ -0,0 +1,528 @@
// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-022
/**
* The document-sync dialog shell (#2391).
*
* The panel itself owns three decisions: which store is open, which of them a
* person is even offered, and whether the "needs a look" list is worth the
* space. Everything below it (the binding card, the flow bar, the connect and
* scope modals) is tested where it lives; what is exercised here is the shell
* around them, driven through the real `useDocSync` against a mocked
* `docsyncApi`, because the selection rules are a reaction to what the server
* answers rather than to a prop.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { act, render, screen, within, fireEvent, waitFor } from '../../../../tests/helpers/render'
/** What the dialog actually renders for a key, via the same fallback chain `t()` uses. */
const t = (key: string): string => (en as unknown as Record<string, string>)[key] ?? key
const providers = vi.fn()
const listConnections = vi.fn()
const listLinks = vi.fn()
const status = vi.fn()
const deleteLink = vi.fn()
const updateLink = vi.fn()
const syncNow = vi.fn()
const items = vi.fn()
const resolve = vi.fn()
const saveConnection = vi.fn()
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
return {
...actual,
docsyncApi: {
...actual.docsyncApi,
providers: (tripId: number | string) => providers(tripId),
listConnections: (tripId: number | string) => listConnections(tripId),
listLinks: (tripId: number | string) => listLinks(tripId),
status: (tripId: number | string) => status(tripId),
deleteLink: (tripId: number | string, linkId: number) => deleteLink(tripId, linkId),
updateLink: (tripId: number | string, linkId: number, patch: unknown) => updateLink(tripId, linkId, patch),
syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNow(tripId, linkId, full),
items: (tripId: number | string, state?: string) => items(tripId, state),
resolve: (tripId: number | string, itemId: number, keep: string) => resolve(tripId, itemId, keep),
saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data),
},
}
})
import DocSyncPanel from './DocSyncPanel'
import type { DocSyncLink, DocSyncProvider } from './useDocSync'
const provider = (id: string, name: string, available = true): DocSyncProvider => ({
id,
name,
description: null,
icon: id,
available,
fields: [],
})
const link = (id: number, providerId: string, overrides: Partial<DocSyncLink> = {}): DocSyncLink => ({
id,
connectionId: id,
providerId,
scopeKey: `scope-${id}`,
remoteLabel: `Folder ${id}`,
remoteRootPath: `/TREK/folder-${id}`,
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
lastSyncAt: null,
lastSyncState: 'ok',
lastSyncError: null,
webhookUrl: null,
...overrides,
})
/** What the server answers, as one object the tests reshape per case. */
function serverHas({
providers: p = [],
connections = [],
links = [],
items = {},
}: {
providers?: DocSyncProvider[]
connections?: Array<Record<string, unknown>>
links?: DocSyncLink[]
items?: Record<string, number>
}): void {
providers.mockResolvedValue(p)
listConnections.mockResolvedValue(connections)
listLinks.mockResolvedValue(links)
status.mockResolvedValue({
items,
links: links.map(l => ({ id: l.id, holdings: { inTrek: 3, atProvider: 3, paired: 3, missing: 0 } })),
})
}
/** The stored connection a binding runs under. */
const connection = (id: number, providerId: string) => ({
id,
providerId,
baseUrl: 'https://docs.example.org',
settings: {},
secrets: { token: '***' },
allowInsecureTls: false,
lastProbeState: 'ok',
lastProbeError: null,
})
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(r => { resolve = r })
return { promise, resolve }
}
const onClose = vi.fn()
/** The detail column's binding card. */
const binding = () => screen.getByRole('article')
/** The store list beside it. */
const sidebar = () => screen.getByRole('navigation')
/** The stores filed under one sidebar heading. */
const group = (heading: string) =>
within(screen.getByRole('heading', { name: heading }).parentElement as HTMLElement)
/** The "needs a look" section under the binding card. */
const issues = () =>
within(screen.getByRole('heading', { name: t('docsync.issues.title') }).closest('section') as HTMLElement)
/** Disconnect asks first; this answers yes. The question renders in a portal. */
const confirmUnlink = () => {
const dialog = screen.getByText(t('docsync.confirmUnlink')).closest('.trek-modal-enter') as HTMLElement
fireEvent.click(within(dialog).getByRole('button', { name: t('docsync.unlink') }))
}
beforeEach(() => {
vi.clearAllMocks()
serverHas({})
deleteLink.mockResolvedValue({})
updateLink.mockResolvedValue({})
syncNow.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
items.mockResolvedValue([])
resolve.mockResolvedValue({ success: true })
saveConnection.mockResolvedValue({ id: 1 })
})
describe('DocSyncPanel: first load', () => {
it('FE-DOCSYNC-PANEL-001: shows a spinner while the first load is in flight and nothing else', async () => {
const gate = deferred<DocSyncProvider[]>()
providers.mockReturnValue(gate.promise)
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
// The spinner carries no text of its own; the spin is the whole affordance.
expect(document.querySelector('.animate-spin')).not.toBeNull()
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
expect(screen.queryByText(t('docsync.noProviders'))).not.toBeInTheDocument()
await act(async () => { gate.resolve([]) })
})
it('FE-DOCSYNC-PANEL-002: replaces the spinner with the store list once the load lands', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')] })
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
expect(await screen.findByRole('navigation')).toBeInTheDocument()
expect(document.querySelector('.animate-spin')).toBeNull()
})
it('FE-DOCSYNC-PANEL-003: says there is nothing to configure when the instance offers no provider', async () => {
serverHas({ providers: [] })
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
expect(await screen.findByText(t('docsync.noProviders'))).toBeInTheDocument()
expect(screen.getByText(t('docsync.noProvidersHint'))).toBeInTheDocument()
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-017: a bound trip keeps showing its binding after every provider was switched off', async () => {
serverHas({ providers: [], links: [link(1, 'paperless', { providerName: 'Paperless-ngx' })] })
render(<DocSyncPanel tripId={7} canManage={false} onClose={onClose} />)
expect(await screen.findByRole('article')).toBeInTheDocument()
expect(within(sidebar()).getByText('Folder 1')).toBeInTheDocument()
expect(screen.queryByText(t('docsync.noProviders'))).not.toBeInTheDocument()
// The providers route no longer names it, so the name comes off the link.
expect(within(sidebar()).getByText('Paperless-ngx')).toBeInTheDocument()
expect(within(binding()).getAllByText('Paperless-ngx').length).toBeGreaterThan(0)
expect(screen.queryByText('paperless')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-018: a reload that finds the addon gone blanks the bindings too', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [link(1, 'paperless')] })
// Documents is switched off while the dialog is open: the run is refused
// and so is every route the reload after it asks.
const gone = { response: { status: 404 } }
syncNow.mockImplementation(async () => {
providers.mockRejectedValue(gone)
throw gone
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.syncNow') }))
expect(await screen.findByText(t('docsync.noProviders'))).toBeInTheDocument()
expect(screen.queryByRole('article')).not.toBeInTheDocument()
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-004: a provider switched on without a working adapter is not offered', async () => {
serverHas({ providers: [provider('papra', 'Papra', false)] })
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
expect(await screen.findByText(t('docsync.noProviders'))).toBeInTheDocument()
expect(screen.queryByText('Papra')).not.toBeInTheDocument()
})
})
describe('DocSyncPanel: the store list', () => {
it('FE-DOCSYNC-PANEL-005: files the bound stores under one heading and the rest under another', async () => {
serverHas({
providers: [
provider('paperless', 'Paperless-ngx'),
provider('nextcloud', 'Nextcloud'),
provider('opencloud', 'OpenCloud'),
],
links: [link(1, 'paperless')],
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('navigation')
const connected = group(t('docsync.sidebar.connected'))
expect(connected.getByRole('button', { name: /Paperless-ngx/ })).toBeInTheDocument()
expect(connected.queryByRole('button', { name: /Nextcloud/ })).not.toBeInTheDocument()
const addable = group(t('docsync.addAnother'))
expect(addable.getByRole('button', { name: /Nextcloud/ })).toBeInTheDocument()
expect(addable.getByRole('button', { name: /OpenCloud/ })).toBeInTheDocument()
expect(addable.queryByRole('button', { name: /Paperless-ngx/ })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-006: with nothing bound the list invites a first connection instead of another one', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [] })
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('navigation')
expect(screen.getByRole('heading', { name: t('docsync.addProvider') })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: t('docsync.addAnother') })).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: t('docsync.sidebar.connected') })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-007: opens the first bound store without being asked', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')],
links: [link(1, 'paperless'), link(2, 'nextcloud')],
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
const card = await screen.findByRole('article')
// The card names its provider twice (header and flow bar), so the folder is
// what says which of the two bindings is open.
expect(within(card).getByText('Folder 1')).toBeInTheDocument()
expect(within(card).queryByText('Folder 2')).not.toBeInTheDocument()
expect(within(card).getAllByText('Paperless-ngx').length).toBeGreaterThan(0)
expect(within(card).queryByText('Nextcloud')).not.toBeInTheDocument()
expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).toHaveAttribute('aria-current', 'true')
})
it('FE-DOCSYNC-PANEL-008: picking a store swaps the detail column and moves the marker with it', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')],
links: [link(1, 'paperless'), link(2, 'nextcloud')],
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
const second = within(sidebar()).getByRole('button', { name: /Nextcloud/ })
fireEvent.click(second)
expect(within(binding()).getByText('Folder 2')).toBeInTheDocument()
expect(within(binding()).queryByText('Folder 1')).not.toBeInTheDocument()
expect(within(binding()).getAllByText('Nextcloud').length).toBeGreaterThan(0)
expect(within(binding()).queryByText('Paperless-ngx')).not.toBeInTheDocument()
expect(second).toHaveAttribute('aria-current', 'true')
expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).not.toHaveAttribute('aria-current')
})
})
describe('DocSyncPanel: a member rather than the owner', () => {
it('FE-DOCSYNC-PANEL-009: is shown where the documents go but is offered no store to add', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')],
links: [link(1, 'paperless')],
})
render(<DocSyncPanel tripId={7} canManage={false} onClose={onClose} />)
await screen.findByRole('navigation')
expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: t('docsync.addAnother') })).not.toBeInTheDocument()
expect(within(sidebar()).queryByRole('button', { name: /Nextcloud/ })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-010: with nothing bound is told the owner sets this up, not to pick a store', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [] })
render(<DocSyncPanel tripId={7} canManage={false} onClose={onClose} />)
expect(await screen.findByText(t('docsync.empty.hintMember'))).toBeInTheDocument()
expect(screen.queryByText(t('docsync.empty.hintOwner'))).not.toBeInTheDocument()
expect(within(sidebar()).queryAllByRole('button')).toHaveLength(0)
})
})
describe('DocSyncPanel: removing a binding', () => {
it('FE-DOCSYNC-PANEL-011: moves the selection to the binding that is left instead of emptying the column', async () => {
const paperless = link(1, 'paperless')
const nextcloud = link(2, 'nextcloud')
const both = [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')]
serverHas({ providers: both, links: [paperless, nextcloud] })
// The unlink lands on the server, and the reload after it is what the panel
// reacts to, so that is where the row disappears.
deleteLink.mockImplementation(async () => {
serverHas({ providers: both, links: [paperless] })
return {}
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
fireEvent.click(within(sidebar()).getByRole('button', { name: /Nextcloud/ }))
expect(within(binding()).getByText('Folder 2')).toBeInTheDocument()
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.unlink') }))
confirmUnlink()
await waitFor(() => expect(deleteLink).toHaveBeenCalledWith(7, 2))
await waitFor(() => expect(within(binding()).getByText('Folder 1')).toBeInTheDocument())
expect(screen.queryByText(t('docsync.empty.title'))).not.toBeInTheDocument()
expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).toHaveAttribute('aria-current', 'true')
})
it('FE-DOCSYNC-PANEL-012: removing the last binding leaves the invitation, not a blank column', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [link(1, 'paperless')] })
deleteLink.mockImplementation(async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [] })
return {}
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.unlink') }))
confirmUnlink()
expect(await screen.findByText(t('docsync.empty.title'))).toBeInTheDocument()
expect(screen.getByText(t('docsync.empty.hintOwner'))).toBeInTheDocument()
expect(screen.queryByRole('article')).not.toBeInTheDocument()
})
})
describe('DocSyncPanel: what needs a look', () => {
it('FE-DOCSYNC-PANEL-013: stays quiet when nothing needs attention', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [link(1, 'paperless')], items: {} })
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
expect(screen.queryByRole('heading', { name: t('docsync.issues.title') })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-014: counts that need no decision do not open the issues panel', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx')],
links: [link(1, 'paperless')],
items: { pending: 9, synced: 41, local_deleted: 2 },
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
expect(screen.queryByRole('heading', { name: t('docsync.issues.title') })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-015: names only the states needing attention, with their counts', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx')],
links: [link(1, 'paperless')],
items: { conflict: 2, too_large: 1, pending: 9, synced: 41 },
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
const panel = within(
screen.getByRole('heading', { name: t('docsync.issues.title') }).closest('section') as HTMLElement,
)
const rows = panel.getAllByRole('listitem')
expect(rows).toHaveLength(2)
expect(within(rows[0]).getByText(t('docsync.state.conflict'))).toBeInTheDocument()
expect(within(rows[0]).getByText(t('docsync.issues.conflict'))).toBeInTheDocument()
// The conflict row carries a button rather than a bare count: it is the one
// of these a person can act on.
expect(within(rows[0]).getByRole('button', { name: /Resolve/ })).toBeInTheDocument()
expect(within(rows[1]).getByText(t('docsync.state.too_large'))).toBeInTheDocument()
expect(within(rows[1]).getByText('1')).toBeInTheDocument()
expect(panel.queryByText(t('docsync.state.pending'))).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-019: a member is shown the conflict count but offered no choice', async () => {
// The server refuses a member's choice with 403, so the three buttons
// would only ever fail for them. The count is what they are owed.
serverHas({
providers: [provider('paperless', 'Paperless-ngx')],
links: [link(1, 'paperless')],
items: { conflict: 2 },
})
render(<DocSyncPanel tripId={7} canManage={false} onClose={onClose} />)
await screen.findByRole('article')
const row = issues().getAllByRole('listitem')[0]
expect(within(row).getByText(t('docsync.state.conflict'))).toBeInTheDocument()
expect(within(row).getByText('2')).toBeInTheDocument()
expect(within(row).queryByRole('button')).not.toBeInTheDocument()
expect(items).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-PANEL-020: a choice the server refuses is said in the column, and the row stays', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx')],
links: [link(1, 'paperless')],
items: { conflict: 1 },
})
items.mockResolvedValue([{ id: 40, file_name: 'boarding-pass.pdf', remote_name: 'boarding-pass.pdf' }])
// Settled by the owner in another tab a moment ago.
resolve.mockRejectedValue({ response: { status: 400, data: { error: 'conflict' } } })
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
fireEvent.click(issues().getByRole('button', { name: /Resolve/ }))
fireEvent.click(await issues().findByRole('button', { name: t('docsync.conflict.keepTrek') }))
await waitFor(() => expect(resolve).toHaveBeenCalledWith(7, 40, 'trek'))
expect(await screen.findByRole('alert')).toHaveTextContent(t('docsync.error.conflict'))
// The list is read back rather than trimmed by hand, so the row is still
// there, ready for another try.
await waitFor(() => expect(issues().getByRole('button', { name: t('docsync.conflict.keepTrek') })).toBeEnabled())
expect(issues().getByText('boarding-pass.pdf')).toBeInTheDocument()
expect(items).toHaveBeenCalledTimes(2)
})
})
describe('DocSyncPanel: what a click on the card reports', () => {
it('FE-DOCSYNC-PANEL-021: a refused run is said under the card rather than swallowed', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [link(1, 'paperless')] })
syncNow.mockRejectedValue({ response: { status: 409, data: { error: 'provider_disabled' } } })
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.syncNow') }))
expect(await screen.findByRole('alert')).toHaveTextContent(t('docsync.error.provider_disabled'))
expect(screen.getByRole('article')).toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-022: a refused credential opens the form for the bound store, and saving runs the binding', async () => {
// The sidebar only ever opened the form for a store with no connection
// yet, and a bound store is not listed there, so a rotated token could
// only be entered through the API.
serverHas({
providers: [provider('paperless', 'Paperless-ngx')],
connections: [connection(5, 'paperless')],
links: [link(1, 'paperless', { lastSyncState: 'needs_reauth', lastSyncError: 'unauthorized' })],
})
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
await screen.findByRole('article')
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.binding.reconnect') }))
// The credential form, for this store, not the folder picker.
expect(screen.getByRole('button', { name: t('docsync.connect.submit') })).toBeInTheDocument()
expect(screen.queryByText(t('docsync.scope.pickTitle'))).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: t('docsync.connect.submit') }))
await waitFor(() => expect(saveConnection).toHaveBeenCalledWith(7, expect.objectContaining({ providerId: 'paperless' })))
await waitFor(() => expect(syncNow).toHaveBeenCalledWith(7, 1, false))
await waitFor(() => expect(screen.queryByRole('button', { name: t('docsync.connect.submit') })).not.toBeInTheDocument())
expect(screen.queryByText(t('docsync.scope.pickTitle'))).not.toBeInTheDocument()
})
})
describe('DocSyncPanel: the dialog itself', () => {
it('FE-DOCSYNC-PANEL-016: names the trip under the title and closes from the header', async () => {
serverHas({ providers: [] })
render(<DocSyncPanel tripId={7} tripTitle="Iceland 2026" canManage onClose={onClose} />)
await screen.findByText(t('docsync.noProviders'))
expect(screen.getByText(t('docsync.title'))).toBeInTheDocument()
expect(screen.getByText('Iceland 2026')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button'))
expect(onClose).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,428 @@
import { useEffect, useMemo, useState } from 'react'
import { AlertCircle, ArrowRight, Check, Loader2, Plus } from 'lucide-react'
import Modal from '../../shared/Modal'
import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import { StateBadge } from './DocSyncBits'
import { useConflicts } from './useConflicts'
import { storeName, useDocSync, type DocSyncLink, type DocSyncProvider } from './useDocSync'
import DocSyncBinding from './DocSyncBinding'
import DocSyncConnectModal from './DocSyncConnectModal'
import DocSyncScopeModal from './DocSyncScopeModal'
/**
* Document sync for one trip, as a dialog with the stores down one side and the
* selected one open beside it.
*
* Two columns rather than a stack, because the two questions are different
* shapes: "which store" is a short list somebody scans, and "how does this one
* behave" is a screenful. Stacked, the second answer pushed the first one off
* the top every time a binding was added.
*
* It opens from the file manager rather than from settings: this is the one
* screen where a person has the context that makes the folder choice obvious.
* Only the trip owner can change a binding (the credential usually reaches
* their whole archive), but every member sees where their documents go, which
* is the minimum a shared folder owes the people sharing it.
*/
export default function DocSyncPanel({
tripId,
tripTitle,
canManage,
onClose,
}: {
tripId: number | string
tripTitle?: string
canManage: boolean
onClose: () => void
}) {
const { t } = useTranslation()
const sync = useDocSync(tripId, true)
const [selected, setSelected] = useState<number | null>(null)
const [connecting, setConnecting] = useState<DocSyncProvider | null>(null)
const [scopeFor, setScopeFor] = useState<string | null>(null)
// The credential form again, for a store this trip is already bound to. Kept
// apart from `connecting`, whose next step is the folder picker: here the
// folder is known and the next step is the run that the refusal stopped.
const [reconnecting, setReconnecting] = useState<DocSyncProvider | null>(null)
const bound = useMemo(() => new Set(sync.links.map(l => l.providerId)), [sync.links])
const available = sync.providers.filter(p => !bound.has(p.id))
const active = sync.links.find(l => l.id === selected) ?? sync.links[0] ?? null
// Undefined once an admin has switched the store off: the form needs the
// provider's field list, and the providers route no longer carries it.
const activeProvider = active ? sync.providers.find(p => p.id === active.providerId) : undefined
// Follow the list: a freshly bound store should be the one on screen, and a
// removed one must not leave the detail column pointing at nothing.
useEffect(() => {
if (sync.links.length === 0) setSelected(null)
else if (!sync.links.some(l => l.id === selected)) setSelected(sync.links[0].id)
}, [sync.links, selected])
const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0)
return (
<>
<Modal
isOpen
onClose={onClose}
size="4xl"
title={
<span className="flex flex-col">
<span>{t('docsync.title')}</span>
{tripTitle && (
<span className="mt-0.5 truncate text-caption font-normal text-content-muted">{tripTitle}</span>
)}
</span>
}
>
{sync.loading ? (
<div className="grid place-items-center py-20">
<Loader2 size={22} className="animate-spin text-content-faint" />
</div>
) : sync.providers.length === 0 && sync.links.length === 0 ? (
// Only with nothing bound: a binding outlives the admin switching its
// provider off, and the people sharing it still need to see it.
<Empty />
) : (
<div className="grid gap-5 md:grid-cols-[17rem_minmax(0,1fr)] md:gap-6">
<Sidebar
links={sync.links}
providers={sync.providers}
available={available}
activeId={active?.id ?? null}
canManage={canManage}
onSelect={setSelected}
onAdd={p => (sync.connectionFor(p.id) ? setScopeFor(p.id) : setConnecting(p))}
/>
<div className="min-w-0 md:border-l md:border-edge-faint md:pl-6">
{active ? (
<div className="space-y-5">
<DocSyncBinding
link={active}
providerName={storeName(active, sync.providers)}
sync={sync}
canManage={canManage}
onReconnect={activeProvider ? () => setReconnecting(activeProvider) : undefined}
/>
{/* Every action in this column reports here: a refused run, a
switch that flipped back, a conflict the server would not
settle. The two dialogs say their own, but nothing said
what happened to a click on the card itself. */}
{sync.error && (
<p role="alert" className="rounded-xl border border-edge bg-danger-soft px-3 py-2.5 text-caption text-danger">
{t(`docsync.error.${sync.error}`)}
</p>
)}
{attention > 0 && (
<Attention counts={sync.itemCounts} tripId={tripId} sync={sync} canManage={canManage} />
)}
</div>
) : (
<NothingBound canManage={canManage} />
)}
</div>
</div>
)}
</Modal>
{connecting && (
<DocSyncConnectModal
provider={connecting}
sync={sync}
onClose={() => setConnecting(null)}
onConnected={id => { setConnecting(null); setScopeFor(id) }}
/>
)}
{reconnecting && (
<DocSyncConnectModal
provider={reconnecting}
sync={sync}
onClose={() => setReconnecting(null)}
onConnected={id => {
setReconnecting(null)
// Run straight away, as binding does: the card still reports the
// refusal until a run says otherwise, and the person has just
// done the one thing that could change the answer.
const rebound = sync.links.find(l => l.providerId === id)
if (rebound) void sync.syncNow(rebound.id)
}}
/>
)}
{scopeFor && sync.connectionFor(scopeFor) && (
<DocSyncScopeModal
connection={sync.connectionFor(scopeFor)!}
providerName={sync.providers.find(p => p.id === scopeFor)?.name ?? scopeFor}
suggestedName={slugFor(tripTitle, tripId)}
sync={sync}
onClose={() => setScopeFor(null)}
onBound={() => setScopeFor(null)}
/>
)}
</>
)
}
const ATTENTION_STATES = ['conflict', 'remote_missing', 'rejected_type', 'too_large', 'error'] as const
/** The stores: the ones this trip uses, then the ones it could. */
function Sidebar({
links,
providers,
available,
activeId,
canManage,
onSelect,
onAdd,
}: {
links: DocSyncLink[]
providers: DocSyncProvider[]
available: DocSyncProvider[]
activeId: number | null
canManage: boolean
onSelect: (id: number) => void
onAdd: (p: DocSyncProvider) => void
}) {
const { t } = useTranslation()
return (
<nav className="space-y-5">
{links.length > 0 && (
<div>
<h4 className="mb-2 px-1 text-caption font-medium text-content-muted">
{t('docsync.sidebar.connected')}
</h4>
<ul className="space-y-1.5">
{links.map(link => (
<li key={link.id}>
<StoreButton
providerId={link.providerId}
title={storeName(link, providers)}
subtitle={link.remoteLabel || link.remoteRootPath || link.scopeKey}
state={link.lastSyncState}
active={link.id === activeId}
onClick={() => onSelect(link.id)}
/>
</li>
))}
</ul>
</div>
)}
{canManage && available.length > 0 && (
<div>
<h4 className="mb-2 px-1 text-caption font-medium text-content-muted">
{links.length === 0 ? t('docsync.addProvider') : t('docsync.addAnother')}
</h4>
<ul className="space-y-1.5">
{available.map(p => (
<li key={p.id}>
<StoreButton
providerId={p.id}
title={p.name}
subtitle={t(`docsync.model.${p.id}`)}
onClick={() => onAdd(p)}
addable
/>
</li>
))}
</ul>
</div>
)}
</nav>
)
}
/**
* One row in the store list.
*
* The selected row is marked with the accent on a subtle fill rather than a
* heavy border, so a list of five does not turn into five competing boxes.
*/
function StoreButton({
providerId,
title,
subtitle,
state,
active,
addable,
onClick,
}: {
providerId: string
title: string
subtitle: string
state?: string
active?: boolean
addable?: boolean
onClick: () => void
}) {
const Icon = DOCUMENT_PROVIDER_ICONS[providerId]
return (
<button
type="button"
onClick={onClick}
aria-current={active ? 'true' : undefined}
className={[
'group flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-colors',
active
? 'border-transparent bg-accent-subtle'
: 'border-edge bg-surface hover:bg-surface-hover',
].join(' ')}
>
<span
className={[
'grid h-8 w-8 shrink-0 place-items-center rounded-lg',
active ? 'bg-surface' : 'bg-surface-secondary',
].join(' ')}
>
{Icon ? <Icon className={`h-4 w-4 ${active ? 'text-accent-on' : 'text-content'}`} /> : null}
</span>
<span className="min-w-0 flex-1">
<span className={`block truncate text-body ${active ? 'font-medium text-accent-on' : 'text-content'}`}>
{title}
</span>
<span className="mt-0.5 block truncate text-caption text-content-muted">{subtitle}</span>
</span>
{addable ? (
<Plus size={14} className="shrink-0 text-content-faint transition-transform group-hover:scale-110" />
) : state ? (
<StateBadge state={state} compact />
) : null}
</button>
)
}
/** What a person still has to decide about, named rather than counted in a chip. */
function Attention({
counts,
tripId,
sync,
canManage,
}: {
counts: Record<string, number>
tripId: number | string
sync: ReturnType<typeof useDocSync>
canManage: boolean
}) {
const { t } = useTranslation()
const rows = ATTENTION_STATES.filter(k => (counts[k] ?? 0) > 0)
const [showConflicts, setShowConflicts] = useState(false)
const conflicts = useConflicts(tripId, sync, showConflicts)
return (
<section className="rounded-xl border border-edge bg-surface">
<h4 className="flex items-center gap-2 border-b border-edge-faint px-4 py-2.5 text-body font-medium text-content">
<AlertCircle size={14} className="text-warning" />
{t('docsync.issues.title')}
</h4>
<ul className="divide-y divide-edge-faint">
{rows.map(k => (
<li key={k} className="px-4 py-2.5">
<div className="flex items-baseline justify-between gap-3">
<span className="min-w-0">
<span className="block text-body text-content">{t(`docsync.state.${k}`)}</span>
<span className="mt-0.5 block text-caption text-content-muted">{t(`docsync.issues.${k}`)}</span>
</span>
{/* A conflict is the one of these a person can act on, so it is
the one that opens. The rest are a state to read. Only for
the owner: the server refuses a member's choice, so a member
gets the count, like every other row. */}
{k === 'conflict' && canManage ? (
<button
type="button"
onClick={() => setShowConflicts(v => !v)}
className="shrink-0 rounded-lg border border-edge px-2.5 py-1 text-caption font-medium text-content transition-colors hover:bg-surface-hover"
>
{showConflicts ? t('common.close') : t('docsync.conflict.resolve', { count: counts[k] })}
</button>
) : (
<span className="shrink-0 text-body font-medium tabular-nums text-content">{counts[k]}</span>
)}
</div>
{k === 'conflict' && canManage && showConflicts && (
<ul className="mt-3 space-y-2">
{conflicts.items === null && (
<li className="flex justify-center py-2">
<Loader2 size={14} className="animate-spin text-content-muted" />
</li>
)}
{conflicts.items?.map(item => (
<li key={item.id} className="rounded-lg bg-surface-secondary px-3 py-2.5">
<p className="truncate text-body text-content" title={item.name}>{item.name}</p>
<p className="mt-0.5 text-caption text-content-muted">{t('docsync.conflict.title')}</p>
<div className="mt-2 flex flex-wrap gap-2">
{(['trek', 'provider', 'both'] as const).map(keep => (
<button
key={keep}
type="button"
disabled={conflicts.working === item.id}
onClick={() => void conflicts.resolve(item.id, keep)}
className="rounded-lg border border-edge px-2.5 py-1 text-caption text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50"
>
{t(`docsync.conflict.${keep === 'trek' ? 'keepTrek' : keep === 'provider' ? 'keepProvider' : 'keepBoth'}`)}
</button>
))}
</div>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</section>
)
}
/** The detail column before anything is bound: an invitation, not a blank. */
function NothingBound({ canManage }: { canManage: boolean }) {
const { t } = useTranslation()
return (
<div className="grid h-full min-h-[16rem] place-items-center rounded-xl border border-dashed border-edge px-6 py-12 text-center">
<div>
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl border border-edge bg-surface-secondary">
<ArrowRight size={18} className="text-content-faint" />
</span>
<p className="mt-3 text-body text-content">{t('docsync.empty.title')}</p>
<p className="mx-auto mt-1 max-w-xs text-caption text-content-muted">
{canManage ? t('docsync.empty.hintOwner') : t('docsync.empty.hintMember')}
</p>
</div>
</div>
)
}
function Empty() {
const { t } = useTranslation()
return (
<div className="rounded-xl border border-dashed border-edge px-6 py-12 text-center">
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl border border-edge bg-surface-secondary">
<Check size={18} className="text-content-faint" />
</span>
<p className="mt-3 text-body text-content">{t('docsync.noProviders')}</p>
<p className="mx-auto mt-1 max-w-sm text-caption text-content-muted">{t('docsync.noProvidersHint')}</p>
</div>
)
}
/**
* A folder name from the trip's own title, so nobody has to invent one.
*
* The id is appended because two trips can share a title and a folder cannot.
*/
function slugFor(title: string | undefined, tripId: number | string): string {
const base = (title || 'trek')
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
return `${base || 'trek'}-${tripId}`
}
@@ -0,0 +1,383 @@
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-023
/**
* The folder picker of the document-sync dialog.
*
* What matters to somebody opening it: the folders of their own store show up,
* the one they pick is the one that gets bound, making a new folder is one
* action rather than two, and a store that refuses to answer says so instead of
* pretending the archive is empty.
*
* The component takes the `useDocSync` hook as a prop, so the double here is the
* real hook with `api/client` mocked underneath, so the assertions are about
* the requests that actually leave, not about a hand-written stand-in. The hook
* is created with `enabled: false` because the panel-level lists it would fetch
* belong to the dialog around this modal, not to the picker.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor } from '../../../../tests/helpers/render'
import type { DocSyncConnection, DocSyncScope } from './useDocSync'
const S = en as unknown as Record<string, string>
const listScopes = vi.fn()
const createScope = vi.fn()
const createLink = vi.fn()
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
return {
...actual,
docsyncApi: {
...actual.docsyncApi,
providers: () => Promise.resolve([]),
status: () => Promise.resolve({ items: {}, links: [] }),
listConnections: () => Promise.resolve([]),
listLinks: () => Promise.resolve([]),
listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopes(tripId, connectionId, q),
createScope: (tripId: number | string, connectionId: number, name: string) => createScope(tripId, connectionId, name),
createLink: (tripId: number | string, data: unknown) => createLink(tripId, data),
},
}
})
import DocSyncScopeModal from './DocSyncScopeModal'
import { useDocSync } from './useDocSync'
const TRIP_ID = 42
const connection: DocSyncConnection = {
id: 5,
providerId: 'paperless',
baseUrl: 'https://docs.example.org',
settings: {},
secrets: {},
allowInsecureTls: false,
lastProbeState: 'ok',
lastProbeError: null,
}
const folder = (n: number, label: string, remoteRootPath: string | null = `/Documents/${label}`): DocSyncScope =>
({ scopeKey: `folder-${n}`, label, remoteRootId: String(n), remoteRootPath })
/** Seven, so the list is one over the threshold that earns a search box. */
const SEVEN = [
folder(1, 'Trips'),
folder(2, 'Archive'),
folder(3, 'Receipts'),
folder(4, 'Invoices'),
folder(5, 'Photos'),
folder(6, 'Manuals'),
folder(7, 'Warranty'),
]
const SIX = SEVEN.slice(0, 6)
const THREE = SEVEN.slice(0, 3)
const onClose = vi.fn()
const onBound = vi.fn()
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }: {
suggestedName?: string
providerName?: string
}) {
const sync = useDocSync(TRIP_ID, false)
return (
<DocSyncScopeModal
connection={connection}
providerName={providerName}
suggestedName={suggestedName}
sync={sync}
onClose={onClose}
onBound={onBound}
/>
)
}
const nameField = () => screen.getByPlaceholderText(S['docsync.newFolderPlaceholder'])
const createButton = () => screen.getByRole('button', { name: S['docsync.scope.createAction'] })
const searchField = () => screen.queryByPlaceholderText(S['docsync.scope.search'])
const rowFor = (label: string) => screen.getByText(label).closest('button') as HTMLButtonElement
beforeEach(() => {
vi.clearAllMocks()
listScopes.mockResolvedValue({ scopes: THREE })
createScope.mockResolvedValue(folder(9, 'Paris 2026'))
createLink.mockResolvedValue({ id: 1 })
})
describe('DocSyncScopeModal list', () => {
it('FE-DOCSYNC-SCOPE-001: asks the store for its folders as soon as it opens', async () => {
render(<Harness />)
await waitFor(() => expect(listScopes).toHaveBeenCalledWith(TRIP_ID, 5, undefined))
})
it('FE-DOCSYNC-SCOPE-002: shows every folder the store answered with, and where each one sits', async () => {
listScopes.mockResolvedValue({ scopes: THREE })
render(<Harness />)
expect(await screen.findByText('Trips')).toBeInTheDocument()
expect(screen.getByText('Archive')).toBeInTheDocument()
expect(screen.getByText('Receipts')).toBeInTheDocument()
expect(screen.getByText('/Documents/Archive')).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-003: says nothing about an empty archive while the list is still in flight', async () => {
const pending = deferred<{ scopes: DocSyncScope[] }>()
listScopes.mockReturnValue(pending.promise)
render(<Harness />)
// The dangerous middle state: "no folders" is a claim about the store, and
// it must not be made before the store has answered.
expect(screen.queryByText(S['docsync.noFolders'])).not.toBeInTheDocument()
expect(screen.queryByText('Trips')).not.toBeInTheDocument()
pending.resolve({ scopes: THREE })
expect(await screen.findByText('Trips')).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-004: a store with no folders at all says so', async () => {
listScopes.mockResolvedValue({ scopes: [] })
render(<Harness />)
expect(await screen.findByText(S['docsync.noFolders'])).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-005: names the store in the question it asks', async () => {
render(<Harness providerName="Paperless" />)
expect(await screen.findByText(S['docsync.scope.title'].replace('{provider}', 'Paperless'))).toBeInTheDocument()
expect(screen.getByText(S['docsync.scope.intro'])).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-006: the header close button leaves without binding anything', async () => {
render(<Harness />)
await screen.findByText('Trips')
fireEvent.click(screen.getAllByRole('button')[0])
expect(onClose).toHaveBeenCalled()
expect(createLink).not.toHaveBeenCalled()
})
})
describe('DocSyncScopeModal search', () => {
it('FE-DOCSYNC-SCOPE-007: a list short enough to read gets no search box', async () => {
listScopes.mockResolvedValue({ scopes: SIX })
render(<Harness />)
await screen.findByText('Trips')
expect(searchField()).not.toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-008: one folder past the threshold the search box appears', async () => {
listScopes.mockResolvedValue({ scopes: SEVEN })
render(<Harness />)
await screen.findByText('Warranty')
expect(searchField()).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-009: typing narrows the list without going back to the store', async () => {
listScopes.mockResolvedValue({ scopes: SEVEN })
render(<Harness />)
await screen.findByText('Warranty')
const before = listScopes.mock.calls.length
fireEvent.change(searchField()!, { target: { value: ' ARCH ' } })
expect(screen.getByText('Archive')).toBeInTheDocument()
expect(screen.queryByText('Trips')).not.toBeInTheDocument()
// Matching is case- and whitespace-insensitive and costs no round trip.
expect(listScopes.mock.calls.length).toBe(before)
})
it('FE-DOCSYNC-SCOPE-010: a query nothing matches says so, and does not claim the store is empty', async () => {
listScopes.mockResolvedValue({ scopes: SEVEN })
render(<Harness />)
await screen.findByText('Warranty')
fireEvent.change(searchField()!, { target: { value: 'zzz' } })
expect(screen.getByText(S['docsync.scope.noMatch'])).toBeInTheDocument()
expect(screen.queryByText(S['docsync.noFolders'])).not.toBeInTheDocument()
})
})
describe('DocSyncScopeModal picking an existing folder', () => {
it('FE-DOCSYNC-SCOPE-011: binds the folder that was clicked, with the defaults a new binding gets', async () => {
render(<Harness />)
await screen.findByText('Archive')
fireEvent.click(rowFor('Archive'))
await waitFor(() => expect(createLink).toHaveBeenCalledWith(TRIP_ID, {
connectionId: 5,
scopeKey: 'folder-2',
remoteRootId: '2',
remoteRootPath: '/Documents/Archive',
remoteLabel: 'Archive',
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
}))
})
it('FE-DOCSYNC-SCOPE-012: a bound folder closes the picker', async () => {
render(<Harness />)
await screen.findByText('Trips')
fireEvent.click(rowFor('Trips'))
await waitFor(() => expect(onBound).toHaveBeenCalled())
})
it('FE-DOCSYNC-SCOPE-013: a refused binding leaves the picker open so another folder can be tried', async () => {
createLink.mockRejectedValueOnce(Object.assign(new Error('nope'), {
response: { data: { error: 'forbidden' } },
}))
render(<Harness />)
await screen.findByText('Trips')
fireEvent.click(rowFor('Trips'))
await waitFor(() => expect(createLink).toHaveBeenCalled())
expect(onBound).not.toHaveBeenCalled()
expect(screen.getByText('Trips')).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-014: while one folder is being bound nothing else can be started', async () => {
const pending = deferred<{ id: number }>()
createLink.mockReturnValueOnce(pending.promise)
render(<Harness />)
await screen.findByText('Trips')
fireEvent.click(rowFor('Trips'))
expect(rowFor('Archive')).toBeDisabled()
expect(createButton()).toBeDisabled()
pending.resolve({ id: 1 })
await waitFor(() => expect(onBound).toHaveBeenCalled())
})
})
describe('DocSyncScopeModal creating a folder', () => {
it('FE-DOCSYNC-SCOPE-015: the trip name is already in the field, so creating is one click', async () => {
render(<Harness suggestedName="trek-paris-42" />)
await screen.findByText('Trips')
expect(nameField()).toHaveValue('trek-paris-42')
expect(createButton()).toBeEnabled()
})
it('FE-DOCSYNC-SCOPE-016: creating makes the folder and binds it in one go', async () => {
createScope.mockResolvedValue(folder(9, 'Paris 2026', '/Documents/Paris 2026'))
render(<Harness suggestedName="Paris 2026" />)
await screen.findByText('Trips')
fireEvent.click(createButton())
await waitFor(() => expect(createScope).toHaveBeenCalledWith(TRIP_ID, 5, 'Paris 2026'))
await waitFor(() => expect(createLink).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({
connectionId: 5,
scopeKey: 'folder-9',
remoteRootId: '9',
remoteLabel: 'Paris 2026',
})))
await waitFor(() => expect(onBound).toHaveBeenCalled())
})
it('FE-DOCSYNC-SCOPE-021: a refused create says why instead of doing nothing', async () => {
// The create had no failure path at all: the rejection escaped the click
// handler unhandled, nothing was bound, and the dialog said nothing: the
// button blinked and the person was left guessing. Every other write in the
// hook reports, and so does this one now.
createScope.mockRejectedValue({ response: { data: { error: 'quota_exceeded' } } })
render(<Harness suggestedName="Paris 2026" />)
await screen.findByText('Trips')
fireEvent.click(createButton())
await waitFor(() => expect(screen.getByText(S['docsync.error.quota_exceeded'])).toBeInTheDocument())
expect(createLink).not.toHaveBeenCalled()
expect(onBound).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-SCOPE-022: the create button comes back after a refusal', async () => {
createScope.mockRejectedValue({ response: { data: { error: 'quota_exceeded' } } })
render(<Harness suggestedName="Paris 2026" />)
await screen.findByText('Trips')
fireEvent.click(createButton())
await waitFor(() => expect(createButton()).toBeEnabled())
})
it('FE-DOCSYNC-SCOPE-017: a blank or whitespace-only name creates nothing', async () => {
render(<Harness suggestedName="" />)
await screen.findByText('Trips')
expect(createButton()).toBeDisabled()
fireEvent.change(nameField(), { target: { value: ' ' } })
expect(createButton()).toBeDisabled()
fireEvent.keyDown(nameField(), { key: 'Enter' })
expect(createScope).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-SCOPE-018: Enter in the name field creates the typed name, not the suggested one', async () => {
createScope.mockResolvedValue(folder(9, 'Belege'))
render(<Harness suggestedName="trek-paris-42" />)
await screen.findByText('Trips')
fireEvent.change(nameField(), { target: { value: ' Belege ' } })
fireEvent.keyDown(nameField(), { key: 'Enter' })
await waitFor(() => expect(createScope).toHaveBeenCalledWith(TRIP_ID, 5, 'Belege'))
})
})
describe('DocSyncScopeModal errors', () => {
it('FE-DOCSYNC-SCOPE-019: a store that refuses the credentials says so instead of showing an empty archive', async () => {
listScopes.mockResolvedValue({ scopes: [], error: 'unauthorized' })
render(<Harness />)
expect(await screen.findByText(S['docsync.error.unauthorized'])).toBeInTheDocument()
expect(screen.queryByText(S['docsync.noFolders'])).not.toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-020: an error wins over folders the store still managed to list', async () => {
listScopes.mockResolvedValue({ scopes: THREE, error: 'scope_missing' })
render(<Harness />)
expect(await screen.findByText(S['docsync.error.scope_missing'])).toBeInTheDocument()
expect(screen.queryByText('Trips')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-023: a listing request that fell over says so instead of spinning forever', async () => {
// The store answers 200 with a code when it refuses, but a request that
// never got an answer (a store behind a VPN that is down, a connection an
// admin deleted meanwhile) rejected instead, nothing caught it, and the
// spinner was all the picker ever showed.
listScopes.mockRejectedValue({ code: 'ECONNABORTED', message: 'timeout of 60000ms exceeded' })
render(<Harness />)
expect(await screen.findByText(S['docsync.error.unknown'])).toBeInTheDocument()
expect(document.querySelector('.animate-spin')).toBeNull()
expect(screen.queryByText(S['docsync.noFolders'])).not.toBeInTheDocument()
// Making a new folder is still on offer: the store may well be reachable
// for that, and it is the way out when the listing is what failed.
expect(createButton()).toBeEnabled()
})
})
@@ -0,0 +1,181 @@
import { useEffect, useMemo, useState } from 'react'
import { FolderPlus, Loader2, Search } from 'lucide-react'
import Modal from '../../shared/Modal'
import { useTranslation } from '../../../i18n/TranslationContext'
import type { DocSyncConnection, DocSyncScope, useDocSync } from './useDocSync'
/**
* Picking the container a trip lives in.
*
* The list is filtered client-side: these are a person's own folders or tags,
* so there are tens of them, not thousands, and a round trip per keystroke
* would be slower than the filter it replaces.
*
* "Create" is a first-class action rather than a fallback at the bottom,
* because for a new trip it is the common case: there is no folder yet.
*/
export default function DocSyncScopeModal({
connection,
providerName,
suggestedName,
sync,
onClose,
onBound,
}: {
connection: DocSyncConnection
providerName: string
/** Pre-filled name for a new container, from the trip's title. */
suggestedName: string
sync: ReturnType<typeof useDocSync>
onClose: () => void
onBound: () => void
}) {
const { t } = useTranslation()
const [scopes, setScopes] = useState<DocSyncScope[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [newName, setNewName] = useState(suggestedName)
const [working, setWorking] = useState<string | null>(null)
// `loadScopes`, not `sync`: the hook hands back a fresh object on every
// render of the panel above, so depending on it re-listed the provider's
// folders each time anything up there changed. The callback itself is
// stable. Taken out of `sync` first, because calling it as `sync.loadScopes`
// inside the effect makes the whole object a dependency again.
const { loadScopes } = sync
useEffect(() => {
let cancelled = false
void (async () => {
const res = await loadScopes(connection.id)
if (cancelled) return
setScopes(res.scopes)
setError(res.error ?? null)
})()
return () => { cancelled = true }
}, [connection.id, loadScopes])
const shown = useMemo(() => {
const needle = query.trim().toLowerCase()
if (!needle || !scopes) return scopes ?? []
return scopes.filter(s => s.label.toLowerCase().includes(needle))
}, [scopes, query])
const bind = async (scope: DocSyncScope) => {
setWorking(scope.scopeKey)
const ok = await sync.createLink({
connectionId: connection.id,
scopeKey: scope.scopeKey,
remoteRootId: scope.remoteRootId,
remoteRootPath: scope.remoteRootPath,
remoteLabel: scope.label,
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
})
setWorking(null)
if (ok) onBound()
}
const createAndBind = async () => {
const name = newName.trim()
if (!name) return
setWorking('__new__')
try {
// A refused create leaves nothing to bind; the hook has already put the
// reason where the dialog shows it.
const scope = await sync.createScope(connection.id, name)
if (scope) await bind(scope)
} finally {
setWorking(null)
}
}
return (
<Modal isOpen onClose={onClose} size="md" title={t('docsync.scope.title', { provider: providerName })}>
<div className="space-y-4">
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
<div className="rounded-xl border border-edge bg-surface-secondary p-3">
<span className="mb-2 block text-body font-medium text-content">{t('docsync.scope.createTitle')}</span>
<div className="flex gap-2">
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void createAndBind() }}
placeholder={t('docsync.newFolderPlaceholder')}
className="min-w-0 flex-1 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content ring-accent focus:outline-none focus:ring-2"
/>
<button
type="button"
onClick={() => void createAndBind()}
disabled={!newName.trim() || working !== null}
className="flex shrink-0 items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-body font-medium text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
>
{working === '__new__' ? <Loader2 size={14} className="animate-spin" /> : <FolderPlus size={14} />}
{t('docsync.scope.createAction')}
</button>
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-body font-medium text-content">{t('docsync.scope.pickTitle')}</span>
{scopes && scopes.length > 6 && (
<span className="relative">
<Search size={13} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('docsync.scope.search')}
className="w-44 rounded-lg border border-edge bg-surface-input py-1.5 pl-8 pr-2.5 text-caption text-content ring-accent focus:outline-none focus:ring-2"
/>
</span>
)}
</div>
{sync.error && (
<p className="mb-2 rounded-xl border border-edge bg-danger-soft px-3 py-2.5 text-caption text-danger">
{t(`docsync.error.${sync.error}`)}
</p>
)}
{scopes === null ? (
<div className="grid place-items-center rounded-xl border border-edge py-8">
<Loader2 size={18} className="animate-spin text-content-faint" />
</div>
) : error ? (
<p className="rounded-xl border border-edge bg-danger-soft px-3 py-2.5 text-caption text-danger">
{t(`docsync.error.${error}`)}
</p>
) : shown.length === 0 ? (
<p className="rounded-xl border border-dashed border-edge px-3 py-6 text-center text-caption text-content-muted">
{query ? t('docsync.scope.noMatch') : t('docsync.noFolders')}
</p>
) : (
<ul className="max-h-64 divide-y divide-edge-faint overflow-y-auto rounded-xl border border-edge">
{shown.map(s => (
<li key={s.scopeKey}>
<button
type="button"
onClick={() => void bind(s)}
disabled={working !== null}
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover disabled:opacity-50"
>
<span className="min-w-0">
<span className="block truncate text-body text-content">{s.label}</span>
{s.remoteRootPath && (
<span className="block truncate text-caption text-content-faint">{s.remoteRootPath}</span>
)}
</span>
{working === s.scopeKey && <Loader2 size={14} className="shrink-0 animate-spin text-content-faint" />}
</button>
</li>
))}
</ul>
)}
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,69 @@
import { useCallback, useEffect, useState } from 'react'
import { docsyncApi } from '../../../api/client'
import type { useDocSync } from './useDocSync'
/**
* The documents that changed in both places, and the way out of that.
*
* The server has resolved conflicts since the first version and the strings
* have been in the locales just as long, but neither shell ever listed them: a
* binding reported "1 conflict" and the only place to act on it was the MCP
* tool. So a conflict was a dead end for anyone using the app.
*
* Shared between the panel and the phone sheet because both list the same rows
* with the same three answers, and because the duplication gate counts a second
* copy of it against the PR.
*/
export interface ConflictItem {
id: number
/** The trip file's name, or the provider's when TREK never got a copy. */
name: string
remoteName: string | null
}
interface ItemRow {
id: number
file_name?: string | null
remote_name?: string | null
}
export function useConflicts(tripId: number | string, sync: ReturnType<typeof useDocSync>, open: boolean) {
const [items, setItems] = useState<ConflictItem[] | null>(null)
const [working, setWorking] = useState<number | null>(null)
const reload = useCallback(async () => {
const rows = (await docsyncApi.items(tripId, 'conflict')) as ItemRow[]
setItems(
rows.map(r => ({
id: r.id,
name: r.file_name || r.remote_name || '',
remoteName: r.remote_name ?? null,
})),
)
}, [tripId])
// Only once the list is on screen: a trip with no conflicts should not pay
// for a request to find that out, and the count above it already says so.
useEffect(() => {
if (open) void reload()
}, [open, reload])
const resolve = useCallback(
async (itemId: number, keep: 'trek' | 'provider' | 'both') => {
setWorking(itemId)
try {
await sync.resolveConflict(itemId, keep)
// The row is gone from this list either way: it was resolved, or the
// call failed and `sync.error` says so. Re-reading is what tells the
// difference, rather than removing it here and hoping.
await reload()
} finally {
setWorking(null)
}
},
[sync, reload],
)
return { items, working, resolve, reload }
}
@@ -0,0 +1,109 @@
import { useState } from 'react'
import type { DocSyncConnection, DocSyncProvider, useDocSync } from './useDocSync'
/**
* The credential form's rules, for both shells.
*
* Which field carries the address, which one must not be drawn as a text box,
* how a label becomes a translation key, what counts as filled in when a secret
* is already stored: every one of those is a rule, and every one of them was
* wrong in the phone sheet while being right on the desktop. The phone read the
* address under `baseUrl` while the server names the field `base_url`, so every
* connection it saved carried an empty address; it drew `allow_insecure_tls` as
* a text input and always sent false; and it built label keys a different way,
* so the labels rendered as raw keys.
*
* None of that is markup, so none of it belongs in a component.
*/
/** The field that holds the address rather than a credential. */
export const BASE_URL_FIELD = 'base_url'
/** Rendered as a switch beside the fields, never as one of them. */
export const INSECURE_TLS_FIELD = 'allow_insecure_tls'
export interface ConnectForm {
/** The fields to draw, in order, minus the ones with their own control. */
fields: DocSyncProvider['fields']
/** Translation key for a field's label, and for its hint where it has one. */
labelKey: (field: DocSyncProvider['fields'][number]) => string
hintKey: (field: DocSyncProvider['fields'][number]) => string | undefined
/** What to show in a field: the typed value, else what is stored. */
valueOf: (fieldKey: string) => string
/** A stored secret never comes back, so its box shows dots instead. */
placeholderOf: (field: DocSyncProvider['fields'][number]) => string
setValue: (fieldKey: string, value: string) => void
baseUrl: string
credentials: Record<string, string>
insecureTls: boolean
setInsecureTls: (on: boolean) => void
/** Every required field is either filled in or already stored. */
complete: boolean
probe: () => Promise<{ connected: boolean; account?: string; error?: string }>
save: () => Promise<boolean>
}
export function useConnectForm(
provider: DocSyncProvider,
existing: DocSyncConnection | null,
sync: ReturnType<typeof useDocSync>,
): ConnectForm {
const [values, setValues] = useState<Record<string, string>>({})
// Null until touched, so a connection that arrives after the form opened is
// still reflected. Seeding from `existing` at mount showed a stored "allow
// self-signed" as off and wrote that back on the next save.
const [insecure, setInsecure] = useState<boolean | null>(null)
const insecureTls = insecure ?? existing?.allowInsecureTls ?? false
/**
* What a box shows.
*
* A secret shows only what was typed into it this time, never what is stored:
* a stored credential must not travel back to a browser, and a value that
* ended up in `settings` by mistake must not become the one place it leaks.
* The address lives in its own column rather than in settings, so it is read
* from there; everything else comes out of settings.
*/
const secretKeys = new Set(provider.fields.filter(f => f.secret).map(f => f.field_key))
const valueOf = (key: string) => {
if (secretKeys.has(key)) return values[key] ?? ''
return values[key] ?? (key === BASE_URL_FIELD ? existing?.baseUrl : existing?.settings?.[key]) ?? ''
}
const baseUrl = valueOf(BASE_URL_FIELD)
// The address stays in here as well as in its own argument: the server walks
// the provider's fields over `credentials` and would otherwise store no
// `base_url` setting. It special-cases the key when checking completeness, so
// dropping it changes nothing it needs, but it is an established request
// shape and this is not the change to alter it in.
const credentials = Object.fromEntries(
Object.entries(values).filter(([k]) => k !== INSECURE_TLS_FIELD),
)
const fields = provider.fields.filter(f => f.field_key !== INSECURE_TLS_FIELD)
const complete = fields.every(f => {
if (!f.required) return true
if (f.secret) return (values[f.field_key] ?? '').length > 0 || !!existing?.secrets?.[f.field_key]
return valueOf(f.field_key).trim().length > 0
})
return {
fields,
// The label and hint columns store a key suffix, not text.
labelKey: f => `docsync.${f.label}`,
hintKey: f => (f.hint ? `docsync.${f.hint}` : undefined),
valueOf,
placeholderOf: f =>
f.secret && existing?.secrets?.[f.field_key] ? '••••••••' : f.placeholder || '',
setValue: (key, value) => setValues(v => ({ ...v, [key]: value })),
baseUrl,
credentials,
insecureTls,
setInsecureTls: setInsecure,
complete,
probe: () => sync.testConnection(provider.id, baseUrl, credentials, insecureTls),
save: () => sync.saveConnection(provider.id, baseUrl, credentials, insecureTls),
}
}
@@ -0,0 +1,517 @@
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-030
/**
* The document-sync hook.
*
* Everything the panel and the phone sheet know about sync comes through here,
* so the interesting behaviour is what a person sees: holdings that arrive on a
* different route than the bindings they belong to, a dialog that must not flash
* white every time a switch is flipped, a toggle that moves before the server
* has answered, and a probe that has to produce a verdict even when the request
* itself fell over.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '../../../../tests/helpers/render'
const providersApi = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const statusApi = vi.fn(async (_tripId: number | string): Promise<unknown> => ({}))
const listConnectionsApi = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const listLinksApi = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const saveConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({}))
const testConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise<unknown> => ({}))
const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise<unknown> => ({}))
const listScopesApi = vi.fn(async (_tripId: number | string, _connectionId: number, _q?: string): Promise<unknown> => ({ scopes: [] }))
const resolveApi = vi.fn(async (_tripId: number | string, _itemId: number, _keep: string): Promise<unknown> => ({ success: true }))
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
return {
...actual,
docsyncApi: {
...actual.docsyncApi,
providers: (tripId: number | string) => providersApi(tripId),
status: (tripId: number | string) => statusApi(tripId),
listConnections: (tripId: number | string) => listConnectionsApi(tripId),
listLinks: (tripId: number | string) => listLinksApi(tripId),
saveConnection: (tripId: number | string, data: unknown) => saveConnectionApi(tripId, data),
testConnection: (tripId: number | string, data: unknown) => testConnectionApi(tripId, data),
updateLink: (tripId: number | string, linkId: number, patch: unknown) => updateLinkApi(tripId, linkId, patch),
syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNowApi(tripId, linkId, full),
listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopesApi(tripId, connectionId, q),
resolve: (tripId: number | string, itemId: number, keep: string) => resolveApi(tripId, itemId, keep),
},
}
})
import { useDocSync, canManageDocSync, needsReauth, storeName, type DocSyncLink, type DocSyncProvider } from './useDocSync'
const TRIP = 3
const provider = (overrides: Record<string, unknown> = {}) => ({
id: 'nextcloud',
name: 'Nextcloud',
description: null,
icon: 'cloud',
available: true,
fields: [],
...overrides,
})
const connection = (overrides: Record<string, unknown> = {}) => ({
id: 9,
providerId: 'nextcloud',
baseUrl: 'https://cloud.example',
settings: {},
secrets: {},
allowInsecureTls: false,
lastProbeState: 'ok',
lastProbeError: null,
...overrides,
})
const link = (overrides: Record<string, unknown> = {}) => ({
id: 1,
connectionId: 9,
providerId: 'nextcloud',
scopeKey: 'trip',
remoteLabel: 'Trip documents',
remoteRootPath: '/Trips/Rome',
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'newer',
syncEnabled: true,
lastSyncAt: null,
lastSyncState: 'idle',
lastSyncError: null,
webhookUrl: null,
...overrides,
})
/** A promise this test settles by hand, to hold a request open. */
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
/** Mount the hook and record `loading` on every render it goes through. */
function mount(tripId: number | string = TRIP, enabled = true) {
const seenLoading: boolean[] = []
const view = renderHook(() => {
const hook = useDocSync(tripId, enabled)
seenLoading.push(hook.loading)
return hook
})
return { ...view, seenLoading }
}
/** Mount and wait until the first load is through. */
async function mountLoaded(tripId: number | string = TRIP) {
const view = mount(tripId)
await waitFor(() => expect(view.result.current.loading).toBe(false))
return view
}
beforeEach(() => {
vi.clearAllMocks()
providersApi.mockResolvedValue([provider()])
statusApi.mockResolvedValue({ items: {}, links: [] })
listConnectionsApi.mockResolvedValue([connection()])
listLinksApi.mockResolvedValue([link()])
saveConnectionApi.mockResolvedValue({})
testConnectionApi.mockResolvedValue({ connected: true })
updateLinkApi.mockResolvedValue({})
syncNowApi.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
listScopesApi.mockResolvedValue({ scopes: [] })
resolveApi.mockResolvedValue({ success: true })
})
describe('useDocSync initial load', () => {
it('FE-DOCSYNC-HOOK-001: joins the status route holdings onto the binding they belong to', async () => {
const holdings = { inTrek: 7, atProvider: 5, paired: 4, missing: 1 }
listLinksApi.mockResolvedValue([link()])
statusApi.mockResolvedValue({ items: { conflict: 2, pending: 1 }, links: [{ id: 1, holdings }] })
const { result } = await mountLoaded()
expect(result.current.links).toHaveLength(1)
expect(result.current.links[0].holdings).toEqual(holdings)
expect(result.current.itemCounts).toEqual({ conflict: 2, pending: 1 })
expect(result.current.connections).toEqual([connection()])
})
it('FE-DOCSYNC-HOOK-002: a binding the status route never mentions does not inherit another binding counts', async () => {
const holdings = { inTrek: 7, atProvider: 5, paired: 4, missing: 1 }
listLinksApi.mockResolvedValue([link(), link({ id: 2, remoteLabel: 'Receipts' })])
statusApi.mockResolvedValue({ items: {}, links: [{ id: 1, holdings }] })
const { result } = await mountLoaded()
expect(result.current.links[0].holdings).toEqual(holdings)
expect(result.current.links[1].holdings).toBeUndefined()
})
it('FE-DOCSYNC-HOOK-025: carries the provider switch from the status route onto its binding', async () => {
listLinksApi.mockResolvedValue([link(), link({ id: 2 }), link({ id: 3 })])
statusApi.mockResolvedValue({ items: {}, links: [{ id: 1, providerOff: true }, { id: 2, providerOff: false }] })
const { result } = await mountLoaded()
// The third is one the status route never mentions: running, not paused.
expect(result.current.links.map(l => l.providerOff)).toEqual([true, false, false])
})
it('FE-DOCSYNC-HOOK-003: a status body without an items map leaves the counts empty', async () => {
statusApi.mockResolvedValue({})
const { result } = await mountLoaded()
expect(result.current.itemCounts).toEqual({})
expect(result.current.links[0].holdings).toBeUndefined()
})
it('FE-DOCSYNC-HOOK-004: offers only the providers this instance can actually use', async () => {
providersApi.mockResolvedValue([
provider(),
provider({ id: 'gdrive', name: 'Google Drive', available: false }),
])
const { result } = await mountLoaded()
expect(result.current.providers.map(p => p.id)).toEqual(['nextcloud'])
})
it('FE-DOCSYNC-HOOK-005: a failed load leaves nothing to configure instead of blowing up', async () => {
// The addon was switched off, or this member lost access to the trip.
providersApi.mockRejectedValue(Object.assign(new Error('forbidden'), { response: { status: 403 } }))
const { result } = await mountLoaded()
expect(result.current.providers).toEqual([])
expect(result.current.links).toEqual([])
expect(result.current.error).toBeNull()
expect(result.current.loading).toBe(false)
})
it('FE-DOCSYNC-HOOK-023: a reload that finds the addon gone drops the bindings it had loaded', async () => {
statusApi.mockResolvedValue({ items: { conflict: 1 }, links: [] })
const { result } = await mountLoaded()
expect(result.current.links).toHaveLength(1)
providersApi.mockRejectedValue(Object.assign(new Error('not found'), { response: { status: 404 } }))
await act(async () => { await result.current.load() })
expect(result.current.providers).toEqual([])
expect(result.current.connections).toEqual([])
expect(result.current.links).toEqual([])
expect(result.current.itemCounts).toEqual({})
expect(result.current.error).toBeNull()
})
it('FE-DOCSYNC-HOOK-006: asks the server nothing while the panel is closed', async () => {
const { result } = mount(TRIP, false)
await act(async () => { await Promise.resolve() })
expect(providersApi).not.toHaveBeenCalled()
expect(listLinksApi).not.toHaveBeenCalled()
expect(result.current.loading).toBe(false)
})
})
describe('useDocSync spinner', () => {
it('FE-DOCSYNC-HOOK-007: shows the spinner for the first load, which has nothing to show yet', async () => {
const gate = deferred<unknown>()
listLinksApi.mockReturnValueOnce(gate.promise)
const { result, seenLoading } = mount()
expect(result.current.loading).toBe(true)
expect(seenLoading[seenLoading.length - 1]).toBe(true)
await act(async () => { gate.resolve([link()]) })
await waitFor(() => expect(result.current.loading).toBe(false))
})
it('FE-DOCSYNC-HOOK-008: never raises the spinner again while a mutation refetches', async () => {
// Regression guard: a refetch that flips `loading` swaps the whole dialog
// for a spinner and back, so flipping one switch flashes the panel white.
const { result, seenLoading } = await mountLoaded()
const gate = deferred<unknown>()
listLinksApi.mockReturnValueOnce(gate.promise)
seenLoading.length = 0
let pending!: Promise<boolean>
await act(async () => { pending = result.current.updateLink(1, { syncEnabled: false }) })
expect(result.current.loading).toBe(false)
await act(async () => {
gate.resolve([link({ syncEnabled: false })])
await pending
})
// the refetch really happened...
expect(listLinksApi).toHaveBeenCalledTimes(2)
// ...and the panel never went blank for it
expect(seenLoading).not.toContain(true)
expect(result.current.loading).toBe(false)
})
})
describe('useDocSync updateLink', () => {
it('FE-DOCSYNC-HOOK-009: moves the switch before the server has answered', async () => {
const { result } = await mountLoaded()
const gate = deferred<unknown>()
updateLinkApi.mockReturnValueOnce(gate.promise)
let pending!: Promise<boolean>
await act(async () => { pending = result.current.updateLink(1, { direction: 'pull' }) })
expect(result.current.links[0].direction).toBe('pull')
expect(updateLinkApi).toHaveBeenCalledWith(TRIP, 1, { direction: 'pull' })
await act(async () => { gate.resolve({}); await pending })
})
it('FE-DOCSYNC-HOOK-010: reloads afterwards, so the server row is what stays on screen', async () => {
const { result } = await mountLoaded()
listLinksApi.mockResolvedValue([link({ direction: 'pull', lastSyncState: 'ok', remoteLabel: 'Trip documents (moved)' })])
await act(async () => { await result.current.updateLink(1, { direction: 'pull' }) })
expect(listLinksApi).toHaveBeenCalledTimes(2)
expect(result.current.links[0].remoteLabel).toBe('Trip documents (moved)')
expect(result.current.links[0].lastSyncState).toBe('ok')
})
it('FE-DOCSYNC-HOOK-011: a rejected update does not leave the optimistic value standing', async () => {
const { result } = await mountLoaded()
updateLinkApi.mockRejectedValueOnce({ response: { data: { error: 'forbidden' } } })
listLinksApi.mockResolvedValueOnce([link({ direction: 'both' })])
let ok: boolean | undefined
await act(async () => { ok = await result.current.updateLink(1, { direction: 'pull' }) })
// Reported rather than thrown: both shells call this as `void`, so a
// rejection reached nobody. The reload puts the server's answer back and
// the reason stays on screen.
expect(ok).toBe(false)
expect(result.current.links[0].direction).toBe('both')
expect(result.current.error).toBe('forbidden')
})
})
describe('useDocSync testConnection', () => {
it('FE-DOCSYNC-HOOK-012: hands the probe verdict back to the form', async () => {
testConnectionApi.mockResolvedValue({ connected: true, account: 'owner@cloud.example' })
const { result } = await mountLoaded()
let verdict: { connected: boolean; account?: string; error?: string } | undefined
await act(async () => {
verdict = await result.current.testConnection('nextcloud', 'https://cloud.example', { token: 'abc' }, false)
})
expect(verdict).toEqual({ connected: true, account: 'owner@cloud.example' })
expect(testConnectionApi).toHaveBeenCalledWith(TRIP, {
providerId: 'nextcloud',
baseUrl: 'https://cloud.example',
credentials: { token: 'abc' },
allowInsecureTls: false,
})
})
it('FE-DOCSYNC-HOOK-013: a request that throws still produces a verdict the form can render', async () => {
testConnectionApi.mockRejectedValueOnce({ response: { data: { error: 'docsync.unreachable' } } })
const { result } = await mountLoaded()
let verdict: { connected: boolean; error?: string } | undefined
await act(async () => {
verdict = await result.current.testConnection('nextcloud', 'https://typo.example', {}, false)
})
expect(verdict).toEqual({ connected: false, error: 'docsync.unreachable' })
})
it('FE-DOCSYNC-HOOK-014: an error with no message of its own falls back to a generic one', async () => {
testConnectionApi.mockRejectedValueOnce(new Error('socket hang up'))
const { result } = await mountLoaded()
let verdict: { connected: boolean; error?: string } | undefined
await act(async () => {
verdict = await result.current.testConnection('nextcloud', 'https://cloud.example', {}, false)
})
expect(verdict).toEqual({ connected: false, error: 'unknown' })
})
it('FE-DOCSYNC-HOOK-015: marks the probe busy while it runs and clears it when it lands', async () => {
const { result } = await mountLoaded()
const gate = deferred<unknown>()
testConnectionApi.mockReturnValueOnce(gate.promise)
let pending!: Promise<unknown>
await act(async () => { pending = result.current.testConnection('nextcloud', 'https://cloud.example', {}, false) })
expect(result.current.busy).toBe('test')
await act(async () => { gate.resolve({ connected: true }); await pending })
expect(result.current.busy).toBeNull()
})
})
describe('useDocSync run and save feedback', () => {
it('FE-DOCSYNC-HOOK-016: remembers what the last run moved, per binding', async () => {
syncNowApi.mockResolvedValue({ state: 'ok', pulled: 4, pushed: 2, conflicts: 0, missing: 0 })
const { result } = await mountLoaded()
await act(async () => { await result.current.syncNow(1) })
expect(syncNowApi).toHaveBeenCalledWith(TRIP, 1, false)
// What a run moved is no longer kept: the flow bar shows standing holdings,
// which is the question somebody opening the dialog actually has. The run's
// own numbers were only ever on screen for the moment after pressing it.
expect(result.current.links).toHaveLength(1)
})
it('FE-DOCSYNC-HOOK-017: a refused run says so instead of vanishing into a void call', async () => {
// syncNow is called as `void sync.syncNow(...)` from both shells, so a
// rejection that escaped the hook reached nobody. An orphaned binding
// answers 409 here, and the person pressing the button deserves the reason.
syncNowApi.mockRejectedValueOnce({ response: { data: { error: 'link_orphaned' } } })
const { result } = await mountLoaded()
let outcome: unknown
await act(async () => { outcome = await result.current.syncNow(1) })
expect(outcome).toBeNull()
expect(result.current.error).toBe('link_orphaned')
})
it('FE-DOCSYNC-HOOK-026: a run refused because an admin switched the provider off keeps the code', async () => {
// Both shells render the error as docsync.error.<code>, so the code has to
// arrive untouched for the sentence to be the right one.
syncNowApi.mockRejectedValueOnce({ response: { status: 409, data: { error: 'provider_disabled' } } })
const { result } = await mountLoaded()
await act(async () => { await result.current.syncNow(1) })
expect(result.current.error).toBe('provider_disabled')
})
it('FE-DOCSYNC-HOOK-018: a refused save reports failure and surfaces the server reason', async () => {
saveConnectionApi.mockRejectedValueOnce({ response: { data: { message: 'docsync.badCredentials' } } })
const { result } = await mountLoaded()
let ok: boolean | undefined
await act(async () => {
ok = await result.current.saveConnection('nextcloud', 'https://cloud.example', { token: 'nope' }, false)
})
expect(ok).toBe(false)
expect(result.current.error).toBe('docsync.badCredentials')
expect(result.current.busy).toBeNull()
// nothing was refetched, because nothing changed server-side
expect(listLinksApi).toHaveBeenCalledTimes(1)
})
})
describe('useDocSync loadScopes', () => {
it('FE-DOCSYNC-HOOK-027: a request that fell over still produces a listing the picker can render', async () => {
// The picker awaits this inside an effect with nothing to catch, so a
// rejection never reached `setScopes` and the spinner ran forever. A store
// behind a VPN that is down answers this way every time.
listScopesApi.mockRejectedValueOnce({ code: 'ECONNABORTED', message: 'timeout of 60000ms exceeded' })
const { result } = await mountLoaded()
let listing: unknown
await act(async () => { listing = await result.current.loadScopes(9) })
expect(listing).toEqual({ scopes: [], error: 'unknown' })
})
it('FE-DOCSYNC-HOOK-028: a refusal with a code keeps the code, a sentence does not become a key', async () => {
const { result } = await mountLoaded()
let listing: { scopes: unknown[]; error?: string } | undefined
listScopesApi.mockRejectedValueOnce({ response: { status: 400, data: { error: 'unknown_provider' } } })
await act(async () => { listing = await result.current.loadScopes(9, 'tri') })
expect(listing?.error).toBe('unknown_provider')
expect(listScopesApi).toHaveBeenCalledWith(TRIP, 9, 'tri')
// The picker renders this as docsync.error.<value>, so prose must not
// arrive there as a translation key.
listScopesApi.mockRejectedValueOnce({ response: { status: 404, data: { message: 'Connection not found' } } })
await act(async () => { listing = await result.current.loadScopes(9) })
expect(listing?.error).toBe('unknown')
})
})
describe('useDocSync resolveConflict', () => {
it('FE-DOCSYNC-HOOK-029: a refused choice says why instead of vanishing into a void call', async () => {
// Both shells call this as `void conflicts.resolve(...)`. A member's choice
// answers 403 and a row settled elsewhere answers 400; either way the
// button came back and nothing said what happened.
resolveApi.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
const { result } = await mountLoaded()
let outcome: boolean | undefined
await act(async () => { outcome = await result.current.resolveConflict(4, 'trek') })
expect(outcome).toBe(false)
expect(result.current.error).toBe('forbidden')
// reloaded, so the counts are the server's, and the message survived it
expect(listLinksApi).toHaveBeenCalledTimes(2)
await act(async () => { outcome = await result.current.resolveConflict(4, 'provider') })
expect(outcome).toBe(true)
expect(result.current.error).toBeNull()
expect(resolveApi).toHaveBeenLastCalledWith(TRIP, 4, 'provider')
})
})
describe('needsReauth', () => {
it('FE-DOCSYNC-HOOK-030: reads a refused credential off either the state or the error code', () => {
expect(needsReauth({ lastSyncState: 'needs_reauth', lastSyncError: null })).toBe(true)
expect(needsReauth({ lastSyncState: 'failed', lastSyncError: 'unauthorized' })).toBe(true)
expect(needsReauth({ lastSyncState: 'partial', lastSyncError: 'unauthorized' })).toBe(true)
expect(needsReauth({ lastSyncState: 'failed', lastSyncError: 'unreachable' })).toBe(false)
expect(needsReauth({ lastSyncState: 'ok', lastSyncError: null })).toBe(false)
})
})
describe('storeName', () => {
it('FE-DOCSYNC-HOOK-024: prefers the provider list, then the name on the link, then the raw id', () => {
const bound = link() as DocSyncLink
const listed = [provider({ name: 'Nextcloud Hub' }) as DocSyncProvider]
expect(storeName({ ...bound, providerName: 'Nextcloud' }, listed)).toBe('Nextcloud Hub')
// Switched off by an admin, so the providers route no longer lists it.
expect(storeName({ ...bound, providerName: 'Nextcloud' }, [])).toBe('Nextcloud')
expect(storeName(bound, [])).toBe('nextcloud')
})
})
describe('canManageDocSync', () => {
it('FE-DOCSYNC-HOOK-019: the trip owner may repoint a binding', () => {
expect(canManageDocSync({ id: 4, role: 'user' }, { user_id: 4 })).toBe(true)
})
it('FE-DOCSYNC-HOOK-020: an instance admin may, even on a trip that is not theirs', () => {
expect(canManageDocSync({ id: 12, role: 'admin' }, { user_id: 4 })).toBe(true)
})
it('FE-DOCSYNC-HOOK-021: an ordinary member may not point the owner archive somewhere else', () => {
expect(canManageDocSync({ id: 12, role: 'user' }, { user_id: 4 })).toBe(false)
expect(canManageDocSync({ id: 12, role: 'user' }, null)).toBe(false)
expect(canManageDocSync({ id: 12, role: 'user' }, {})).toBe(false)
})
it('FE-DOCSYNC-HOOK-022: nobody signed in, nobody may manage', () => {
expect(canManageDocSync(null, { user_id: 4 })).toBe(false)
expect(canManageDocSync(undefined, { user_id: 4 })).toBe(false)
// ids arrive as strings on some routes and as numbers on others
expect(canManageDocSync({ id: '4', role: 'user' }, { user_id: 4 })).toBe(true)
})
})
@@ -0,0 +1,449 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { docsyncApi } from '../../../api/client'
import { useServerPing } from '../../../sync/useServerPing'
/**
* All document-sync state and actions, in one hook.
*
* Deliberately a hook rather than logic inside the panel: the phone shell is a
* separate component tree (client/src/mobile/screens/trip/...), and duplicating
* this across both would land on SonarCloud's 3% duplication budget the way the
* photo provider section already does. `useDawarichConnection` states the same
* reason for the same problem.
*/
export interface DocSyncField {
field_key: string
label: string
input_type: string
placeholder: string | null
hint: string | null
required: boolean
secret: boolean
}
export interface DocSyncProvider {
id: string
name: string
description: string | null
icon: string
available: boolean
fields: DocSyncField[]
}
export interface DocSyncConnection {
id: number
providerId: string
baseUrl: string
settings: Record<string, string>
secrets: Record<string, string>
allowInsecureTls: boolean
lastProbeState: string
lastProbeError: string | null
}
export interface DocSyncScope {
scopeKey: string
label: string
remoteRootId: string | null
remoteRootPath: string | null
}
export interface DocSyncLink {
id: number
connectionId: number
providerId: string
scopeKey: string
remoteLabel: string
remoteRootPath: string | null
direction: 'both' | 'pull' | 'push'
deletePolicy: 'unlink' | 'trash'
conflictPolicy: string
syncEnabled: boolean
lastSyncAt: string | null
lastSyncState: string
lastSyncError: string | null
webhookUrl: string | null
/**
* The store's display name as the server knows it. Carried on the link
* because the providers route only lists what an admin has switched on, and
* a binding outlives its provider being switched off.
*/
providerName?: string
/** Standing counts per side, from the status route. */
holdings?: { inTrek: number; atProvider: number; paired: number; missing: number }
/**
* An admin has switched this binding's provider off. The server leaves the
* binding as it was, so `lastSyncState` still describes the last run and
* this is what says why nothing moves now. From the status route.
*/
providerOff?: boolean
}
/**
* What to call the store a binding points at.
*
* One lookup for both shells, so the list row, the card header and the flow
* summary cannot name the same store three different ways. The providers list
* wins because the rest of the dialog uses its names; the link's own name
* covers a provider that has since been switched off.
*/
export function storeName(link: DocSyncLink, providers: readonly DocSyncProvider[]): string {
return providers.find(p => p.id === link.providerId)?.name || link.providerName || link.providerId
}
/**
* The line a binding card shows under its header, or null when there is
* nothing to say.
*
* Both shells render it, so a phone and a laptop cannot disagree about why a
* binding stands still. A provider an admin switched off comes first: the
* server leaves the binding's state as the last run wrote it, which would
* otherwise report an old failure, or nothing, for a binding that is paused.
*/
export function bindingNotice(
link: Pick<DocSyncLink, 'providerOff' | 'lastSyncState' | 'lastSyncError'>,
t: (key: string) => string,
): string | null {
if (link.providerOff) return t('docsync.error.provider_disabled')
if (link.lastSyncState === 'ok' || link.lastSyncState === 'never') return null
const state = t(`docsync.linkState.${link.lastSyncState}`)
return link.lastSyncError ? `${state} · ${t(`docsync.error.${link.lastSyncError}`)}` : state
}
/**
* Whether the store has refused the stored credential.
*
* Two shapes say so. A refusal while listing is recorded as its own state; a
* refusal anywhere else (resolving the folder, one transfer) leaves the state
* at failed or partial and puts the reason in the error code. Either way the
* cure is the same, a new credential, and both shells decide from here
* whether to offer the form for it.
*/
export function needsReauth(link: Pick<DocSyncLink, 'lastSyncState' | 'lastSyncError'>): boolean {
return link.lastSyncState === 'needs_reauth' || link.lastSyncError === 'unauthorized'
}
/**
* Whether this person may change a sync binding.
*
* Only the trip owner: the credential a binding stores usually reaches that
* person's entire document archive, so letting any member repoint it would
* share a folder the owner never chose to share. Instance admins are included
* because they already override every other permission check in the client.
*
* Shared by both shells rather than written out twice: the desktop file
* manager and the phone sheet must not be able to drift on who may do this.
*/
export function canManageDocSync(
user: { id?: number | string; role?: string } | null | undefined,
trip: { user_id?: number | string } | null | undefined,
): boolean {
if (!user) return false
return user.role === 'admin' || Number(trip?.user_id) === Number(user.id)
}
export function useDocSync(tripId: number | string, enabled: boolean) {
const [providers, setProviders] = useState<DocSyncProvider[]>([])
const [connections, setConnections] = useState<DocSyncConnection[]>([])
const [links, setLinks] = useState<DocSyncLink[]>([])
const [itemCounts, setItemCounts] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
/**
* Whether the first load is through.
*
* Every mutation refreshes, and a refresh that flips `loading` swaps the whole
* dialog for a spinner and back: a white flash on something as small as
* toggling a direction. Only the first load has nothing to show yet.
*/
const loadedOnce = useRef(false)
/**
* Which load is the current one.
*
* Four requests go out per load and several loads overlap: every mutation
* starts one, and so does every ping. Without a generation stamp the slowest
* response wins, which on a binding somebody just changed means the screen
* settles on the state from before the change.
*/
const generation = useRef(0)
/** Whether the last failure came from loading rather than from a write. */
const loadFailed = useRef(false)
const load = useCallback(async () => {
if (!enabled) return
const mine = ++generation.current
if (!loadedOnce.current) setLoading(true)
try {
const [p, c, l, s] = await Promise.all([
docsyncApi.providers(tripId),
docsyncApi.listConnections(tripId),
docsyncApi.listLinks(tripId),
docsyncApi.status(tripId),
])
if (mine !== generation.current) return
setProviders(p as DocSyncProvider[])
setConnections(c as DocSyncConnection[])
// The status route carries the holdings and the provider switch; the
// links route does not, so the two are merged here rather than asking
// every caller to join them.
const status = s as { items?: Record<string, number>; links?: DocSyncLink[] }
const statusById = new Map((status.links ?? []).map(x => [x.id, x]))
setLinks((l as DocSyncLink[]).map(x => ({
...x,
holdings: statusById.get(x.id)?.holdings,
providerOff: statusById.get(x.id)?.providerOff === true,
})))
setItemCounts(status.items || {})
// Only a load failure is cleared here. A write that just failed reloads to
// put the server's answer back on screen, and clearing unconditionally
// wiped the reason it had set a moment earlier.
setError(prev => (prev !== null && loadFailed.current ? null : prev))
loadFailed.current = false
loadedOnce.current = true
} catch (e: unknown) {
// A 403 or 404 really does mean "nothing to configure here" (the addon is
// off or this person lost access), and blanking the panel is the honest
// answer. Anything else is a failure to say so: blanking on a dropped
// connection told the user their provider list was empty.
if (mine !== generation.current) return
const status = (e as { response?: { status?: number } })?.response?.status
loadFailed.current = true
if (status === 403 || status === 404) {
// The bindings go too. Both shells keep a binding on screen with no
// provider left, so emptying the providers alone blanked nothing.
setProviders([])
setConnections([])
setLinks([])
setItemCounts({})
setError(null)
} else {
setError(readError(e))
}
} finally {
if (mine === generation.current) setLoading(false)
}
}, [tripId, enabled])
useEffect(() => { void load() }, [load])
// A run started by another member moves the same documents and the same
// counts. The event is a ping rather than a payload: the numbers it would
// carry are per-binding server state, and reading them back is the only way
// this panel and that member's panel end up saying the same thing.
useServerPing('docsync:changed', enabled, load)
const connectionFor = useCallback(
(providerId: string) => connections.find(c => c.providerId === providerId) || null,
[connections],
)
const saveConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
setBusy('save')
setError(null)
try {
await docsyncApi.saveConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls })
await load()
return true
} catch (e: unknown) {
setError(readError(e))
return false
} finally {
setBusy(null)
}
}, [tripId, load])
/**
* Probe without saving. Always resolves to a verdict object: the route
* answers 200 even for an unreachable instance, because a form showing a typo
* needs a field to render, not an exception.
*/
const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
setBusy('test')
try {
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as {
connected: boolean; account?: string; error?: string; detail?: string
}
} catch (e: unknown) {
return { connected: false, error: readError(e) }
} finally {
setBusy(null)
}
}, [tripId])
/**
* The containers a connection can be bound to.
*
* Always resolves to a listing, the way `testConnection` always resolves to
* a verdict: the route answers 200 with an error code when the store refuses,
* and a request that fell over on the way (a store behind a VPN that is not
* up, a connection an admin deleted meanwhile) has to reach the picker the
* same way. Left to reject, it never reached `setScopes`, and the picker
* spun forever with nothing said.
*/
const loadScopes = useCallback(async (connectionId: number, q?: string) => {
try {
return await docsyncApi.listScopes(tripId, connectionId, q) as { scopes: DocSyncScope[]; error?: string }
} catch (e: unknown) {
return { scopes: [] as DocSyncScope[], error: readError(e) }
}
}, [tripId])
/**
* Make a container at the provider.
*
* Reports failure the same way every other write here does. A rejected
* create used to escape as an unhandled rejection, so the button blinked and
* nothing happened and nothing was said. Null rather than a throw, because
* the caller's next step is to bind what came back, and there is nothing to
* bind.
*/
const createScope = useCallback(async (connectionId: number, name: string): Promise<DocSyncScope | null> => {
setBusy('scope')
setError(null)
try {
return await docsyncApi.createScope(tripId, connectionId, name) as DocSyncScope
} catch (e: unknown) {
setError(readError(e))
return null
} finally {
setBusy(null)
}
}, [tripId])
const createLink = useCallback(async (payload: Record<string, unknown>) => {
setBusy('link')
setError(null)
try {
await docsyncApi.createLink(tripId, payload)
await load()
return true
} catch (e: unknown) {
setError(readError(e))
return false
} finally {
setBusy(null)
}
}, [tripId, load])
/**
* Change one binding, optimistically.
*
* The reload in `finally` puts the server's answer back either way, so a
* refused change corrects itself on screen, but it used to do that in
* silence, with the rejection escaping into a `void` call site. The switch
* simply flipped back and nobody said why.
*/
const updateLink = useCallback(async (linkId: number, patch: Record<string, unknown>) => {
setLinks(prev => prev.map(l => (l.id === linkId ? { ...l, ...patch } as DocSyncLink : l)))
setError(null)
try {
await docsyncApi.updateLink(tripId, linkId, patch)
await load()
return true
} catch (e: unknown) {
// Reload first, so the optimistic value is corrected, then say why.
// The other way round the reload cleared the message again.
await load()
setError(readError(e))
return false
}
}, [tripId, load])
const removeLink = useCallback(async (linkId: number) => {
setBusy('link')
setError(null)
try {
await docsyncApi.deleteLink(tripId, linkId)
await load()
return true
} catch (e: unknown) {
setError(readError(e))
return false
} finally {
setBusy(null)
}
}, [tripId, load])
const syncNow = useCallback(async (linkId: number, full = false) => {
setBusy(`sync-${linkId}`)
setError(null)
try {
const res = await docsyncApi.syncNow(tripId, linkId, full) as {
state: string; pulled: number; pushed: number; conflicts: number; missing: number
}
await load()
return res
} catch (e: unknown) {
// A refused run is a thing the person asked for and did not get (an
// orphaned binding answers 409 here), so it has to be said out loud
// rather than escaping into the `void` at the call site. After the
// reload, which would otherwise clear the message.
await load()
setError(readError(e))
return null
} finally {
setBusy(null)
}
}, [tripId, load])
/**
* Settle one conflict.
*
* Reports failure the way the other writes do. The rejection used to escape
* into the `void` at the call site, so a refused choice (the row was settled
* elsewhere a moment ago, or the server answered 403) put the button back
* and left the row standing with nothing said.
*/
const resolveConflict = useCallback(async (itemId: number, keep: 'trek' | 'provider' | 'both') => {
setError(null)
try {
await docsyncApi.resolve(tripId, itemId, keep)
await load()
return true
} catch (e: unknown) {
// Reload first, so the counts match what the server has, then say why.
// The other way round the reload cleared the message again.
await load()
setError(readError(e))
return false
}
}, [tripId, load])
/** Providers the admin switched on that also have a working adapter. */
const usableProviders = useMemo(() => providers.filter(p => p.available), [providers])
return {
providers: usableProviders,
connections, links, itemCounts,
loading, busy, error,
connectionFor, load,
saveConnection, testConnection,
loadScopes, createScope,
createLink, updateLink, removeLink, syncNow, resolveConflict,
}
}
/**
* The error code a failed call carries, or `unknown`.
*
* Both shells render this as `docsync.error.<code>`, so anything that is not
* one of the codes reaches the screen as a raw translation key, and the server
* answers plenty of routes with a sentence ("Link not found") rather than a
* code. A general message is worse than a specific one and far better than
* `docsync.error.Link not found`.
*/
function readError(e: unknown): string {
const res = (e as { response?: { data?: { error?: string; message?: string } } })?.response
const raw = res?.data?.error || res?.data?.message || ''
// A code passes through: the sync codes and the handful of keys the routes
// answer with are all of this shape. A sentence does not: several routes
// answer with prose ("Link not found"), and both shells render this as
// `docsync.error.<value>`, so that reached the screen as a raw translation
// key. A general message is worse than a specific one and far better than
// `docsync.error.Link not found`.
return /^[a-z0-9_.]+$/i.test(raw) ? raw : 'unknown'
}
@@ -0,0 +1,190 @@
// FE-DOCSYNC-OFFER-001 to FE-DOCSYNC-OFFER-013
/**
* Whether the Files screen offers document sync.
*
* The providers ship switched off, so the rule is what keeps an upgraded
* instance from growing a button into an empty dialog on every trip: a bound
* trip shows it to everybody, an unbound one only to somebody who could bind
* it, and only once the admin has switched a provider on.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { act, renderHook, waitFor } from '../../../../tests/helpers/render'
import { server } from '../../../../tests/helpers/msw/server'
import { useDocSyncOfferStore } from '../../../store/docSyncOfferStore'
import { setForcedOffline } from '../../../sync/networkMode'
import { useDocSyncOffered } from './useDocSyncOffered'
const listeners = new Set<(e: Record<string, unknown>) => void>()
vi.mock('../../../api/websocket', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../api/websocket')>()),
addListener: (fn: (e: Record<string, unknown>) => void) => { listeners.add(fn) },
removeListener: (fn: (e: Record<string, unknown>) => void) => { listeners.delete(fn) },
}))
/** Deliver an event the way the websocket layer would. */
function emit(type: string): void {
for (const fn of [...listeners]) fn({ type })
}
const TRIP = 4
let providerCalls = 0
let linkCalls = 0
function routes({ providers = [] as unknown[], links = [] as unknown[], linksStatus = 200 } = {}) {
server.use(
http.get('/api/trips/:tripId/docsync/providers', () => {
providerCalls += 1
return HttpResponse.json(providers)
}),
http.get('/api/trips/:tripId/docsync/links', () => {
linkCalls += 1
return linksStatus === 200
? HttpResponse.json(links)
: HttpResponse.json({ error: 'nope' }, { status: linksStatus })
}),
)
}
const paperless = { id: 'paperless', name: 'Paperless-ngx', available: true }
const binding = { id: 1, providerId: 'paperless' }
beforeEach(() => {
providerCalls = 0
linkCalls = 0
listeners.clear()
useDocSyncOfferStore.setState({ bound: {}, providers: null })
})
afterEach(() => setForcedOffline(false))
describe('useDocSyncOffered', () => {
it('FE-DOCSYNC-OFFER-001: somebody who may bind the trip sees it once a provider is on, without asking about bindings', async () => {
routes({ providers: [paperless] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, true))
await waitFor(() => expect(result.current).toBe(true))
expect(providerCalls).toBe(1)
expect(linkCalls).toBe(0)
})
it('FE-DOCSYNC-OFFER-002: with every provider off and nothing bound, nobody sees it', async () => {
routes({ providers: [] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, true))
await waitFor(() => expect(linkCalls).toBe(1))
expect(result.current).toBe(false)
})
it('FE-DOCSYNC-OFFER-003: a provider row without an adapter does not count as offered', async () => {
routes({ providers: [{ ...paperless, available: false }] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, true))
await waitFor(() => expect(linkCalls).toBe(1))
expect(result.current).toBe(false)
})
it('FE-DOCSYNC-OFFER-004: a member sees a bound trip, and is never asked about providers', async () => {
routes({ providers: [paperless], links: [binding] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, false))
await waitFor(() => expect(result.current).toBe(true))
expect(providerCalls).toBe(0)
})
it('FE-DOCSYNC-OFFER-005: a member does not see an unbound trip, providers or not', async () => {
routes({ providers: [paperless], links: [] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, false))
await waitFor(() => expect(linkCalls).toBe(1))
expect(result.current).toBe(false)
})
it('FE-DOCSYNC-OFFER-006: a bound trip keeps the button after the admin switched every provider off', async () => {
routes({ providers: [], links: [binding] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, true))
await waitFor(() => expect(result.current).toBe(true))
})
it('FE-DOCSYNC-OFFER-007: the last answer renders at once on the next visit, then is checked again', async () => {
routes({ links: [binding] })
const first = renderHook(() => useDocSyncOffered(TRIP, false))
await waitFor(() => expect(first.result.current).toBe(true))
first.unmount()
const second = renderHook(() => useDocSyncOffered(TRIP, false))
expect(second.result.current).toBe(true)
await waitFor(() => expect(linkCalls).toBe(2))
})
it('FE-DOCSYNC-OFFER-008: offline it asks nothing and keeps what it knew, and asks again once back', async () => {
routes({ links: [] })
useDocSyncOfferStore.setState({ bound: { [TRIP]: true } })
act(() => setForcedOffline(true))
const { result } = renderHook(() => useDocSyncOffered(TRIP, false))
expect(result.current).toBe(true)
expect(linkCalls).toBe(0)
act(() => setForcedOffline(false))
await waitFor(() => expect(result.current).toBe(false))
expect(linkCalls).toBe(1)
})
it('FE-DOCSYNC-OFFER-009: a request that never got an answer leaves the last one standing', async () => {
server.use(http.get('/api/trips/:tripId/docsync/links', () => HttpResponse.error()))
useDocSyncOfferStore.setState({ bound: { [TRIP]: true } })
const { result } = renderHook(() => useDocSyncOffered(TRIP, false))
await act(() => useDocSyncOfferStore.getState().refresh(TRIP, false))
expect(result.current).toBe(true)
})
it('FE-DOCSYNC-OFFER-010: the Documents addon switched off is a real no', async () => {
routes({ linksStatus: 403 })
useDocSyncOfferStore.setState({ bound: { [TRIP]: true } })
const { result } = renderHook(() => useDocSyncOffered(TRIP, false))
await waitFor(() => expect(result.current).toBe(false))
})
it('FE-DOCSYNC-OFFER-011: a refresh asked for while offline sends nothing and changes nothing', async () => {
routes({ providers: [paperless] })
useDocSyncOfferStore.setState({ providers: false })
setForcedOffline(true)
await useDocSyncOfferStore.getState().refresh(TRIP, true)
expect(providerCalls).toBe(0)
expect(useDocSyncOfferStore.getState().providers).toBe(false)
})
it('FE-DOCSYNC-OFFER-012: a member on the Files tab gets the button when the owner binds the trip', async () => {
routes({ links: [] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, false))
await waitFor(() => expect(linkCalls).toBe(1))
expect(result.current).toBe(false)
// The owner binds it: nothing remounts here, the server only pings.
routes({ links: [binding] })
act(() => emit('docsync:changed'))
await waitFor(() => expect(result.current).toBe(true))
expect(linkCalls).toBe(2)
})
it('FE-DOCSYNC-OFFER-013: the button goes when the last binding does, with every provider off', async () => {
routes({ providers: [], links: [binding] })
const { result } = renderHook(() => useDocSyncOffered(TRIP, true))
await waitFor(() => expect(result.current).toBe(true))
routes({ providers: [], links: [] })
act(() => emit('docsync:changed'))
await waitFor(() => expect(result.current).toBe(false))
})
})
@@ -0,0 +1,34 @@
import { useEffect } from 'react'
import { useNetworkMode } from '../../../hooks/useNetworkMode'
import { useDocSyncOfferStore } from '../../../store/docSyncOfferStore'
import { useServerPing } from '../../../sync/useServerPing'
/**
* Whether this trip's Files screen shows the "Document sync" button.
*
* One hook for both shells, so the toolbar and the phone header cannot drift
* on who sees it. The last known answer renders straight away; the server is
* asked again when the screen mounts and when the connection comes back, and
* never while offline, where the button keeps whatever it last knew.
*
* It is also asked on every `docsync:changed`, which the server sends when a
* binding comes or goes. A member can sit on the Files tab while the owner
* binds the trip, and on desktop that tab may never remount before a reload.
*/
export function useDocSyncOffered(tripId: number | string, canManage: boolean): boolean {
const offered = useDocSyncOfferStore(
s => s.bound[String(tripId)] === true || (canManage && s.providers === true),
)
const refresh = useDocSyncOfferStore(s => s.refresh)
const { offline } = useNetworkMode()
useEffect(() => {
if (!offline) void refresh(tripId, canManage)
}, [tripId, canManage, offline, refresh])
// Off while offline: the effect above already asks on the way back, and the
// ping's own return-to-online refetch would only ask a second time.
useServerPing('docsync:changed', !offline, () => refresh(tripId, canManage))
return offered
}
@@ -6,6 +6,9 @@ import { filesApi } from '../../api/client'
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { canManageDocSync } from './docsync/useDocSync'
import { useDocSyncOffered } from './docsync/useDocSyncOffered'
import { getAuthUrl } from '../../api/authUrl'
import { isImage, isMedia, isWalletPass } from './FileManager.helpers'
import { openFile as openFileInTab } from '../../utils/fileDownload'
@@ -30,6 +33,7 @@ export interface FileManagerProps {
*/
export function useFileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
const [uploading, setUploading] = useState(false)
const [showDocSync, setShowDocSync] = useState(false)
const [filterType, setFilterType] = useState('all')
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
const [showTrash, setShowTrash] = useState(false)
@@ -38,6 +42,9 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
const toast = useToast()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const currentUser = useAuthStore((s) => s.user)
const canManageSync = canManageDocSync(currentUser, trip)
const docSyncOffered = useDocSyncOffered(tripId, canManageSync)
const { t, locale } = useTranslation()
const loadTrash = useCallback(async () => {
@@ -210,6 +217,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
previewFile, setPreviewFile, previewFileUrl, assignFileId, setAssignFileId,
getRootProps, getInputProps, isDragActive, handlePaste, filteredFiles, handleDelete,
handleAssign, mediaFiles, openFile,
showDocSync, setShowDocSync, canManageSync, docSyncOffered,
}
}
@@ -1,6 +1,6 @@
// FE-JRN-PICKER-001 to FE-JRN-PICKER-021
// FE-JRN-PICKER-001 to FE-JRN-PICKER-023
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, afterAll, beforeAll, beforeEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render'
@@ -54,6 +54,16 @@ function mountPicker(props: Partial<React.ComponentProps<typeof ProviderPicker>>
return { ...utils, onClose, onAdd }
}
// The day headings follow the reader's wall clock now, so they would follow the
// runner's zone too. Nothing in the vitest config pins one, so this file does.
const runnerTimeZone = process.env.TZ
beforeAll(() => { process.env.TZ = 'UTC' })
afterAll(() => {
if (runnerTimeZone === undefined) delete process.env.TZ
else process.env.TZ = runnerTimeZone
})
beforeEach(() => {
searchReturns([asset('a1')])
})
@@ -103,7 +113,9 @@ describe('ProviderPicker', () => {
await user.click(screen.getByRole('button', { name: /All Photos/ }))
await waitFor(() => expect(ranges).toHaveLength(2))
expect(ranges[1]).toEqual({ from: '', to: '', page: 1, size: 50 })
// utc_offset_minutes rides along on every search: the server cannot tell
// which 24 hours a date-only bound means without it (#2336).
expect(ranges[1]).toEqual({ from: '', to: '', page: 1, size: 50, utc_offset_minutes: 0 })
})
it('FE-JRN-PICKER-006: the album tab loads albums and reports when there are none', async () => {
@@ -286,6 +298,55 @@ describe('ProviderPicker', () => {
}
})
it('FE-JRN-PICKER-022: says which zone the searched day is meant in', async () => {
const ranges: Record<string, unknown>[] = []
server.use(http.post('/api/integrations/memories/immich/search', async ({ request }) => {
ranges.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ assets: [asset('a1')], hasMore: false })
}))
process.env.TZ = 'Australia/Sydney'
try {
mountPicker({ initialDate: '2026-03-15' })
await waitFor(() => expect(ranges).toHaveLength(1))
} finally {
process.env.TZ = 'UTC'
}
// +11 on 15 March, read at local noon of the day being searched rather than
// at "now" — Sydney is +10 for half the year, so a day picked out of the
// other half would otherwise be an hour off.
expect(ranges[0]).toMatchObject({ from: '2026-03-15', to: '2026-03-15', utc_offset_minutes: 660 })
})
it('FE-JRN-PICKER-023: keeps paging when a page holds no photos of the searched day', async () => {
// The local-day filter can empty a whole page (50 shots taken the next
// morning). The sentinel used to live inside the grid, so an empty page
// mounted none and the picker sat on "no photos" forever (#2336).
const pages: number[] = []
server.use(http.post('/api/integrations/memories/immich/search', async ({ request }) => {
const body = await request.json() as { page: number }
pages.push(body.page)
return HttpResponse.json({ assets: body.page < 2 ? [] : [asset('a1')], hasMore: body.page < 2 })
}))
class ImmediateObserver {
constructor(private cb: IntersectionObserverCallback) {}
observe() { this.cb([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver) }
unobserve() {}
disconnect() {}
}
const original = globalThis.IntersectionObserver
globalThis.IntersectionObserver = ImmediateObserver as unknown as typeof IntersectionObserver
try {
mountPicker()
await waitFor(() => expect(pages).toEqual([1, 2]))
expect(await screen.findAllByAltText('')).toHaveLength(1)
// And the empty first page never claimed there were none to find.
expect(screen.queryByText('No photos yet')).not.toBeInTheDocument()
} finally {
globalThis.IntersectionObserver = original
}
})
it('FE-JRN-PICKER-018: the embedded variant drops the header, the add-to bar and the date captions', async () => {
mountPicker({ embedded: true, initialDate: '2026-03-15' })
@@ -5,6 +5,7 @@ import { useTranslation } from '../../i18n';
import {
groupPhotosByDate,
sortProviderPhotos,
utcOffsetMinutesForDay,
type GeoPoint,
} from '../../pages/journeyDetail/JourneyDetailPage.helpers';
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore';
@@ -94,7 +95,17 @@ export function ProviderPicker({
setSearchTo(to);
setSearchPage(page);
try {
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal);
// The bounds are calendar days as this browser reads them, so the zone they
// are meant in travels with them. Without it the server can only take them
// as UTC days and a reader outside UTC gets somebody else's 24 hours
// (#2336). A single offset leaves a one-hour sliver at the far end of a
// multi-day range that crosses a DST change, and none at all for a single
// day, which is the case this fixes.
const data = await memoriesApi.search(
provider,
{ from, to, page, size: 50, utc_offset_minutes: utcOffsetMinutesForDay(from || to) },
signal,
);
const assets = data.assets || [];
setPhotos((prev) => (append ? [...prev, ...assets] : assets));
setHasMore(!!data.hasMore);
@@ -162,6 +173,9 @@ export function ProviderPicker({
[photos, contextLocation?.lat, contextLocation?.lng],
);
// Albums come back whole, so they never page.
const morePagesPending = hasMore && !selectedAlbum;
const toggleAsset = (id: string) => {
setSelected((prev) => {
const next = new Map(prev);
@@ -474,82 +488,91 @@ export function ProviderPicker({
<div className="flex justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-900" />
</div>
) : sortedPhotos.length === 0 ? (
<div className="py-12 text-center">
<p className="text-[13px] text-zinc-500">
{filter === 'trip' && !tripRange.from
? t('journey.trips.noTripsLinkedSettings')
: t('journey.detail.noPhotos')}
</p>
</div>
) : (
<div>
{groupPhotosByDate(sortedPhotos).map((group) => (
<div key={group.date}>
{(!embedded || filter !== 'day') && (
<p className="mt-4 mb-2 text-[11px] font-medium text-zinc-500 first:mt-0 dark:text-zinc-400">
{group.label}
</p>
)}
<div className="mb-1 grid grid-cols-3 gap-1.5 sm:grid-cols-4 md:grid-cols-5">
{group.assets.map((asset: any) => {
const isSelected = selected.has(asset.id);
const alreadyAdded = existingAssetIds.has(asset.id);
return (
<button
type="button"
key={asset.id}
disabled={alreadyAdded}
aria-pressed={isSelected}
aria-label={asset.city || group.label}
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
className={`relative block aspect-square w-full overflow-hidden rounded-lg ${
alreadyAdded
? 'cursor-not-allowed opacity-40'
: isSelected
? 'cursor-pointer ring-2 ring-zinc-900 ring-offset-2 dark:ring-white dark:ring-offset-zinc-900'
: 'cursor-pointer'
}`}
>
<img
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
alt=""
className="h-full w-full object-cover"
loading="lazy"
onError={(e) => {
const img = e.currentTarget;
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`;
if (!img.src.includes('/original')) img.src = original;
}}
/>
{alreadyAdded && (
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-500 text-white">
<Check size={12} />
</div>
)}
{isSelected && !alreadyAdded && (
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-900 text-white dark:bg-white dark:text-zinc-900">
<Check size={12} />
</div>
)}
{asset.mediaType === 'video' && (
<div className="pointer-events-none absolute bottom-1.5 left-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-black/60">
<Play size={8} className="ml-px text-white" fill="currentColor" />
</div>
)}
{asset.city && (
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/50 to-transparent p-1">
<p className="truncate text-[8px] text-white">{asset.city}</p>
</div>
)}
</button>
);
})}
</div>
{sortedPhotos.length === 0 ? (
<div className="py-12 text-center">
<p className="text-[13px] text-zinc-500">
{filter === 'trip' && !tripRange.from
? t('journey.trips.noTripsLinkedSettings')
: morePagesPending
? t('common.loading')
: t('journey.detail.noPhotos')}
</p>
</div>
))}
{/* Infinite scroll trigger */}
{hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
) : (
<div>
{groupPhotosByDate(sortedPhotos).map((group) => (
<div key={group.date}>
{(!embedded || filter !== 'day') && (
<p className="mt-4 mb-2 text-[11px] font-medium text-zinc-500 first:mt-0 dark:text-zinc-400">
{group.label}
</p>
)}
<div className="mb-1 grid grid-cols-3 gap-1.5 sm:grid-cols-4 md:grid-cols-5">
{group.assets.map((asset: any) => {
const isSelected = selected.has(asset.id);
const alreadyAdded = existingAssetIds.has(asset.id);
return (
<button
type="button"
key={asset.id}
disabled={alreadyAdded}
aria-pressed={isSelected}
aria-label={asset.city || group.label}
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
className={`relative block aspect-square w-full overflow-hidden rounded-lg ${
alreadyAdded
? 'cursor-not-allowed opacity-40'
: isSelected
? 'cursor-pointer ring-2 ring-zinc-900 ring-offset-2 dark:ring-white dark:ring-offset-zinc-900'
: 'cursor-pointer'
}`}
>
<img
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
alt=""
className="h-full w-full object-cover"
loading="lazy"
onError={(e) => {
const img = e.currentTarget;
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`;
if (!img.src.includes('/original')) img.src = original;
}}
/>
{alreadyAdded && (
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-500 text-white">
<Check size={12} />
</div>
)}
{isSelected && !alreadyAdded && (
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-900 text-white dark:bg-white dark:text-zinc-900">
<Check size={12} />
</div>
)}
{asset.mediaType === 'video' && (
<div className="pointer-events-none absolute bottom-1.5 left-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-black/60">
<Play size={8} className="ml-px text-white" fill="currentColor" />
</div>
)}
{asset.city && (
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/50 to-transparent p-1">
<p className="truncate text-[8px] text-white">{asset.city}</p>
</div>
)}
</button>
);
})}
</div>
</div>
))}
</div>
)}
{/* Outside the empty/grid choice on purpose: a page whose photos all
belong to a neighbouring day leaves the grid empty, and a sentinel
that only exists next to photos would never ask for the next page
(#2336). */}
{morePagesPending && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
</div>
)}
</div>
+121 -4
View File
@@ -56,6 +56,37 @@ vi.mock('../../hooks/useGeolocation', () => ({
// after the map already rendered.
const thumbCallbacks = vi.hoisted(() => new Map<string, (thumb: string) => void>())
// The cluster group and the Leaflet markers MapView reaches for when a selection lands
// on a stop that shares its coordinates and so has no pin of its own. `stacked` decides
// whether the group answers with a bubble or with the marker itself.
const clusterMock = vi.hoisted(() => {
const spiderfy = vi.fn()
return {
spiderfy,
stacked: false,
asked: [] as unknown[],
markers: new Map<Element, object>(),
/**
* One marker double per pin, not per coordinate. Stops that share a coordinate are
* the whole point here, and a registry keyed by place id only proves anything when
* the two markers on that coordinate can be told apart.
*/
stub(node: Element) {
const existing = clusterMock.markers.get(node)
if (existing) return existing
const fresh = { pin: node }
clusterMock.markers.set(node, fresh)
return fresh
},
group: {
getVisibleParent(marker: unknown) {
clusterMock.asked.push(marker)
return clusterMock.stacked ? { spiderfy } : marker
},
},
}
})
vi.mock('react-leaflet', () => ({
// center/zoom are surfaced so tests can assert the camera the map is built
// with; maxZoom because a cluster refuses to attach to a map without one.
@@ -63,9 +94,16 @@ vi.mock('react-leaflet', () => ({
<div data-testid="map-container" data-center={JSON.stringify(center)} data-zoom={zoom} data-maxzoom={maxZoom}>{children}</div>
),
TileLayer: () => <div data-testid="tile-layer" />,
Marker: ({ children, eventHandlers, position, icon, zIndexOffset }: any) => (
Marker: ({ children, eventHandlers, position, icon, zIndexOffset, ref }: any) => (
<div
ref={node => { if (node && zIndexOffset === 500) eventHandlers?.add?.({ target: { getElement: () => node } }) }}
ref={node => {
if (node && zIndexOffset === 500) eventHandlers?.add?.({ target: { getElement: () => node } })
// Stands in for the L.Marker react-leaflet hands back, so MapView's registry has
// something to look a selected stop up by. Only the markers the cluster group
// holds get one: a place pin is all this double stands in for, and the route-via
// markers outside the group expect a Leaflet marker of their own.
ref?.(node?.closest('[data-testid="cluster-group"]') ? clusterMock.stub(node) : null)
}}
data-testid="marker"
data-lat={position[0]}
data-lng={position[1]}
@@ -118,8 +156,8 @@ vi.mock('react-leaflet', () => ({
vi.mock('react-leaflet-cluster', () => ({
// The real cluster group calls iconCreateFunction itself; the probe button
// lets a test invoke it with a chosen child count.
default: ({ children, iconCreateFunction }: any) => (
<div data-testid="cluster-group">
default: ({ children, iconCreateFunction, ref }: any) => (
<div data-testid="cluster-group" ref={node => { ref?.(node ? clusterMock.group : null) }}>
<button
data-testid="cluster-icon-probe"
onClick={(e: any) => {
@@ -174,6 +212,9 @@ const ORIGINAL_WIDTH = window.innerWidth
afterEach(() => {
vi.clearAllMocks()
resetAllStores()
clusterMock.markers.clear()
clusterMock.asked.length = 0
clusterMock.stacked = false
mapMock.panes.clear()
thumbCallbacks.clear()
geoMock.position = null
@@ -1180,6 +1221,31 @@ describe('MapView photo thumbnails', () => {
expect(thumbCallbacks.size).toBe(0)
expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-080: an uploaded photo fills its marker whatever its proportions', () => {
// Leaflet's marker pane sets `width: auto` on every img in it, so a photo sized
// by attributes was drawn at its full pixel size and the circle only ever showed
// the transparent corner of it, over the category colour.
render(<MapView places={[buildMapPlace({ id: 27, lat: 48, lng: 2, image_url: '/uploads/places/wide.jpg' })]} />)
const holder = document.createElement('div')
holder.innerHTML = iconHtmlOf(screen.getAllByTestId('marker')[0])
const img = holder.querySelector('img')!
expect(img.getAttribute('src')).toBe('/uploads/places/wide.jpg')
expect(img.style.width).toBe('100%')
expect(img.style.height).toBe('100%')
})
it('FE-COMP-MAPVIEW-081: taking the upload off a place asks for its auto photo again, without a reload', () => {
const place = buildMapPlace({ id: 28, lat: 48, lng: 2, google_place_id: 'gp-28', name: 'Tower', image_url: '/uploads/places/own.jpg' })
const { rerender } = render(<MapView places={[place]} />)
expect(vi.mocked(photoService.fetchPhoto)).not.toHaveBeenCalled()
rerender(<MapView places={[{ ...place, image_url: null }]} />)
expect(vi.mocked(photoService.fetchPhoto)).toHaveBeenCalledWith('gp-28', 'gp-28', 48, 2, 'Tower')
expect(thumbCallbacks.has('gp-28')).toBe(true)
})
})
// The marker HTML is a hand-built string handed to L.divIcon, i.e. innerHTML.
@@ -1217,3 +1283,54 @@ describe('MapView — untrusted values in the marker HTML', () => {
expect(iconHtml()).toContain('#00ff00')
})
})
// Stops modelling one building — drop the bags, check in, the museum inside it — all
// carry the same coordinates, so only the top pin of the pile is ever on screen. The
// places rail used to raise the chosen one with a z-index; inside a bubble there is no
// pin to raise, so the bubble has to open instead (#2344).
describe('MapView — picking a stop that shares its coordinates', () => {
const hotel = { lat: 48.8584, lng: 2.2945 }
const stacked = [
buildMapPlace({ id: 1, name: 'Drop the bags', ...hotel }),
buildMapPlace({ id: 2, name: 'Check in', ...hotel }),
buildMapPlace({ id: 3, name: 'Louvre', lat: 48.8606, lng: 2.3376 }),
]
/** The place pins, in the order the places were handed over. */
const pins = () => screen.getAllByTestId('marker').filter(node => node.closest('[data-testid="cluster-group"]'))
const markerOf = (index: number) => clusterMock.markers.get(pins()[index])
it('FE-COMP-MAPVIEW-077: the bubble it is hiding in is fanned open', () => {
clusterMock.stacked = true
const { rerender } = render(<MapView places={stacked} />)
expect(clusterMock.spiderfy).not.toHaveBeenCalled()
rerender(<MapView places={stacked} selectedPlaceId={2} />)
expect(clusterMock.spiderfy).toHaveBeenCalled()
// And it asked about the stop that was picked — not about the one stacked under it,
// which carries the same coordinates, and not about whichever registered first.
expect(clusterMock.asked).toContain(markerOf(1))
expect(clusterMock.asked).not.toContain(markerOf(0))
expect(clusterMock.asked).not.toContain(markerOf(2))
})
it('FE-COMP-MAPVIEW-079: a stop in another pile is looked up by its own id', () => {
clusterMock.stacked = true
const { rerender } = render(<MapView places={stacked} />)
rerender(<MapView places={stacked} selectedPlaceId={3} />)
expect(clusterMock.asked).toContain(markerOf(2))
expect(clusterMock.asked).not.toContain(markerOf(0))
expect(clusterMock.asked).not.toContain(markerOf(1))
})
it('FE-COMP-MAPVIEW-078: a stop with a pin of its own is left alone, camera included', () => {
clusterMock.stacked = false
const { rerender } = render(<MapView places={stacked} />)
rerender(<MapView places={stacked} selectedPlaceId={1} />)
expect(clusterMock.spiderfy).not.toHaveBeenCalled()
// The selection still only pans: the zoom belongs to the day fit.
expect(mapMock.panTo).toHaveBeenCalled()
expect(mapMock.setView).not.toHaveBeenCalled()
})
})
+52 -9
View File
@@ -4,7 +4,7 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { CLUSTER_OPTIONS, createClusterIcon } from './markerCluster'
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import HazardLayers from './HazardLayers'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
@@ -195,7 +195,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
box-shadow:${shadow};
overflow:hidden;background:${bgColor};
">
<img src="${escapeHtml(place.image_url)}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
${markerPhotoHtml(place.image_url)}
</div>
${badgeHtml}
</div>`,
@@ -543,7 +543,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
// Module-level photo cache shared with PlaceAvatar
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } from './placePhoto'
import { useAuthStore } from '../../store/authStore'
import { useGeolocation } from '../../hooks/useGeolocation'
import LocationButton from './LocationButton'
@@ -629,16 +629,25 @@ interface MemoMarkerProps {
onHoverOut: () => void
/** Off in read-only trips and on the phone, where HTML5 drag does not exist. */
draggable: boolean
/** Hands the Leaflet marker up, so a selection can find the bubble it is hiding in. */
onRegister: (id: number, marker: unknown) => void
}
const MemoMarker = memo(function MemoMarker({
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut, draggable,
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut, draggable, onRegister,
}: MemoMarkerProps) {
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected)
const cleanupRef = useRef<(() => void) | null>(null)
// react-leaflet compares `position` by reference and calls setLatLng whenever it
// differs, and the cluster group answers a moved child by taking it out and putting
// it back — which collapses an open fan. A fresh array literal here did that on
// every render the marker took part in.
const position = useMemo<[number, number]>(() => [place.lat, place.lng], [place.lat, place.lng])
const register = useCallback((marker: unknown) => { onRegister(place.id, marker) }, [onRegister, place.id])
return (
<Marker
position={[place.lat, place.lng]}
ref={register}
position={position}
icon={icon}
eventHandlers={{
// The element only exists once Leaflet has put the marker on the map,
@@ -838,7 +847,7 @@ export const MapView = memo(function MapView({
const pendingThumbsRef = useRef<Record<string, string>>({})
const thumbRafRef = useRef<number | null>(null)
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
const photoSources = useMemo(() => photoSourcesKey(places), [places])
// Flattened [lat,lng] points of the selected day's route, so the bounds fit can
// include the full polyline once it has been computed.
const routeCoords = useMemo<[number, number][]>(() => (route || []).flat() as [number, number][], [route])
@@ -895,13 +904,46 @@ export const MapView = memo(function MapView({
thumbRafRef.current = null
}
}
}, [placeIds, placesPhotosEnabled])
}, [photoSources, placesPhotosEnabled])
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
// Drag a marker onto a day (#891). Pointer-driven, so it is off wherever
// HTML5 drag does not exist — and the day plan is not on screen there anyway.
const markersDraggable = !isTouchDevice
/**
* Reaching a stop that shares its coordinates with others.
*
* Such a stop has no pin of its own while the stack is collapsed — it is inside a
* cluster bubble — so the z-index that used to lift the selected pin out of the pile
* has nothing to lift. Fan the bubble open instead, which leaves the camera alone
* (#2344). The registry is what lets a selection find its Leaflet marker at all.
*/
const clusterGroupRef = useRef<ClusterGroupLike | null>(null)
const placeMarkersRef = useRef(new Map<number, unknown>())
const registerClusterGroup = useCallback((group: unknown) => {
clusterGroupRef.current = (group as ClusterGroupLike | null) ?? null
}, [])
const registerMarker = useCallback((id: number, marker: unknown) => {
if (marker) placeMarkersRef.current.set(id, marker)
else placeMarkersRef.current.delete(id)
}, [])
useEffect(() => {
if (selectedPlaceId == null) return
let frame: number | null = null
const reveal = () => {
frame = null
revealInCluster(clusterGroupRef.current, placeMarkersRef.current.get(selectedPlaceId))
}
reveal()
// The group takes its markers in on a microtask and adds them in chunks, so a
// selection that arrives with the map — a link straight into a day — finds nothing
// on the first pass. A second attempt one frame later does.
if (typeof requestAnimationFrame === 'function') frame = requestAnimationFrame(reveal)
return () => { if (frame !== null) cancelAnimationFrame(frame) }
}, [selectedPlaceId, places])
const markers = useMemo(() => places.map((place) => {
const isSelected = place.id === selectedPlaceId
const pck = photoCacheKey(place)
@@ -919,9 +961,10 @@ export const MapView = memo(function MapView({
onHover={handleMarkerHover}
onHoverOut={handleMarkerHoverOut}
draggable={markersDraggable}
onRegister={registerMarker}
/>
)
}), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut, markersDraggable])
}), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut, markersDraggable, registerMarker])
// Parsing track geometry is the expensive part (tracks run to tens of thousands
// of points), so it hangs off `places` alone — a selection change must not
@@ -1088,7 +1131,7 @@ export const MapView = memo(function MapView({
<PoiDropTarget onPoiDropOnRoute={onPoiDropOnRoute} />
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
<MarkerClusterGroup {...CLUSTER_OPTIONS} iconCreateFunction={createClusterIcon}>
<MarkerClusterGroup ref={registerClusterGroup} {...CLUSTER_OPTIONS} iconCreateFunction={createClusterIcon}>
{markers}
</MarkerClusterGroup>
+271 -2
View File
@@ -870,6 +870,17 @@ describe('MapViewGL', () => {
properties: Record<string, unknown>
geometry: { type: string; coordinates: number[] | number[][] | number[][][] }
}
// A projection for the tests that care how far apart two pins land on screen. The map
// double projects 10 px per degree, an overview so coarse that a whole city sits under
// one pin; a real map is around 1500 px per degree at the zoom a day is fitted to.
// Setting an implementation at all is also what puts project() back: an earlier test
// pins it to a single point, and clearAllMocks keeps an implementation once set.
const PX_PER_DEGREE = 1500
const projectAtDayZoom = () =>
glMap.project.mockImplementation((lngLat: [number, number]) => (
{ x: lngLat[0] * PX_PER_DEGREE, y: lngLat[1] * PX_PER_DEGREE }
))
const geoSource = () => ({ setData: vi.fn((_data: unknown) => {}) })
function lastData(src: ReturnType<typeof geoSource>): { features: GeoFeature[] } {
const calls = vi.mocked(src.setData).mock.calls
@@ -1534,6 +1545,7 @@ describe('MapViewGL', () => {
it('FE-COMP-MAPVIEWGL-047: only the unclustered leaves of the cluster source get a rich marker (#1385)', async () => {
loadOnAttach()
projectAtDayZoom()
const clusterSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-place-clusters' ? clusterSource : null))
glMap.querySourceFeatures.mockReturnValue([
@@ -1565,6 +1577,102 @@ describe('MapViewGL', () => {
expect(drawn).not.toContainEqual([2.3, 48.3])
})
// One building modelled as several stops — drop the bags, check in, the museum inside
// it — puts every pin on the same spot, and all but the top one are unreachable. There
// is no spiderfy on a GL map, so the stack draws as the one pin worth seeing (#2344).
it('FE-COMP-MAPVIEWGL-077: stops on one coordinate draw one pin, and picking a buried one makes it that pin', async () => {
loadOnAttach()
projectAtDayZoom()
const clusterSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-place-clusters' ? clusterSource : null))
glMap.querySourceFeatures.mockReturnValue([
{ properties: { placeId: 71 } },
{ properties: { placeId: 72 } },
{ properties: { placeId: 73 } },
])
const hotel = { lat: 48.8584, lng: 2.2945 }
const places = [
buildMapPlace({ id: 71, name: 'Drop the bags', ...hotel }),
buildMapPlace({ id: 72, name: 'Check in', ...hotel }),
buildMapPlace({ id: 73, name: 'Louvre', lat: 60.1, lng: 9.3 }),
]
// The order badge is what tells the two stops on the hotel apart on screen.
const dayOrderMap = { 71: [1], 72: [2], 73: [3] }
const pinsOnTheHotel = () => glMarkers.created.filter(marker => marker.lngLat?.[0] === hotel.lng)
const { rerender } = render(<MapViewGL places={places} fitKey={1} dayOrderMap={dayOrderMap} />)
await flushFrames()
expect(pinsOnTheHotel()).toHaveLength(1)
expect(pinsOnTheHotel()[0].element.innerHTML).toContain('>1</span>')
// The stop elsewhere is untouched by the folding.
expect(glMarkers.created.map(m => m.lngLat)).toContainEqual([9.3, 60.1])
glMarkers.clear()
rerender(<MapViewGL places={places} fitKey={1} dayOrderMap={dayOrderMap} selectedPlaceId={72} />)
await flushFrames()
expect(pinsOnTheHotel()).toHaveLength(1)
expect(pinsOnTheHotel()[0].element.innerHTML).toContain('>2</span>')
})
// Two ordinary neighbours — a hotel and the restaurant across the street — are not a
// pile. Nothing here can fan a bubble open again, so anything the fold reaches beyond
// the pins that cover each other is a stop the map simply loses (#2344).
it('FE-COMP-MAPVIEWGL-078: stops a few hundred metres apart keep a pin each', async () => {
loadOnAttach()
projectAtDayZoom()
const clusterSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-place-clusters' ? clusterSource : null))
glMap.querySourceFeatures.mockReturnValue([
{ properties: { placeId: 81 } },
{ properties: { placeId: 82 } },
])
// ~300 m apart at this latitude, which the projection above puts 6 px apart: well
// inside the width of a pin, and still two stops a user has to be able to reach.
const places = [
buildMapPlace({ id: 81, name: 'Hotel Le Marais', lat: 48.8584, lng: 2.2945 }),
buildMapPlace({ id: 82, name: 'Chez Julien', lat: 48.8584, lng: 2.2986 }),
]
render(<MapViewGL places={places} fitKey={1} />)
await flushFrames()
const drawn = glMarkers.created.map(m => m.lngLat)
expect(drawn).toContainEqual([2.2945, 48.8584])
expect(drawn).toContainEqual([2.2986, 48.8584])
})
// Without a projection there is no telling which pins land on each other, and a guess
// costs a stop its marker for good. Draw them all instead, as the map always did.
it('FE-COMP-MAPVIEWGL-079: a map that cannot place its pins still draws every one of them', async () => {
loadOnAttach()
const clusterSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-place-clusters' ? clusterSource : null))
glMap.querySourceFeatures.mockReturnValue([
{ properties: { placeId: 91 } },
{ properties: { placeId: 92 } },
])
const places = [
buildMapPlace({ id: 91, lat: 48.1, lng: 2.1 }),
buildMapPlace({ id: 92, lat: 48.2, lng: 2.2 }),
]
const projection = glMap.project
const engine = glMap as unknown as { project?: unknown }
try {
engine.project = undefined
render(<MapViewGL places={places} fitKey={1} />)
await flushFrames()
} finally {
engine.project = projection
}
const drawn = glMarkers.created.map(m => m.lngLat)
expect(drawn).toContainEqual([2.1, 48.1])
expect(drawn).toContainEqual([2.2, 48.2])
})
it('FE-COMP-MAPVIEWGL-048: the day route becomes one LineString per drawn segment', async () => {
const routeSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-route' ? routeSource : null))
@@ -1735,7 +1843,7 @@ describe('MapViewGL', () => {
expect(srcs()).toContain('data:image/png;base64,BBB')
})
it('FE-COMP-MAPVIEWGL-056: an in-flight photo is not requested twice and the proxy url is used as the id', async () => {
it('FE-COMP-MAPVIEWGL-056: an in-flight photo is not requested twice and a picked proxy url is both id and cache key', async () => {
vi.mocked(photoService.isLoading).mockImplementation((key: string) => key === 'osm-loading')
const places = [
buildMapPlace({ id: 94, lat: 48.4, lng: 2.4, osm_id: 'osm-loading' }),
@@ -1746,7 +1854,56 @@ describe('MapViewGL', () => {
await act(async () => {})
expect(photoService.fetchPhoto).toHaveBeenCalledTimes(1)
expect(photoService.fetchPhoto).toHaveBeenCalledWith('osm-5', '/api/maps/place-photo/abc', 48.5, 2.5, 'Museum')
expect(photoService.fetchPhoto).toHaveBeenCalledWith('/api/maps/place-photo/abc', '/api/maps/place-photo/abc', 48.5, 2.5, 'Museum')
})
it('FE-COMP-MAPVIEWGL-101: an uploaded photo fills its marker whatever its proportions', async () => {
// A phone photo is no 48px square like the thumbs. Sized by attributes it lost to
// the stylesheet's `height: auto` and sat as a sliver in a disc of category colour.
loadOnAttach()
render(<MapViewGL places={[buildMapPlace({ id: 14, lat: 48.14, lng: 2.14, image_url: '/uploads/places/wide.jpg' })]} fitKey={1} />)
await act(async () => {})
const img = glMarkers.created[0].element.querySelector('img')!
expect(img.getAttribute('src')).toBe('/uploads/places/wide.jpg')
expect(img.style.width).toBe('100%')
expect(img.style.height).toBe('100%')
})
it('FE-COMP-MAPVIEWGL-102: taking the upload off a place asks for its auto photo again, without a reload', async () => {
const place = buildMapPlace({ id: 15, lat: 48.15, lng: 2.15, google_place_id: 'gp-15', name: 'Tower', image_url: '/uploads/places/own.jpg' })
const { rerender } = render(<MapViewGL places={[place]} fitKey={1} />)
await act(async () => {})
expect(photoService.fetchPhoto).not.toHaveBeenCalled()
rerender(<MapViewGL places={[{ ...place, image_url: null }]} fitKey={1} />)
await act(async () => {})
expect(photoService.fetchPhoto).toHaveBeenCalledWith('gp-15', 'gp-15', 48.15, 2.15, 'Tower')
})
it('FE-COMP-MAPVIEWGL-103: picking another suggested photo replaces the thumb of the old one', async () => {
loadOnAttach()
const first = '/api/maps/place-photo/gp-16~p0/bytes'
const second = '/api/maps/place-photo/gp-16~p1/bytes'
// Everything seen before the new pick has a thumb: the place's auto photo under
// its provider id and the first pick under its own url.
vi.mocked(photoService.getCached).mockImplementation((key: string) => (
key === 'gp-16' || key === first
? ({ thumbDataUrl: 'data:image/png;base64,OLD' } as unknown as ReturnType<typeof photoService.getCached>)
: undefined
))
const place = buildMapPlace({ id: 16, lat: 48.16, lng: 2.16, google_place_id: 'gp-16', name: 'Tower', image_url: first })
const { rerender } = render(<MapViewGL places={[place]} fitKey={1} />)
await flushFrames()
rerender(<MapViewGL places={[{ ...place, image_url: second }]} fitKey={1} />)
await flushFrames()
expect(photoService.fetchPhoto).toHaveBeenCalledWith(second, second, 48.16, 2.16, 'Tower')
// Until its thumb is ready the marker shows the new pick itself, not the old thumb.
const latest = glMarkers.created[glMarkers.created.length - 1]
expect(latest.element.querySelector('img')?.getAttribute('src')).toBe(second)
})
it('FE-COMP-MAPVIEWGL-066: a place with neither provider id nor coordinates has no cache key and is skipped', async () => {
@@ -2008,6 +2165,118 @@ describe('MapViewGL', () => {
// in the wrong place.
expect(layer.firstElementChild).not.toBe(handle)
})
// Both GL libraries take their clicks from the canvas container, and the handle sits
// inside it, right on the route's hit band. Stopping pointerdown does not stop the click
// that follows, so a click on a handle also read as a click on the road under it and
// dropped a second via exactly there.
it('FE-COMP-MAPVIEWGL-VIA-003: a click on a via handle is the handle\'s, not the road\'s', async () => {
loadOnAttach()
glCanvasContainer.replaceChildren()
glMap.project.mockReturnValue({ x: 100, y: 80 })
glMap.getLayer.mockImplementation((id: string) => (id === 'trip-route-hit' ? { id } : null))
const onRouteClick = vi.fn()
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
render(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
roadtripVias={{ 1: [stored] }}
onMoveVia={() => {}}
onRouteClick={onRouteClick}
/>,
)
await flushFrames()
const layer = glCanvasContainer.firstElementChild as HTMLElement
const handle = layer.firstElementChild as HTMLElement
const reachedMap = vi.fn()
glCanvasContainer.addEventListener('click', reachedMap)
try {
act(() => { handle.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(reachedMap).not.toHaveBeenCalled()
} finally {
glCanvasContainer.removeEventListener('click', reachedMap)
}
// And the road's own handler leaves a click that came through the handle alone.
const roadClick = layerHandler('click', 'trip-route-hit')
expect(roadClick).toBeTypeOf('function')
act(() => { roadClick({ lngLat: { lat: 48.1, lng: 2.1 }, point: { x: 100, y: 80 }, originalEvent: { target: handle } }) })
expect(onRouteClick).not.toHaveBeenCalled()
act(() => { roadClick({ lngLat: { lat: 48.1, lng: 2.1 }, point: { x: 100, y: 80 }, originalEvent: { target: glCanvasContainer } }) })
expect(onRouteClick).toHaveBeenCalledWith(48.1, 2.1)
})
it('FE-COMP-MAPVIEWGL-VIA-004: a right-click on a via handle removes it and opens nothing underneath', async () => {
loadOnAttach()
glCanvasContainer.replaceChildren()
glMap.project.mockReturnValue({ x: 100, y: 80 })
const onRemoveVia = vi.fn()
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
render(
<MapViewGL
places={[]}
fitKey={1}
glProvider="maplibre-gl"
roadtripVias={{ 1: [stored] }}
onMoveVia={() => {}}
onRemoveVia={onRemoveVia}
/>,
)
await flushFrames()
const layer = glCanvasContainer.firstElementChild as HTMLElement
const handle = layer.firstElementChild as HTMLElement
// The map's contextmenu is the add-place gesture; it must not fire for the spot the
// via has just been removed from.
const reachedMap = vi.fn()
glCanvasContainer.addEventListener('contextmenu', reachedMap)
try {
act(() => { handle.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })) })
} finally {
glCanvasContainer.removeEventListener('contextmenu', reachedMap)
}
expect(onRemoveVia).toHaveBeenCalledWith(1, 5)
expect(reachedMap).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEWGL-VIA-005: the handles go away below the zoom a via can be aimed at', async () => {
loadOnAttach()
glCanvasContainer.replaceChildren()
glMap.project.mockReturnValue({ x: 100, y: 80 })
// Country scale: a day's vias collapse into a heap of dots over one town, and a
// short drag on one of them moves the route by kilometres.
vi.mocked(glMap.getZoom).mockReturnValue(8)
try {
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
render(
<MapViewGL places={[]} fitKey={1} glProvider="maplibre-gl" roadtripVias={{ 1: [stored] }} onMoveVia={() => {}} />,
)
await flushFrames()
const layer = glCanvasContainer.firstElementChild as HTMLElement
const handle = layer.firstElementChild as HTMLElement
expect(handle.style.display).toBe('none')
// Back in close enough, the zoom that settles brings them back.
vi.mocked(glMap.getZoom).mockReturnValue(12)
act(() => {
glMap.on.mock.calls
.filter(c => c[0] === 'zoomend' && typeof c[1] === 'function')
.forEach(c => (c[1] as () => void)())
})
expect(handle.style.display).toBe('block')
} finally {
vi.mocked(glMap.getZoom).mockReturnValue(10)
}
})
// ── Satellite ───────────────────────────────────────────────────────────────
//
// Leaflet has had the imagery for a while and swaps its whole tile layer for it.
+59 -12
View File
@@ -12,7 +12,7 @@ import { useTranslation } from '../../i18n/TranslationContext'
import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer } from './MapLayerSwitcher'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } from './placePhoto'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
@@ -20,7 +20,6 @@ import { ReservationMapboxOverlay } from './reservationsMapbox'
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { escapeHtml } from '@trek/shared'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
@@ -28,6 +27,7 @@ import type { Day, Place, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover'
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import { groupCoincidentPlaces } from './coincidentPlaces'
import type { RoadtripHazard } from '@trek/shared'
import { useHazardLayerGL } from './useHazardLayerGL'
import { useDawarichTrailGL } from './useDawarichTrailGL'
@@ -230,7 +230,15 @@ interface Props {
onMapReady?: (map: any | null) => void
}
/** How eagerly place markers merge into a cluster. */
/**
* How eagerly place markers merge into a cluster.
*
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
* kept as plain numbers rather than shared constants so the GL bundle does not have to
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
* own, and the stops that share a coordinate are folded together at the pin level
* instead — see the reconcile below.
*/
const CLUSTER_RADIUS = 20
const CLUSTER_MAX_ZOOM = 8
@@ -406,7 +414,7 @@ function createMarkerElement(place: Place & { category_color?: string; category_
overflow:hidden;background:${bgColor};
box-sizing:content-box;
">
<img src="${escapeHtml(photoUrl)}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
${markerPhotoHtml(photoUrl)}
</div>
${badgeHtml}
`
@@ -837,15 +845,17 @@ export function MapViewGL({
const VIA_MIN_ZOOM = 9
const applyViaZoom = () => {
const on = map.getZoom() >= VIA_MIN_ZOOM
for (const pin of viaPinsRef.current) {
const node = (pin as { getElement?: () => HTMLElement | null }).getElement?.()
if (node) node.style.display = on ? '' : 'none'
}
// `el` is the one handle both pin paths expose; the library marker's own accessor
// does not exist on the hand-positioned pin, so reading that left every handle on.
for (const pin of viaPinsRef.current) pin.el.style.display = on ? 'block' : 'none'
}
for (const via of vias) {
const el = document.createElement('span')
el.style.cssText = 'display:block;width:12px;height:12px;border-radius:9999px;background:#0a84ff;border:2.5px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;touch-action:none'
// Marks the handle for the map's own click handlers: the drive's hit band runs
// right under it, and a click that lands here is the handle's, not the road's.
el.dataset.viaHandle = ''
const pin = attachPin(map, gl, pinLayerRef.current, el, via.lng, via.lat)
viaPinsRef.current.push(pin)
if (!viasDraggable) continue
@@ -910,6 +920,10 @@ export function MapViewGL({
}
const onContext = (e: MouseEvent) => {
e.preventDefault()
// Stopped here as well: both GL libraries listen on the canvas container the
// handle sits in, and the map's own contextmenu opens the add-place form at
// the very spot the via has just been removed from.
e.stopPropagation()
viaHandlersRef.current.onRemoveVia?.(via.day_id, via.id)
}
el.addEventListener('pointerdown', onDown)
@@ -920,6 +934,10 @@ export function MapViewGL({
el.addEventListener('mousedown', swallow)
el.addEventListener('touchstart', swallow, { passive: true })
el.addEventListener('dblclick', swallow)
// Stopping pointerdown does not stop the click that follows it, and the handle sits
// on the route's hit band: the map's click landed on the road and dropped a second
// via exactly under the one that was clicked.
el.addEventListener('click', swallow)
viaCleanupRef.current.push(() => {
el.removeEventListener('pointerdown', onDown)
el.removeEventListener('pointermove', onMove)
@@ -929,6 +947,7 @@ export function MapViewGL({
el.removeEventListener('mousedown', swallow)
el.removeEventListener('touchstart', swallow)
el.removeEventListener('dblclick', swallow)
el.removeEventListener('click', swallow)
})
}
@@ -1064,8 +1083,19 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady || !onRouteClick) return
const onClick = (e: { lngLat: { lat: number; lng: number }; point?: { x: number; y: number }; preventDefault?: () => void }) => {
const onClick = (e: {
lngLat: { lat: number; lng: number }
point?: { x: number; y: number }
originalEvent?: { target?: EventTarget | null }
preventDefault?: () => void
}) => {
e.preventDefault?.()
// A layer handler only asks what is drawn under the point, not what element the
// click came through. A via handle sits on the band by definition, and a click on
// it must not read as a click on the road under it; the map-level handler below
// filters markers the same way.
const target = e.originalEvent?.target
if (target instanceof Element && target.closest('[data-via-handle], .mapboxgl-marker, .maplibregl-marker')) return
// Layer handlers are independent: MapLibre evaluates each registration
// against the same click, so a point that hits both the offered-route band
// and the current-route band fires both. Every alternative starts and ends
@@ -1720,7 +1750,7 @@ export function MapViewGL({
// simultaneous thumb arrivals into one re-render.
const pendingThumbsRef = useRef<Record<string, string>>({})
const thumbRafRef = useRef<number | null>(null)
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
const photoSources = useMemo(() => photoSourcesKey(places), [places])
useEffect(() => {
if (!places || places.length === 0 || !placesPhotosEnabled) return
const cleanups: (() => void)[] = []
@@ -1771,7 +1801,7 @@ export function MapViewGL({
thumbRafRef.current = null
}
}
}, [placeIds, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps
}, [photoSources, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps
// Reconcile markers with places + photos. The clustered GeoJSON source decides
// which points are currently unclustered, and we render the existing rich HTML
@@ -1873,7 +1903,24 @@ export function MapViewGL({
seen.add(id)
visiblePlaces.push(place)
}
reconcileMarkers(visiblePlaces)
// Without a projection there is no way to tell which pins land on each other, so
// nothing is folded and every stop keeps the pin it has always had.
if (typeof map.project !== 'function') {
reconcileMarkers(visiblePlaces)
return
}
// Above the cluster cutoff supercluster reports every point unclustered, so stops
// on one coordinate would each get a pin and all but one of them would be buried
// (#2344). There is no spiderfy on a GL map to fan them out, so the pile draws as
// the one pin that is wanted: the selected stop when it is in there, which is what
// makes picking it from the places rail visible at all. Only pins that sit on each
// other fold — this side cannot undo a fold, hence its own tight radius.
const stacks = groupCoincidentPlaces(
visiblePlaces,
place => map.project([place.lng, place.lat]),
selectedPlaceId,
)
reconcileMarkers(stacks.map(stack => stack.lead))
}
const scheduleReconcile = () => {
if (raf !== null) return
@@ -87,4 +87,39 @@ describe('TripRouteOverviewPanel', () => {
const { container } = render(<TripRouteOverviewPanel overview={overview({ days: [], totalDistance: 0 })} unit="metric" />)
expect(container).toBeEmptyDOMElement()
})
it('FE-MAP-TROU-009: says when legs could not be routed, and marks the day that is short', () => {
// A refused leg stays a straight line and adds nothing to the sum, so a total shown
// without a word about it reads as the whole trip when it is not.
const days = overview().days
const short = overview({
days: [{ ...days[0], unroutedLegs: 1 }, { ...days[1], unroutedLegs: 0 }],
unroutedLegs: 1,
})
render(<TripRouteOverviewPanel overview={short} unit="metric" />)
expect(screen.getByTestId('trip-overview-unrouted')).toHaveTextContent('map.overview.unrouted')
expect(screen.getAllByRole('img', { name: 'map.overview.dayUnrouted' })).toHaveLength(1)
})
it('FE-MAP-TROU-010: does not call a leg unrouted while the round is still running', () => {
// Every leg still waiting is unrouted too; the ellipsis already says the number grows.
const days = overview().days
const running = overview({
days: [{ ...days[0], unroutedLegs: 1 }, { ...days[1], unroutedLegs: 1 }],
unroutedLegs: 2,
loading: true,
})
render(<TripRouteOverviewPanel overview={running} unit="metric" />)
expect(screen.queryByTestId('trip-overview-unrouted')).toBeNull()
expect(screen.queryByRole('img', { name: 'map.overview.dayUnrouted' })).toBeNull()
})
it('FE-MAP-TROU-011: a fully routed trip carries no warning at all', () => {
render(<TripRouteOverviewPanel overview={overview({ unroutedLegs: 0 })} unit="metric" />)
expect(screen.queryByTestId('trip-overview-unrouted')).toBeNull()
expect(screen.queryByRole('img', { name: 'map.overview.dayUnrouted' })).toBeNull()
})
})
@@ -1,4 +1,4 @@
import { Route } from 'lucide-react'
import { AlertTriangle, Route } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { profileIcon } from '../Planner/DayPlanSidebarRouteConnector'
@@ -66,6 +66,9 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
}) {
const { t } = useTranslation()
if (!overview.days.length) return null
// Only once the round is over: while it runs every leg still waiting is unrouted too,
// and the ellipsis on the total already says the number is growing.
const unrouted = overview.loading ? 0 : (overview.unroutedLegs ?? 0)
return (
<div
@@ -95,9 +98,23 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
{overview.loading && <span className="text-content-muted" style={{ fontWeight: 400 }}>{' '}…</span>}
</span>
</div>
{/* A leg the router refused stays a straight line and adds nothing to the sum, so
the total is short by however much road those legs are. Said in words rather than
left to the reader to notice, because the number is what a fuel estimate starts from. */}
{unrouted > 0 && (
<div
data-testid="trip-overview-unrouted"
className="text-warning"
style={{ display: 'flex', alignItems: 'flex-start', gap: 6, padding: '0 14px 8px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}
>
<AlertTriangle size={12} style={{ flexShrink: 0, marginTop: 1 }} aria-hidden />
<span>{t('map.overview.unrouted', { count: unrouted })}</span>
</div>
)}
<div style={{ maxHeight: 220, overflowY: 'auto', borderTop: '1px solid var(--border-primary)' }}>
{overview.days.map(day => {
const label = day.title || t('dayplan.dayN', { n: day.dayNumber })
const dayUnrouted = overview.loading ? 0 : (day.unroutedLegs ?? 0)
const row = (
<>
<span style={{ width: 8, height: 8, borderRadius: 999, background: day.color.line, flexShrink: 0 }} aria-hidden />
@@ -110,6 +127,17 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
return <Icon key={mode} size={12} strokeWidth={2} aria-hidden />
})}
</span>
{/* The day's own share of the shortfall, so the reader knows which figure
to distrust rather than only that one of them is off. */}
{dayUnrouted > 0 && (
<AlertTriangle
size={12}
className="text-warning"
style={{ flexShrink: 0 }}
role="img"
aria-label={t('map.overview.dayUnrouted', { count: dayUnrouted })}
/>
)}
<span className="text-content-muted" style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
{formatDistance(day.distance / 1000, unit)}
</span>
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { COINCIDENT_RADIUS_PX, groupCoincidentPlaces, STACK_RADIUS_PX } from './coincidentPlaces'
interface Stop { id: number; x: number; y: number }
const stop = (id: number, x: number, y = 0): Stop => ({ id, x, y })
const project = (item: Stop) => ({ x: item.x, y: item.y })
describe('stops that share a spot on screen', () => {
it('COINCIDENT-001: stops on one coordinate become a single group', () => {
const groups = groupCoincidentPlaces([stop(1, 40), stop(2, 40), stop(3, 40)], project)
expect(groups).toHaveLength(1)
expect(groups[0].members.map(member => member.id)).toEqual([1, 2, 3])
expect(groups[0].lead.id).toBe(1)
})
it('COINCIDENT-002: the fold reaches no further than the pins that cover each other', () => {
const apart = groupCoincidentPlaces([stop(1, 0), stop(2, COINCIDENT_RADIUS_PX)], project)
expect(apart.map(group => group.members.length)).toEqual([1, 1])
// Inside the radius the lower pin has nothing showing to click at anyway.
const touching = groupCoincidentPlaces([stop(1, 0), stop(2, COINCIDENT_RADIUS_PX - 0.5)], project)
expect(touching.map(group => group.members.length)).toEqual([2])
// A pin's width apart is two ordinary neighbouring stops — a hotel and the
// restaurant across the street — and both of them keep a pin, since the renderer
// this grouping serves has no bubble to put the second one in (#2344).
const neighbours = groupCoincidentPlaces([stop(1, 0), stop(2, STACK_RADIUS_PX)], project)
expect(neighbours.map(group => group.members.length)).toEqual([1, 1])
})
it('COINCIDENT-003: the same stops group the same way whatever order they arrive in', () => {
const stops = [stop(3, 100), stop(1, 0), stop(2, 1), stop(1, 0)]
const groups = groupCoincidentPlaces(stops, project)
expect(groups.map(group => group.members.map(member => member.id))).toEqual([[1, 2], [3]])
expect(groupCoincidentPlaces([...stops].reverse(), project)).toEqual(groups)
})
it('COINCIDENT-004: a stop the map cannot place keeps a group of its own rather than vanishing', () => {
const missing = { id: 9, x: Number.NaN, y: 0 }
const groups = groupCoincidentPlaces([stop(1, 0), missing, stop(2, 400)], item => (
item.id === 9 ? null : project(item)
))
expect(groups.map(group => group.members.map(member => member.id))).toEqual([[1], [2], [9]])
expect(groupCoincidentPlaces([missing], project).map(group => group.lead)).toEqual([missing])
// And two of them stay apart: an item nothing could place cannot swallow another.
const unplaceable = groupCoincidentPlaces([stop(1, 0), stop(2, 0)], () => null)
expect(unplaceable.map(group => group.members.map(member => member.id))).toEqual([[1], [2]])
})
it('COINCIDENT-005: the selected stop is the one drawn for its stack', () => {
const stops = [stop(1, 40), stop(2, 40), stop(3, 400)]
expect(groupCoincidentPlaces(stops, project, 2)[0].lead.id).toBe(2)
// A selection from another stack, or none at all, leaves the first member leading.
expect(groupCoincidentPlaces(stops, project, 3)[0].lead.id).toBe(1)
expect(groupCoincidentPlaces(stops, project, null)[0].lead.id).toBe(1)
})
})
@@ -0,0 +1,79 @@
/**
* Stops that land on the same spot on screen.
*
* A trip often models one building as several stops — drop the bags, check in, the
* museum inside the hotel, the tour — and every one of them carries the same
* coordinates. No zoom level can pull those apart, so each renderer draws the pile as a
* single thing instead. This is the grouping the GL side does that with, kept clear of
* Leaflet and of the GL SDKs so it can be reasoned about without a map.
*
* The two renderers fold at deliberately different distances, because a fold costs them
* different things. Leaflet folds into a cluster bubble that carries a count and fans
* open on click, so a stop inside one is still reachable and the generous
* `STACK_RADIUS_PX` is a straight improvement on two pins overlapping. MapLibre and
* Mapbox have no spiderfy: a folded-away stop there has no marker at all, and with it no
* hover card, no click target, no drag onto a day and no order badge. So the GL side
* folds at `COINCIDENT_RADIUS_PX`, which reaches no further than pins that are
* effectively the same point — where nothing was reachable before the fold either. The
* two numbers are not meant to be brought in line: a fold is only safe where the
* renderer can undo it.
*/
/**
* How close two pins have to be before Leaflet stacks them into a cluster.
*
* A place pin is 36 px across (44 while selected), so a third of a pin is already
* enough overlap to swallow whatever is underneath: two stops two metres apart sit
* about 7 px apart at the map's maximum zoom.
*/
export const STACK_RADIUS_PX = 12
/**
* How close two pins have to be before the GL renderer draws the pile as one pin.
*
* Two pixels is the rounding of a projection rather than a distance on the ground. Pins
* that close overlap by more than nine tenths of their width, so the buried one had
* nothing left to hover or click before the fold either. Two stops a few hundred metres
* apart — the neighbours a city day is made of — sit further apart than that from the
* overview zooms upwards, and keep a marker each.
*/
export const COINCIDENT_RADIUS_PX = 2
export interface CoincidentGroup<T> {
/** The one member drawn for the whole group. */
lead: T
/** Every member, in id order. */
members: T[]
}
/**
* Bundle the items whose projected pixels sit within `COINCIDENT_RADIUS_PX` of each other.
*
* `project` is the caller's map: a GL `map.project`, or a plain stub under test. An item
* it cannot place — no coordinates, a projection that came back NaN — is given a group
* of its own rather than grouped at the origin or dropped, so a projection that fails
* costs nobody their pin. Grouping runs in id order so the same input always produces
* the same groups, and `leadId` (the selected stop, normally) decides which member of a
* group is the one drawn.
*/
export function groupCoincidentPlaces<T extends { id: number }>(
items: T[],
project: (item: T) => { x: number; y: number } | null | undefined,
leadId: number | null = null,
): CoincidentGroup<T>[] {
const groups: { members: T[]; at: { x: number; y: number } | null }[] = []
const unique = new Map(items.map(item => [item.id, item]))
for (const item of [...unique.values()].sort((a, b) => a.id - b.id)) {
const point = project(item)
const at = point && Number.isFinite(point.x) && Number.isFinite(point.y) ? point : null
// An unplaceable item anchors its group nowhere, so nothing joins it and nothing is
// folded into it: hiding a pin is only ever allowed where another pin covers it.
const stack = at && groups.find(group => group.at && Math.hypot(at.x - group.at.x, at.y - group.at.y) < COINCIDENT_RADIUS_PX)
if (stack) stack.members.push(item)
else groups.push({ members: [item], at })
}
return groups.map(({ members }) => ({
members,
lead: members.find(member => member.id === leadId) ?? members[0],
}))
}
@@ -0,0 +1,121 @@
/**
* What the shared cluster options do to stops that share a coordinate (#2344).
*
* Nothing is mocked: the behaviour under test is leaflet.markercluster's own. The
* library's single answer to markers no zoom can separate is to fan them out on click,
* and whether that can fire at all is decided entirely by this option block — which is
* why the block is worth a test of its own rather than a snapshot in a component suite.
*/
import { describe, it, expect, afterEach } from 'vitest'
import L from 'leaflet'
import 'leaflet.markercluster'
import { CLUSTER_OPTIONS, CLUSTER_RADIUS_PX, CLUSTER_UNTIL_ZOOM, revealInCluster } from './markerCluster'
import { COINCIDENT_RADIUS_PX, STACK_RADIUS_PX } from './coincidentPlaces'
import { MAP_MAX_ZOOM } from '../../constants/mapDefaults'
/**
* `leaflet.markercluster` ships no types and we do not depend on
* `@types/leaflet.markercluster`, so the factory and the two internals these tests read
* back are named here rather than pulling a package in.
*/
type Group = L.Layer & {
_maxZoom: number
addLayers: (layers: L.Marker[]) => Group
getVisibleParent: (marker: L.Marker) => L.Layer | null
}
const markerClusterGroup = (L as unknown as { markerClusterGroup: (options: unknown) => Group }).markerClusterGroup
const containers: HTMLElement[] = []
function mapAt(zoom: number, maxZoom = MAP_MAX_ZOOM): L.Map {
const el = document.createElement('div')
Object.defineProperty(el, 'clientWidth', { value: 800 })
Object.defineProperty(el, 'clientHeight', { value: 600 })
document.body.appendChild(el)
containers.push(el)
return L.map(el, { center: [48.8584, 2.2945], zoom, maxZoom })
}
/** A group already on the map, holding a marker per coordinate. */
function stopsOn(map: L.Map, coords: [number, number][]) {
const group = markerClusterGroup(CLUSTER_OPTIONS)
group.addTo(map)
const markers = coords.map(coord => L.marker(coord))
group.addLayers(markers)
return { group, markers }
}
const HOTEL: [number, number] = [48.8584, 2.2945]
// ~300 m east: two ordinary neighbouring stops in a city day plan.
const NEARBY: [number, number] = [48.8584, 2.2986]
afterEach(() => { for (const el of containers.splice(0)) el.remove() })
describe('the shared cluster options', () => {
it('MARKERCLUSTER-001: cluster by the overview radius in the overview, by a pin width above it', () => {
const radius = CLUSTER_OPTIONS.maxClusterRadius
expect(radius(0)).toBe(CLUSTER_RADIUS_PX)
expect(radius(CLUSTER_UNTIL_ZOOM - 1)).toBe(CLUSTER_RADIUS_PX)
expect(radius(CLUSTER_UNTIL_ZOOM)).toBe(STACK_RADIUS_PX)
expect(radius(MAP_MAX_ZOOM)).toBe(STACK_RADIUS_PX)
// Wider than what the GL renderer folds at, and meant to stay wider: a bubble here
// hands the stops in it back on a click, where a folded GL pin is simply gone.
expect(STACK_RADIUS_PX).toBeGreaterThan(COINCIDENT_RADIUS_PX)
})
it('MARKERCLUSTER-002: carry no clustering cutoff at all, not even an undefined one', () => {
// The library guards on `!== null`, so the key spelled with `undefined` gives the
// group a NaN maximum zoom, builds no grid at all and throws on the first marker.
expect('disableClusteringAtZoom' in CLUSTER_OPTIONS).toBe(false)
})
it('MARKERCLUSTER-003: take their maximum zoom from the map, which is what makes the fan reachable', () => {
expect(stopsOn(mapAt(14), []).group._maxZoom).toBe(MAP_MAX_ZOOM)
})
it('MARKERCLUSTER-004: follow a mount site that lowers its own ceiling, silently', () => {
// Nothing throws and nothing warns: a map mounted with a lower ceiling simply moves
// the zoom at which a stack can still be fanned open.
expect(stopsOn(mapAt(14, 15), []).group._maxZoom).toBe(15)
})
it('MARKERCLUSTER-005: keep stops on one coordinate in a bubble at a day zoom, and fan them open', () => {
const map = mapAt(16)
const { group, markers } = stopsOn(map, [HOTEL, HOTEL, HOTEL])
const bubble = group.getVisibleParent(markers[0])
expect(bubble).not.toBe(markers[0])
expect(group.getVisibleParent(markers[2])).toBe(bubble)
expect(revealInCluster(group, markers[0])).toBe(true)
const spread = markers.map(marker => `${marker.getLatLng().lat},${marker.getLatLng().lng}`)
expect(new Set(spread).size).toBe(3)
})
it('MARKERCLUSTER-006: leave neighbouring stops alone at a day zoom, as they are today', () => {
const map = mapAt(16)
const { group, markers } = stopsOn(map, [HOTEL, NEARBY])
expect(group.getVisibleParent(markers[0])).toBe(markers[0])
expect(group.getVisibleParent(markers[1])).toBe(markers[1])
})
it('MARKERCLUSTER-007: still merge those same stops in the overview the radius was tuned for', () => {
const map = mapAt(5)
const { group, markers } = stopsOn(map, [HOTEL, NEARBY])
expect(group.getVisibleParent(markers[0])).not.toBe(markers[0])
expect(group.getVisibleParent(markers[0])).toBe(group.getVisibleParent(markers[1]))
})
})
describe('reaching a stop inside a bubble', () => {
it('MARKERCLUSTER-008: does nothing when there is no group, no marker or no bubble to open', () => {
expect(revealInCluster(null, {})).toBe(false)
expect(revealInCluster({ getVisibleParent: () => null }, null)).toBe(false)
expect(revealInCluster({}, {})).toBe(false)
expect(revealInCluster({ getVisibleParent: () => null }, {})).toBe(false)
const marker = {}
// A stop with a pin of its own is its own visible parent: nothing to fan.
expect(revealInCluster({ getVisibleParent: () => marker }, marker)).toBe(false)
// And a parent from a group that cannot spiderfy is left alone rather than called.
expect(revealInCluster({ getVisibleParent: () => ({}) }, {})).toBe(false)
})
})
+44 -3
View File
@@ -1,4 +1,10 @@
import L from 'leaflet'
import { STACK_RADIUS_PX } from './coincidentPlaces'
/** Up to here the map is an overview, where stops merge to keep the world readable. */
export const CLUSTER_UNTIL_ZOOM = 9
/** The overview radius, in pixels at the current zoom. */
export const CLUSTER_RADIUS_PX = 20
/**
* The cluster group the planner and the public share page both draw places with,
@@ -8,15 +14,50 @@ export const CLUSTER_OPTIONS = {
chunkedLoading: true,
chunkInterval: 30,
chunkDelay: 0,
// Pixels at the current zoom, so ordinary stops still come apart by zooming in.
maxClusterRadius: 20,
disableClusteringAtZoom: 9,
/**
* Pixels at the current zoom, so ordinary stops still come apart by zooming in.
* From the day zooms upwards only the width of a pin counts: stops sharing one
* coordinate cannot be separated by any zoom, and a cluster is the only thing
* that can fan them apart again. There is deliberately no `disableClusteringAtZoom`
* here — it redefines the group's own maximum zoom, which is what the spiderfy
* branch is measured against.
*
* A whole pin's third is affordable precisely because the fold is reversible here:
* what comes out of it is a counted bubble the user can click open. The GL renderer
* has no fan to offer and folds far tighter — see `COINCIDENT_RADIUS_PX`.
*/
maxClusterRadius: (zoom: number) => (zoom < CLUSTER_UNTIL_ZOOM ? CLUSTER_RADIUS_PX : STACK_RADIUS_PX),
spiderfyOnMaxZoom: true,
// The default legs put four 36px pins barely a pin apart and six of them back on
// top of each other, which is the stack the fan is there to undo.
spiderfyDistanceMultiplier: 1.6,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
animate: false,
}
/** The part of a cluster group that `revealInCluster` reads. */
export interface ClusterGroupLike {
getVisibleParent?: (marker: unknown) => unknown
}
/**
* Fan open the bubble a stop is hidden inside, if it is in one.
*
* Picking a stop from the places rail used to raise its pin out of the stack with a
* z-index. A stop sharing its coordinates now sits in a cluster instead and has no pin
* of its own to raise, so the bubble has to open for the selection to be visible at all.
* Deliberately not `zoomToShowLayer`: the zoom belongs to the day fit, and the selection
* only ever pans.
*/
export function revealInCluster(group: ClusterGroupLike | null | undefined, marker: unknown): boolean {
if (!group || !marker || typeof group.getVisibleParent !== 'function') return false
const parent = group.getVisibleParent(marker) as { spiderfy?: () => void } | null
if (!parent || parent === marker || typeof parent.spiderfy !== 'function') return false
parent.spiderfy()
return true
}
/** The part of a Leaflet cluster this factory reads. */
export interface ClusterLike {
getChildCount: () => number
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest'
import { isCustomPlaceImage, markerPhotoHtml, photoCacheKey, photoSourcesKey } from './placePhoto'
function imgOf(html: string): HTMLImageElement {
const holder = document.createElement('div')
holder.innerHTML = html
return holder.querySelector('img')!
}
describe('isCustomPlaceImage', () => {
it('FE-MAP-PLACEPHOTO-001: an upload or an inline image is shown as it is, a provider photo is not', () => {
expect(isCustomPlaceImage('/uploads/places/a.jpg')).toBe(true)
expect(isCustomPlaceImage('data:image/png;base64,AAA')).toBe(true)
expect(isCustomPlaceImage('/api/maps/place-photo/gp-1~p0/bytes')).toBe(false)
expect(isCustomPlaceImage(null)).toBe(false)
})
})
describe('markerPhotoHtml', () => {
it('FE-MAP-PLACEPHOTO-002: sizes the picture by its style, where no stylesheet rule can undo it', () => {
// Preflight's `height: auto` and Leaflet's `width: auto` both outrank a width or
// height attribute, so a photo that is not a small square has to be pinned here.
const img = imgOf(markerPhotoHtml('/uploads/places/wide.jpg'))
expect(img.getAttribute('src')).toBe('/uploads/places/wide.jpg')
expect(img.style.width).toBe('100%')
expect(img.style.height).toBe('100%')
expect(img.hasAttribute('width')).toBe(false)
expect(img.hasAttribute('height')).toBe(false)
})
it('FE-MAP-PLACEPHOTO-003: escapes the url, which a user can set', () => {
const html = markerPhotoHtml('/uploads/x" onerror="alert(1)" y="')
expect(html).not.toContain('onerror="alert(1)"')
expect(imgOf(html).getAttribute('src')).toBe('/uploads/x" onerror="alert(1)" y="')
})
})
describe('photoCacheKey', () => {
it('FE-MAP-PLACEPHOTO-004: a picked provider photo is its own key, ahead of the provider id', () => {
expect(photoCacheKey({ image_url: '/api/maps/place-photo/gp-1~p2/bytes', google_place_id: 'gp-1', lat: 1, lng: 2 }))
.toBe('/api/maps/place-photo/gp-1~p2/bytes')
})
it('FE-MAP-PLACEPHOTO-005: otherwise the provider id, then the coordinates, then nothing', () => {
expect(photoCacheKey({ image_url: '/uploads/places/a.jpg', google_place_id: 'gp-1', osm_id: 'node/1' })).toBe('gp-1')
expect(photoCacheKey({ osm_id: 'node/1', lat: 1, lng: 2 })).toBe('node/1')
expect(photoCacheKey({ lat: 0, lng: 0 })).toBe('0,0')
expect(photoCacheKey({ image_url: 'https://example.com/a.jpg', lat: null, lng: null })).toBe('')
})
})
describe('photoSourcesKey', () => {
const place = { id: 7, google_place_id: 'gp-7', lat: 48, lng: 2 }
it('FE-MAP-PLACEPHOTO-006: changes when an upload comes off or another photo is picked', () => {
const withUpload = photoSourcesKey([{ ...place, image_url: '/uploads/places/a.jpg' }])
const bare = photoSourcesKey([{ ...place, image_url: null }])
const picked = photoSourcesKey([{ ...place, image_url: '/api/maps/place-photo/gp-7~p0/bytes' }])
const pickedAgain = photoSourcesKey([{ ...place, image_url: '/api/maps/place-photo/gp-7~p1/bytes' }])
expect(new Set([withUpload, bare, picked, pickedAgain]).size).toBe(4)
})
it('FE-MAP-PLACEPHOTO-007: stays put when one upload replaces another, which needs no thumb', () => {
expect(photoSourcesKey([{ ...place, image_url: '/uploads/places/a.jpg' }]))
.toBe(photoSourcesKey([{ ...place, image_url: '/uploads/places/b.jpg' }]))
})
})
+44 -8
View File
@@ -1,3 +1,16 @@
import { escapeHtml } from '@trek/shared'
const PHOTO_PROXY_PREFIX = '/api/maps/place-photo/'
/** The fields of a place that decide which picture its marker gets. */
interface PhotoSource {
image_url?: string | null
google_place_id?: string | null
osm_id?: string | null
lat?: number | null
lng?: number | null
}
/**
* A place's image_url that is directly displayable on a map marker and should
* win over the auto-fetched provider thumbnail — a custom uploaded image (#1136)
@@ -11,18 +24,41 @@ export function isCustomPlaceImage(url: string | null | undefined): boolean {
/**
* Cache key under which a place's auto-fetched thumbnail is stored: the provider
* id when there is one, otherwise the coordinates. A place with neither has no
* identity to cache under, so it gets the empty string — callers skip it rather
* photo the user picked when there is one, else the provider id, otherwise the
* coordinates. The picked photo comes first because picking another picture of the
* same place leaves its provider id alone, and the thumb cached under that id
* would go on answering for the new one. A place with none of these has no
* identity to cache under, so it gets the empty string: callers skip it rather
* than filing every coordinate-less place under one shared entry.
*/
export function photoCacheKey(place: {
google_place_id?: string | null
osm_id?: string | null
lat?: number | null
lng?: number | null
}): string {
export function photoCacheKey(place: PhotoSource): string {
if (place.image_url?.startsWith(PHOTO_PROXY_PREFIX)) return place.image_url
if (place.google_place_id) return place.google_place_id
if (place.osm_id) return place.osm_id
if (place.lat == null || place.lng == null) return ''
return `${place.lat},${place.lng}`
}
/**
* What the maps' thumb loading depends on, as one string to key the effect on.
* Place ids alone missed every change of picture: taking an upload off a place or
* picking another suggested photo changes no id, so the marker kept a category
* dot or the old thumb until the page was reloaded.
*/
export function photoSourcesKey(places: Array<PhotoSource & { id: number }>): string {
return places.map(p => `${p.id}:${isCustomPlaceImage(p.image_url) ? 'own' : photoCacheKey(p)}`).join('|')
}
/**
* The picture inside a round place marker, for both the Leaflet and the GL map.
*
* Sized by its style and not by width/height attributes, because stylesheet rules
* beat attributes: Tailwind's preflight gives every img `height: auto`, and
* Leaflet's marker pane adds `width: auto`. The 48px square thumbs got away with
* that. An uploaded photo kept its own proportions on the GL map and its full pixel
* size on Leaflet, where only the rounded-off corner of a 4000px image lands inside
* the circle, so the marker showed nothing but its category colour.
*/
export function markerPhotoHtml(url: string): string {
return `<img src="${escapeHtml(url)}" alt="" style="display:block;width:100%;height:100%;border-radius:50%;object-fit:cover;" />`
}
@@ -9,7 +9,7 @@ vi.mock('./RouteCalculator', async (importActual) => {
return { ...actual, calculateRouteWithLegs: vi.fn() }
})
const { calculateRouteWithLegs } = await import('./RouteCalculator')
const { calculateRouteWithLegs, RoutingRefusedError } = await import('./RouteCalculator')
const leg = (distance: number): RouteSegment => ({
mid: [0, 0], from: [0, 0], to: [0, 0], distance, duration: 600,
@@ -104,4 +104,112 @@ describe('tripRouteGeometry', () => {
await expect(pending).resolves.toMatchObject({ totalDistance: 0 })
})
// The public OSRM hosts allow about one request a second. Every leg of every day at
// once is exactly the burst they answer with 429 from the second second on, and each
// refused leg used to stay a straight line with no distance and no word about it.
it('FE-MAP-TRG-007: asks the router one leg at a time, a second apart', async () => {
vi.useFakeTimers()
try {
// A round trip takes time; only a network answer is paced, a cache hit is not.
vi.mocked(calculateRouteWithLegs).mockImplementation(() => new Promise(resolve => {
setTimeout(() => resolve({ coordinates: [[48.86, 2.35], [48.90, 2.42]], distance: 10000, duration: 600, legs: [leg(10000)] }), 100)
}))
const pending = routeTrip(input, { profile: 'driving', tripId: 7 })
// The second day waits for the first to answer...
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(100)
// ...and then for the gap the shared hosts need between two requests.
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1100)
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(100)
const summary = await pending
expect(summary.totalDistance).toBe(20000)
expect(summary.unroutedLegs).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('FE-MAP-TRG-008: a rate limit is waited out and the leg asked for again', async () => {
vi.useFakeTimers()
try {
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
.mockResolvedValue({ coordinates: [[48.86, 2.35], [48.90, 2.42]], distance: 10000, duration: 600, legs: [leg(10000)] })
const pending = routeTrip(input, { profile: 'driving', tripId: 7 })
await vi.advanceTimersByTimeAsync(1500)
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1100)
const summary = await pending
// Both days routed: the refused leg answered on its second try.
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(3)
expect(summary.totalDistance).toBe(20000)
expect(summary.unroutedLegs).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('FE-MAP-TRG-009: a host that says how long to wait is given that long', async () => {
vi.useFakeTimers()
try {
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new RoutingRefusedError(429, 6000))
.mockResolvedValue({ coordinates: [[48.86, 2.35], [48.90, 2.42]], distance: 10000, duration: 600, legs: [leg(10000)] })
const pending = routeTrip(input, { profile: 'driving', tripId: 7 })
// The own first backoff would be 1500 ms; asking then is just a second refusal.
await vi.advanceTimersByTimeAsync(2000)
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(4000)
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1100)
await expect(pending).resolves.toMatchObject({ totalDistance: 20000 })
} finally {
vi.useRealTimers()
}
})
it('FE-MAP-TRG-010: a refusal the host meant is not repeated, and the leg is counted as unrouted', async () => {
// A 400 objects to these coordinates; the same request earns the same answer.
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new RoutingRefusedError(400, null))
.mockResolvedValue({ coordinates: [[45.76, 4.83], [45.80, 4.90]], distance: 10000, duration: 600, legs: [leg(10000)] })
const summary = await routeTrip(input, { profile: 'driving', tripId: 7 })
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
// The first day keeps its straight line and adds nothing to the sum...
expect(summary.lines[0]).toEqual([[48.86, 2.35], [48.90, 2.42]])
expect(summary.days[0].distance).toBe(0)
expect(summary.totalDistance).toBe(10000)
// ...and says so, per day and for the trip, so the total is not mistaken for complete.
expect(summary.days.map(d => d.unroutedLegs)).toEqual([1, 0])
expect(summary.unroutedLegs).toBe(1)
})
it('FE-MAP-TRG-011: a rate limit that never lifts gives up after the retries and is counted', async () => {
vi.useFakeTimers()
try {
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(429, null))
const pending = routeTrip(input, { profile: 'driving', tripId: 7 })
// Two backoffs per leg, a gap between the legs, then it stops asking.
await vi.advanceTimersByTimeAsync(20000)
const summary = await pending
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(6)
expect(summary.totalDistance).toBe(0)
expect(summary.unroutedLegs).toBe(2)
} finally {
vi.useRealTimers()
}
})
})
+75 -20
View File
@@ -1,4 +1,4 @@
import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator'
import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator'
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
import { resolveLegMode } from '../Planner/legMode'
import { dayColor } from '../Roadtrip/dayColors'
@@ -21,6 +21,12 @@ export interface TripOverviewDay {
duration: number
/** The modes this day is actually travelled in, first use first. */
modes: string[]
/**
* Legs drawn as a straight line because the router gave no road for them: not asked
* yet while the round is running, refused or cut off by the deadline once it is over.
* Those legs add nothing to `distance`, so a day with any of them reads too short.
*/
unroutedLegs?: number
}
export interface TripRouteSummary {
@@ -34,6 +40,8 @@ export interface TripRouteSummary {
focusPoints: [number, number][]
totalDistance: number
totalDuration: number
/** `unroutedLegs` over every day: above zero, `totalDistance` is a partial sum. */
unroutedLegs?: number
}
/** Neighbouring legs of one run that resolve to the same mode travel as one request,
@@ -44,6 +52,8 @@ interface Chunk { points: DayRoutePoint[]; mode: string }
export interface TripRoutePlanDay { day: Day; runs: Chunk[][] }
type Answer = { coordinates: [number, number][]; legs: RouteSegment[] } | null
/** Every leg's answer so far, indexed like the plan: day, run, chunk. */
export type TripRouteAnswers = Answer[][][]
/** The palette entry a day keeps, whatever else is added to the trip around it. */
export const dayRouteColor = (day: Day): { line: string; casing: string } =>
@@ -90,10 +100,12 @@ export function assembleTripRoute(plan: TripRoutePlanDay[], routed: Answer[][][]
return plan.map(({ day, runs }, d) => {
const lines: [number, number][][] = []
const segments: RouteSegment[] = []
let unroutedLegs = 0
runs.forEach((chunks, r) => {
const polyline: [number, number][] = []
chunks.forEach((chunk, c) => {
const answer = routed[d]?.[r]?.[c]
if (!answer) unroutedLegs++
const coords = answer && answer.coordinates.length >= 2 ? answer.coordinates : straight(chunk.points)
for (const point of coords) {
// Drop the point shared with the previous chunk so concatenated legs
@@ -119,6 +131,7 @@ export function assembleTripRoute(plan: TripRoutePlanDay[], routed: Answer[][][]
distance: segments.reduce((sum, s) => sum + s.distance, 0),
duration: segments.reduce((sum, s) => sum + s.duration, 0),
modes,
unroutedLegs,
}
})
}
@@ -133,18 +146,49 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
focusPoints: lines.flat(),
totalDistance: days.reduce((sum, d) => sum + d.distance, 0),
totalDuration: days.reduce((sum, d) => sum + d.duration, 0),
unroutedLegs: days.reduce((sum, d) => sum + (d.unroutedLegs ?? 0), 0),
}
}
/**
* Ask the router for every leg of every day, a few at a time.
* Gap between two routing requests. The public OSRM hosts TREK ships with state one
* request per second; the overview asks for every leg of every day, so without spacing
* the first handful answer and the rest come back 429 (the road trip rail learnt this
* first and paces itself the same way).
*/
const REQUEST_SPACING_MS = 1100
/** Anything answered faster than this came out of RouteCalculator's cache, not the network. */
const CACHE_HIT_MS = 60
/** How often a rate-limited leg is tried again, and how long after. */
const RETRY_DELAYS_MS = [1500, 4000]
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
new Promise(resolve => {
if (signal?.aborted || ms <= 0) { resolve(); return }
const timer = setTimeout(done, ms)
function done(): void {
clearTimeout(timer)
signal?.removeEventListener('abort', done)
resolve()
}
signal?.addEventListener('abort', done, { once: true })
})
/**
* Ask the router for every leg of every day, one at a time.
*
* A fortnight is dozens of legs and the routing host is usually a shared OSRM, so the
* pool is deliberately small — the same one the sidebar connectors use. RouteCalculator's
* cache is shared with the day route, so a day already drawn on screen costs nothing.
* A fortnight is dozens of legs and the routing host is usually a shared OSRM that
* refuses a burst, so the legs go out one after another with a pause between them, and
* a rate limit is waited out (for as long as the host asks, when it says) and asked
* again. Only what actually went to the network is paced: RouteCalculator's cache is
* shared with the day route, so a day already drawn on screen costs nothing and waits
* for nothing.
*
* A leg the router refuses keeps its straight line and contributes no distance, exactly
* as a failed leg of a day route does. Nothing here throws.
* A leg the router still refuses keeps its straight line and contributes no distance,
* exactly as a failed leg of a day route does; `assembleTripRoute` counts it so the
* caller can say the total is short. A refusal the host meant (these coordinates, this
* profile) is not repeated, because the same request earns the same answer. Nothing
* here throws.
*/
export async function routeTripLegs(
plan: TripRoutePlanDay[],
@@ -161,26 +205,37 @@ export async function routeTripLegs(
runs.forEach((chunks, r) => {
chunks.forEach((chunk, c) => {
tasks.push(async () => {
try {
const answer = await calculateRouteWithLegs(
chunk.points.map(p => ({ lat: p.lat, lng: p.lng })),
{ signal, profile: chunk.mode, tripId, dayId: day.id },
)
routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs }
onAnswer?.(routed)
} catch {
// Refused (usually a rate limit) — the straight line stands.
const waypoints = chunk.points.map(p => ({ lat: p.lat, lng: p.lng }))
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
if (signal?.aborted) return
try {
const answer = await calculateRouteWithLegs(
waypoints,
{ signal, profile: chunk.mode, tripId, dayId: day.id },
)
routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs }
onAnswer?.(routed)
return
} catch (err) {
if (signal?.aborted) return
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null
if (!rateLimit || attempt === RETRY_DELAYS_MS.length) return
// When the host says how long to wait, waiting less is just a second refusal.
await sleep(Math.max(RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), signal)
}
}
})
})
})
})
let next = 0
const worker = async () => {
while (next < tasks.length && !signal?.aborted) await tasks[next++]()
for (let i = 0; i < tasks.length; i++) {
if (signal?.aborted) break
const startedAt = performance.now()
await tasks[i]()
const wasNetwork = performance.now() - startedAt > CACHE_HIT_MS
if (wasNetwork && i < tasks.length - 1) await sleep(REQUEST_SPACING_MS, signal)
}
await Promise.all(Array.from({ length: Math.min(6, tasks.length) }, worker))
return routed
}
@@ -1,4 +1,4 @@
import { renderHook, waitFor } from '@testing-library/react'
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { dayColor } from '../Roadtrip/dayColors'
import { useTripRouteOverview } from './useTripRouteOverview'
@@ -10,7 +10,9 @@ vi.mock('./RouteCalculator', async (importActual) => {
return { ...actual, calculateRouteWithLegs: vi.fn() }
})
const { calculateRouteWithLegs } = await import('./RouteCalculator')
const { calculateRouteWithLegs, RoutingRefusedError } = await import('./RouteCalculator')
type RouteAnswer = Awaited<ReturnType<typeof calculateRouteWithLegs>>
const leg = (distance: number, duration: number): RouteSegment => ({
mid: [0, 0], from: [0, 0], to: [0, 0],
@@ -136,4 +138,76 @@ describe('useTripRouteOverview', () => {
await waitFor(() => expect(result.current.loading).toBe(false))
expect(vi.mocked(calculateRouteWithLegs).mock.calls.length).toBe(calls)
})
it('FE-MAP-TRO-009: a leg the router refused outright is counted, so the total is not taken for complete', async () => {
// A 400 objects to these coordinates; the leg stays a straight line with no distance.
vi.mocked(calculateRouteWithLegs).mockRejectedValueOnce(new RoutingRefusedError(400, null))
const { result } = render()
await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 3000 })
expect(result.current.days.map(d => d.unroutedLegs)).toEqual([1, 0])
expect(result.current.unroutedLegs).toBe(1)
expect(result.current.totalDistance).toBe(12000)
})
it('FE-MAP-TRO-010: each day is drawn as soon as its road answers, not when the round is over', async () => {
// The legs go to the router one at a time with a pause between them, so a cold trip
// takes a second per leg; roads filling in one by one is what makes that read as
// progress rather than as a hang.
const answer: RouteAnswer = {
coordinates: [[48.86, 2.35], [48.87, 2.355], [48.88, 2.36]],
distance: 12000, duration: 900,
legs: [leg(12000, 900)],
}
const answers: Array<(value: RouteAnswer) => void> = []
vi.mocked(calculateRouteWithLegs).mockImplementation(
() => new Promise<RouteAnswer>(resolve => { answers.push(resolve) }),
)
const { result } = render()
// Only the first leg has been asked for.
expect(answers).toHaveLength(1)
await act(async () => { answers[0](answer) })
// Its day is on the map while the second is still waiting.
expect(result.current.loading).toBe(true)
expect(result.current.days[0].distance).toBe(12000)
expect(result.current.days[1].distance).toBe(0)
await waitFor(() => expect(answers).toHaveLength(2), { timeout: 3000 })
await act(async () => { answers[1](answer) })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.totalDistance).toBe(24000)
})
it('FE-MAP-TRO-011: keeps the frame it set on the straight lines until the round is over', async () => {
// A fresh `focusPoints` array is what tells the map to fit the camera. One per
// answering leg would take the map back from wherever the reader has panned to, so
// the frame changes exactly twice: on the straight lines, and on the finished roads.
const answer: RouteAnswer = {
coordinates: [[48.86, 2.35], [48.87, 2.355], [48.88, 2.36]],
distance: 12000, duration: 900,
legs: [leg(12000, 900)],
}
const answers: Array<(value: RouteAnswer) => void> = []
vi.mocked(calculateRouteWithLegs).mockImplementation(
() => new Promise<RouteAnswer>(resolve => { answers.push(resolve) }),
)
const { result } = render()
const frame = result.current.focusPoints
expect(frame.length).toBeGreaterThan(0)
await act(async () => { answers[0](answer) })
// The road is drawn, the camera stays put.
expect(result.current.days[0].distance).toBe(12000)
expect(result.current.focusPoints).toBe(frame)
await waitFor(() => expect(answers).toHaveLength(2), { timeout: 3000 })
await act(async () => { answers[1](answer) })
await waitFor(() => expect(result.current.loading).toBe(false))
// The finished result frames the roads themselves, which have more points than the
// straight lines had.
expect(result.current.focusPoints).not.toBe(frame)
expect(result.current.focusPoints.length).toBeGreaterThan(frame.length)
})
})
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { type RouteProfileKey } from './RouteCalculator'
import {
assembleTripRoute, emptyAnswers, planTripRoute, routeTripLegs, summariseTripRoute,
type TripRouteSummary,
type TripRouteAnswers, type TripRouteSummary,
} from './tripRouteGeometry'
import { useSettingsStore } from '../../store/settingsStore'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
@@ -66,12 +66,25 @@ export function useTripRouteOverview(
// Straight lines first so the shape of the trip is on screen immediately, then the
// real roads replace them — the same two-step the single-day route draws with.
setResult({ ...summariseTripRoute(assembleTripRoute(plan, emptyAnswers(plan))), loading: true })
const first = summariseTripRoute(assembleTripRoute(plan, emptyAnswers(plan)))
setResult({ ...first, loading: true })
routeTripLegs(plan, { tripId, signal: controller.signal }).then(routed => {
// Published leg by leg: the legs go to the router one at a time with a pause between
// them, so a cold trip takes a second per leg, and roads that fill in as they answer
// read as progress where straight lines that all flip at once read as a hang.
//
// The frame is the exception. A fresh `focusPoints` array is what tells the map to
// fit the camera, and a fit every second would take the map back from wherever the
// reader has panned to. The frame set on the straight lines therefore holds until
// the round is over, and only the finished result brings a new one, exactly the two
// fits the overview made when every answer landed at once.
const publish = (routed: TripRouteAnswers, loading: boolean): void => {
if (controller.signal.aborted) return
setResult({ ...summariseTripRoute(assembleTripRoute(plan, routed)), loading: false })
})
const next = summariseTripRoute(assembleTripRoute(plan, routed))
setResult({ ...next, focusPoints: loading ? first.focusPoints : next.focusPoints, loading })
}
routeTripLegs(plan, { tripId, signal: controller.signal, onAnswer: routed => publish(routed, true) })
.then(routed => publish(routed, false))
return () => controller.abort()
// planKey is derived from the same inputs as plan, so keying on the string is
@@ -1,94 +1,13 @@
import { useEffect, useRef } from 'react'
import { addListener, removeListener } from '../../api/websocket'
import { isEffectivelyOnline, onNetworkModeChange } from '../../sync/networkMode'
/**
* How long pings are collected before one refetch goes out.
*
* Long enough to fold a burst into a single request — clearing twelve checked
* items issues twelve deletes, and an import writes a whole list — short enough
* that a single edit still reads as instant.
*/
const COALESCE_MS = 250
import { useServerPing } from '../../sync/useServerPing'
/**
* Re-read the bag weights when the server says they moved (#2191).
*
* The ping is content-free by design: totals are summed across every member,
* including people whose items this client may not see, so the event cannot
* carry the new numbers without leaking what produced them. That makes every
* ping a refetch, which is why this coalesces bursts and never lets two
* requests overlap — the server pings the whole room, the originating socket
* included, so a busy trip would otherwise have every client refetching once
* per written item.
*
* A ping sent while this client is disconnected is gone for good, and bags have
* no offline cache to sync back, so the two moments where that can have happened
* count as pings of their own: the room re-join after a reconnect, and the
* return to online.
*
* `reload` may change identity every render; the latest one is always used.
* carry the new numbers without leaking what produced them. `useServerPing`
* carries the coalescing, the in-flight guard and the reconnect handling.
*/
export function useBagTotalsPing(enabled: boolean, reload: () => Promise<void>): void {
const reloadRef = useRef(reload)
reloadRef.current = reload
useEffect(() => {
if (!enabled) return
let timer: ReturnType<typeof setTimeout> | null = null
let inFlight = false
/** A ping that arrived while a refetch was running: serve it once that lands. */
let pendingAfterFlight = false
let cancelled = false
const run = (): void => {
if (cancelled) return
if (inFlight) {
pendingAfterFlight = true
return
}
inFlight = true
void reloadRef.current().finally(() => {
inFlight = false
if (pendingAfterFlight && !cancelled) {
pendingAfterFlight = false
run()
}
})
}
const schedule = (): void => {
if (timer) return // already collecting this burst
timer = setTimeout(() => {
timer = null
run()
}, COALESCE_MS)
}
const handler = (event: Record<string, unknown>) => {
// 'joined' is the room re-join the socket sends on every reconnect, so it
// is also the moment the totals on screen are the ones from before the
// drop: whatever moved them in the meantime pinged into a closed socket.
if (event.type !== 'packing:bag-totals' && event.type !== 'joined') return
schedule()
}
let wasOnline = isEffectivelyOnline()
const onMode = (): void => {
const online = isEffectivelyOnline()
// Only the way back matters: that is when the surfaces stop summing what
// this client can see and go back to trusting the server numbers.
if (online && !wasOnline) schedule()
wasOnline = online
}
addListener(handler)
const unsubscribeMode = onNetworkModeChange(onMode)
return () => {
cancelled = true
removeListener(handler)
unsubscribeMode()
if (timer) clearTimeout(timer)
}
}, [enabled])
useServerPing('packing:bag-totals', enabled, reload)
}
@@ -4299,6 +4299,104 @@ describe('DayPlanSidebar', () => {
fireEvent.dragEnd(row)
expect(row.style.opacity).toBe('1')
})
// ── Drop positions count the rows the list hides ───────────────────────
// The stop a booked night wrote is in the day but not in the list. The store
// splices into the full day, so a position counted over the visible rows would
// land one slot early and reorder would persist it that way.
/** A day whose first stored row is the hotel a booking put there, hidden from the list. */
function dayWithHiddenHotel(dayId: number) {
const hotel = buildPlace({ id: 600, name: 'Hotel Adlon', stop_type: 'hotel' })
const lunch = buildPlace({ id: 601, name: 'Lunch' })
const museum = buildPlace({ id: 602, name: 'Museum' })
return {
places: [hotel, lunch, museum],
assignments: [
buildAssignment({ id: 400, day_id: dayId, order_index: 0, place: hotel, accommodation_id: 7 } as never),
buildAssignment({ id: 401, day_id: dayId, order_index: 1, place: lunch }),
buildAssignment({ id: 402, day_id: dayId, order_index: 2, place: museum }),
],
}
}
it('FE-PLANNER-DAYPLAN-210: a place dropped on a row lands ahead of that row in the stored day', () => {
const onAssignToDay = vi.fn()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const { places, assignments } = dayWithHiddenHotel(10)
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places, assignments: { '10': assignments }, onAssignToDay,
})} />)
;(window as any).__dragData = { placeId: '55' }
fireEvent.drop(dragRow(screen.getByText('Museum')), { dataTransfer: { getData: vi.fn(() => '') } })
// Museum is the second visible row but the third stored one.
expect(onAssignToDay).toHaveBeenCalledWith(55, 10, 2)
;(window as any).__dragData = { placeId: '55' }
fireEvent.drop(dragRow(screen.getByText('Lunch')), { dataTransfer: { getData: vi.fn(() => '') } })
expect(onAssignToDay).toHaveBeenLastCalledWith(55, 10, 1)
;(window as any).__dragData = null
})
it('FE-PLANNER-DAYPLAN-211: a stop moved from another day onto a row lands ahead of that row in the stored day', () => {
const moveAssignment = vi.fn(async () => undefined)
stubTripActions({ moveAssignment })
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
]
const source = buildPlace({ id: 1, name: 'Source place' })
const { places, assignments } = dayWithHiddenHotel(11)
render(<DayPlanSidebar {...makeDefaultProps({
days, places: [source, ...places],
assignments: { '10': [buildAssignment({ id: 11, day_id: 10, order_index: 0, place: source })], '11': assignments },
})} />)
fireEvent.dragStart(dragRow(screen.getByText('Source place')), { dataTransfer: emptyDataTransfer })
fireEvent.drop(dragRow(screen.getByText('Museum')), { dataTransfer: { getData: vi.fn(() => '') } })
expect(moveAssignment).toHaveBeenCalledWith(1, 11, 10, 11, 2)
})
it('FE-PLANNER-DAYPLAN-212: a place dropped on a note lands ahead of the next stop below it, or last', () => {
const onAssignToDay = vi.fn()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const { places, assignments } = dayWithHiddenHotel(10)
mockDayNotesState.dayNotes = { '10': [
buildDayNote({ id: 70, day_id: 10, text: 'Between note', sort_order: 1.5 }),
buildDayNote({ id: 71, day_id: 10, text: 'Closing note', sort_order: 5 }),
] }
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places, assignments: { '10': assignments }, onAssignToDay,
})} />)
;(window as any).__dragData = { placeId: '55' }
fireEvent.drop(cardRow(screen.getByText('Between note')), { dataTransfer: { getData: vi.fn(() => '') } })
expect(onAssignToDay).toHaveBeenCalledWith(55, 10, 2)
// Nothing below the closing note, so the stop goes to the end of the stored
// day: three rows, the hidden hotel among them.
;(window as any).__dragData = { placeId: '55' }
fireEvent.drop(cardRow(screen.getByText('Closing note')), { dataTransfer: { getData: vi.fn(() => '') } })
expect(onAssignToDay).toHaveBeenLastCalledWith(55, 10, 3)
;(window as any).__dragData = null
})
it('FE-PLANNER-DAYPLAN-213: a stop moved from another day onto a note lands ahead of the next stop below it', () => {
const moveAssignment = vi.fn(async () => undefined)
stubTripActions({ moveAssignment })
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
]
const source = buildPlace({ id: 1, name: 'Source place' })
const { places, assignments } = dayWithHiddenHotel(11)
mockDayNotesState.dayNotes = { '11': [buildDayNote({ id: 70, day_id: 11, text: 'Between note', sort_order: 1.5 })] }
render(<DayPlanSidebar {...makeDefaultProps({
days, places: [source, ...places],
assignments: { '10': [buildAssignment({ id: 11, day_id: 10, order_index: 0, place: source })], '11': assignments },
})} />)
fireEvent.dragStart(dragRow(screen.getByText('Source place')), { dataTransfer: emptyDataTransfer })
fireEvent.drop(cardRow(screen.getByText('Between note')), { dataTransfer: { getData: vi.fn(() => '') } })
expect(moveAssignment).toHaveBeenCalledWith(1, 11, 10, 11, 2)
})
})
// FE-W5DPS-001 to FE-W5DPS-006 — booking subtitles, the collections entry in the
@@ -9,6 +9,7 @@ import { avatarSrc } from '../../utils/avatarSrc'
import { safeHttpUrl } from '../../utils/safeUrl'
import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, StickyNote, TramFront, Zap } from 'lucide-react'
import { type PickedPlace } from './TransitSearchPanel'
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, type NamedWaypoint } from '../Map/RouteCalculator'
import GoogleMapsIcon from '../shared/GoogleMapsIcon'
@@ -493,6 +494,26 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
getDisplayTime: getDisplayTimeForDay,
})
// The list is a filtered view of the day, and the store is not: assignPlaceToDay
// and moveAssignment splice into the full day, hidden rows included, and persist
// the result. A position counted over the rows on screen therefore lands one slot
// early for every hidden row above the target. So a drop names the row it should
// land ahead of, and what goes to the store is that row's slot in the full day,
// in the order the list reads it. No row (an empty day, a note under the last
// stop) means the end of the full day.
const storedPositionBefore = (dayId: number, target: { id: number } | null | undefined): number => {
const stored = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const idx = target ? stored.findIndex(a => a.id === target.id) : -1
return idx >= 0 ? idx : stored.length
}
// The stop a drop on a note lands ahead of: the next place below the note.
const placeBelowNote = (dayId: number, noteId: number): Assignment | undefined => {
const tm = getMergedItems(dayId)
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === noteId)
return noteIdx < 0 ? undefined : tm.slice(noteIdx + 1).find(i => i.type === 'place')?.data
}
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
// eslint-disable-next-line react-hooks/exhaustive-deps
const mergedItemsMap = useMemo(() => {
@@ -1189,6 +1210,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
computeTransportPosition,
initTransportPositions,
getMergedItems,
storedPositionBefore,
placeBelowNote,
mergedItemsMap,
applyMergedOrder,
handleMergedDrop,
@@ -1387,6 +1410,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
computeTransportPosition,
initTransportPositions,
getMergedItems,
storedPositionBefore,
placeBelowNote,
mergedItemsMap,
applyMergedOrder,
handleMergedDrop,
@@ -1434,61 +1459,18 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
})
}
// 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 }
}
// Coordinates back to names for the transit search, over every located place,
// hotel and booking endpoint on the trip (see transitLeg.ts).
const transitNameIndex = useMemo(
() => buildTransitNameIndex(assignments, accommodations, reservations),
[assignments, accommodations, reservations],
)
// 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)
const leg = buildTransitLeg(seg, dayId, transitNameIndex, assignments, reservations)
if (!leg) return []
return [{ label: t('transit.title'), icon: TramFront, onClick: () => onPlanTransitLeg({ dayId, from: leg.from, to: leg.to, time: leg.time }) }]
}
@@ -2059,8 +2041,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
e.preventDefault(); e.stopPropagation()
const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
if (placeId) {
const pos = placeItems.findIndex(i => i.data.id === assignment.id)
onAssignToDay?.(Number.parseInt(placeId), day.id, pos >= 0 ? pos : undefined)
onAssignToDay?.(Number.parseInt(placeId), day.id, storedPositionBefore(day.id, assignment))
setDropTargetKey(null); window.__dragData = null
} else if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
@@ -2069,8 +2050,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
} else if (fromReservationId) {
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'place', assignment.id)
} else if (fromAssignmentId && fromDayId !== day.id) {
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, storedPositionBefore(day.id, assignment)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
@@ -2659,12 +2639,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const { placeId, noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
if (placeId) {
// New place dropped onto a note: insert it among the
// assignments at the note's position (after the places
// above it), so it lands right where the note sits.
const tm = getMergedItems(day.id)
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const pos = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
onAssignToDay?.(Number.parseInt(placeId), day.id, pos)
// assignments at the note's position (ahead of the first
// place below it), so it lands right where the note sits.
onAssignToDay?.(Number.parseInt(placeId), day.id, storedPositionBefore(day.id, placeBelowNote(day.id, note.id)))
setDropTargetKey(null); window.__dragData = null
} else if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
@@ -2681,10 +2658,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
} else if (fromNoteId && fromNoteId !== String(note.id)) {
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
} else if (fromAssignmentId && fromDayId !== day.id) {
const tm = getMergedItems(day.id)
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, storedPositionBefore(day.id, placeBelowNote(day.id, note.id))).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null)
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
@@ -585,6 +585,20 @@ describe('PlaceFormModal', () => {
expect(screen.getByDisplayValue('20:34')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-086: on a road trip the End says the drive leaves at it', () => {
const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '14:00' });
const assignment = buildAssignment({ id: 10, day_id: 5, place });
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={10} dayAssignments={[assignment]} roadtripActive />);
expect(screen.getByText('On the road trip, the drive leaves at this time.')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-087: in Days the End stays a plain label', () => {
const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '14:00' });
const assignment = buildAssignment({ id: 10, day_id: 5, place });
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={10} dayAssignments={[assignment]} />);
expect(screen.queryByText('On the road trip, the drive leaves at this time.')).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
// Build an assignment whose place has end_time before place_time
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
@@ -68,6 +68,8 @@ interface PlaceFormModalProps {
* of the drive it belongs on. Absent, nothing about the form changes.
*/
serviceStop?: ServiceStopMode | null
/** Road trip mode is on, where a visit's End is when the drive leaves it. */
roadtripActive?: boolean
}
@@ -1181,6 +1183,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
assignmentId={assignmentId}
dayAssignments={dayAssignments}
hasTimeError={hasTimeError}
endIsLeave={!!props.roadtripActive}
t={t}
/>
)}
@@ -1270,10 +1273,13 @@ interface TimeSectionProps {
assignmentId: number | null
dayAssignments: Assignment[]
hasTimeError: boolean
/** On a road trip the End is when the drive leaves, which the field says. In Days it
* stays the plain label it has always been: nothing is scheduled off it there. */
endIsLeave: boolean
t: (key: string, params?: Record<string, string | number>) => string
}
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }: TimeSectionProps) {
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, endIsLeave, t }: TimeSectionProps) {
const collisions = useMemo(() => {
if (!assignmentId || !form.place_time || form.place_time.length < 5) return []
@@ -1311,6 +1317,7 @@ function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTime
value={form.end_time}
onChange={v => handleChange('end_time', v)}
/>
{endIsLeave && <p className="mt-1 text-caption text-content-faint">{t('roadtrip.stop.endIsLeave')}</p>}
</div>
</div>
{hasTimeError && (
@@ -1498,4 +1498,50 @@ describe('PlaceInspector', () => {
expect(screen.queryByRole('button', { name: /remove from day/i })).toBeNull();
expect(screen.queryByRole('button', { name: /add to day/i })).toBeNull();
});
it('FE-PLANNER-INSPECTOR-017c: the booked stop stays off the buttons when the day list hides it', () => {
// The day view hands the inspector the day without the stop a booking wrote,
// while the store still holds it. Judged on the list alone the hotel looked
// unassigned, and "Add to day" put a second copy beside the booked night.
const booked = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null, accommodation_id: 4 }];
seedStore(useTripStore, { assignments: { '1': booked } as never });
render(<PlaceInspector {...defaultProps} selectedDayId={1} assignments={{ '1': [] }} />);
expect(screen.queryByText('Add to Day')).toBeNull();
expect(screen.queryByText('Remove from Day')).toBeNull();
});
it('FE-PLANNER-INSPECTOR-017d: a stop the traveller placed at the booked hotel can still be removed', () => {
// A second copy that did get onto the day is the traveller's own row and shows
// in the list. That one keeps its "Remove from day", which is the way back.
const onRemoveAssignment = vi.fn();
const booked = { id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null, accommodation_id: 4 };
const own = { id: 100, place, day_id: 1, place_id: place.id, order_index: 1, notes: null };
seedStore(useTripStore, { assignments: { '1': [booked, own] } as never });
render(
<PlaceInspector
{...defaultProps}
selectedDayId={1}
assignments={{ '1': [own] } as never}
onRemoveAssignment={onRemoveAssignment}
/>
);
fireEvent.click(screen.getByText('Remove from Day').closest('button')!);
expect(onRemoveAssignment).toHaveBeenCalledWith(1, 100);
expect(screen.queryByText('Add to Day')).toBeNull();
});
it('FE-PLANNER-INSPECTOR-017e: any booked stop at the place keeps the buttons off, whichever row the store lists first', () => {
// With the service stops switched off the day list can hide a hotel the traveller
// placed as well, so the store may list that row ahead of the booked one. The
// question is whether a booking holds this place on this day, not which row comes first.
const own = { id: 100, place, day_id: 1, place_id: place.id, order_index: 0, notes: null };
const booked = { id: 99, place, day_id: 1, place_id: place.id, order_index: 1, notes: null, accommodation_id: 4 };
seedStore(useTripStore, { assignments: { '1': [own, booked] } as never });
render(<PlaceInspector {...defaultProps} selectedDayId={1} assignments={{ '1': [] }} />);
expect(screen.queryByText('Add to Day')).toBeNull();
expect(screen.queryByText('Remove from Day')).toBeNull();
});
})
@@ -209,6 +209,12 @@ export default function PlaceInspector({
const { t, locale, language } = useTranslation()
// Currency-less prices mean "the trip's currency"; null in collection mode (EUR fallback below).
const tripCurrency = useTripStore(s => s.trip?.currency)
// The day list handed in is the one the planner shows, and in the day view that
// list leaves out the stop a booking wrote. The store still holds it, so the
// booked-night check below reads the day from there as well: on the list alone
// the hotel of a booked night looked unassigned and could be put on the day a
// second time, which is the duplicate that check exists to prevent.
const storedDayAssignments = useTripStore(s => (selectedDayId ? s.assignments[String(selectedDayId)] : undefined))
const toast = useToast()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
@@ -322,7 +328,9 @@ export default function PlaceInspector({
?? dayAssignments.find(a => a.place?.id === place.id))
: null
/** This stop belongs to a booked night rather than to the traveller. */
const bookedNight = assignmentInDay?.accommodation_id != null
const bookedNight = assignmentInDay
? assignmentInDay.accommodation_id != null
: !!storedDayAssignments?.some(a => a.place?.id === place.id && a.accommodation_id != null)
// The weekday lines are display text; the ring is computed from the structured
// periods next to them, in the place's own timezone. open_now stays the fallback.
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
import type { Accommodation, Assignment, Reservation, RouteSegment } from '../../types'
// FE-PLANNER-TRANSITLEG-001 to FE-PLANNER-TRANSITLEG-006
const visit = (id: number, dayId: number, name: string, lat: number, lng: number, placeTime?: string) =>
({ id, day_id: dayId, place: { id, name, lat, lng, place_time: placeTime ?? null } }) as unknown as Assignment
const seg = (from: [number, number], to: [number, number]) => ({ from, to }) as unknown as RouteSegment
describe('buildTransitNameIndex', () => {
it('FE-PLANNER-TRANSITLEG-001: names every located place, hotel and booking endpoint by its coordinate', () => {
const names = buildTransitNameIndex(
{ '1': [visit(11, 1, 'Louvre', 48.86, 2.34)] },
[{ place_lat: 48.85, place_lng: 2.35, place_name: 'Hotel Lutetia' } as unknown as Accommodation],
[{ endpoints: [{ lat: 48.88, lng: 2.36, name: 'Gare du Nord' }] } as unknown as Reservation],
)
expect(names.get('48.86,2.34')).toBe('Louvre')
expect(names.get('48.85,2.35')).toBe('Hotel Lutetia')
expect(names.get('48.88,2.36')).toBe('Gare du Nord')
})
it('FE-PLANNER-TRANSITLEG-002: keeps the first name for a coordinate and skips what has no position or name', () => {
const names = buildTransitNameIndex(
{ '1': [visit(11, 1, 'First', 1, 2), visit(12, 1, 'Second', 1, 2)] },
[{ place_lat: null, place_lng: null, place_name: 'Nowhere' } as unknown as Accommodation],
[{ endpoints: [{ lat: 3, lng: 4, name: '' }] } as unknown as Reservation, {} as Reservation],
)
expect(names.get('1,2')).toBe('First')
expect(names.size).toBe(1)
})
})
describe('buildTransitLeg', () => {
const assignments = {
'1': [visit(11, 1, 'Louvre', 48.86, 2.34, '9:05'), visit(12, 1, 'Orsay', 48.87, 2.33)],
'2': [visit(21, 2, 'Louvre', 48.86, 2.34, '15:30:00')],
}
const names = buildTransitNameIndex(assignments, [], [])
it('FE-PLANNER-TRANSITLEG-003: seeds both ends and the origin\'s time on that day, as HH:mm', () => {
expect(buildTransitLeg(seg([48.86, 2.34], [48.87, 2.33]), 1, names, assignments, [])).toEqual({
from: { name: 'Louvre', lat: 48.86, lng: 2.34 },
to: { name: 'Orsay', lat: 48.87, lng: 2.33 },
time: '09:05',
})
// The same place on another day carries that day's time.
expect(buildTransitLeg(seg([48.86, 2.34], [48.87, 2.33]), 2, names, assignments, [])?.time).toBe('15:30')
})
it('FE-PLANNER-TRANSITLEG-004: takes the time from a booking endpoint that touches the day', () => {
const reservations = [
{ day_id: 9, end_day_id: null, endpoints: [{ lat: 1, lng: 1, local_time: '07:00' }] },
{ day_id: 3, end_day_id: 1, endpoints: [{ lat: 1, lng: 1, local_time: '18:45' }] },
] as unknown as Reservation[]
expect(buildTransitLeg(seg([1, 1], [2, 2]), 1, new Map(), {}, reservations)).toEqual({
from: { name: '', lat: 1, lng: 1 },
to: { name: '', lat: 2, lng: 2 },
time: '18:45',
})
})
it('FE-PLANNER-TRANSITLEG-005: leaves the time empty when the origin has none', () => {
expect(buildTransitLeg(seg([48.87, 2.33], [48.86, 2.34]), 1, names, assignments, [])?.time).toBeNull()
})
it('FE-PLANNER-TRANSITLEG-006: answers nothing for a leg without both ends', () => {
expect(buildTransitLeg(undefined, 1, names, assignments, [])).toBeNull()
expect(buildTransitLeg({ from: [1, 1] } as unknown as RouteSegment, 1, names, assignments, [])).toBeNull()
})
})
@@ -0,0 +1,81 @@
import type { Accommodation, Assignment, Reservation, RouteSegment } from '../../types'
import type { PickedPlace } from './TransitSearchPanel'
/** What the transit search is opened with for one leg of a day. */
export interface TransitLeg {
from: PickedPlace
to: PickedPlace
time: string | null
}
/**
* 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).
*/
export function buildTransitNameIndex(
assignments: Record<string, Assignment[]>,
accommodations: Accommodation[],
reservations: Reservation[],
): Map<string, string> {
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
}
/**
* 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.
*/
function originDepartureTime(
originKey: string,
dayId: number,
assignments: Record<string, Assignment[]>,
reservations: Reservation[],
): 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
}
/**
* 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'. Shared by
* the desktop day plan and the phone timeline, so both open the search alike.
*/
export function buildTransitLeg(
seg: RouteSegment | undefined,
dayId: number,
names: Map<string, string>,
assignments: Record<string, Assignment[]>,
reservations: Reservation[],
): TransitLeg | null {
if (!seg?.from || !seg?.to) return null
const pick = (c: [number, number]): PickedPlace => ({ name: names.get(`${c[0]},${c[1]}`) || '', lat: c[0], lng: c[1] })
const rawTime = originDepartureTime(`${seg.from[0]},${seg.from[1]}`, dayId, assignments, reservations)
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 }
}
@@ -1,4 +1,4 @@
// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-051
// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-052
import { http, HttpResponse } from 'msw';
import userEvent from '@testing-library/user-event';
import { render, screen, fireEvent, act, waitFor } from '../../../tests/helpers/render';
@@ -333,6 +333,21 @@ describe('usePlacesSidebar day helpers', () => {
expect(S.inDaySet.size).toBe(0);
expect(S.isAssignedToSelectedDay(5)).toBe(false);
});
it('FE-PLANNER-PSHOOK-052: the stop a booking wrote counts as in the day when the list hides it', () => {
// The day view hands the pool the day without the stop a booking wrote, while
// the store still holds it. Judged on the list alone the hotel offered "add to
// day" on its own check-in day, and that put a second row beside the night.
const hotel = buildPlace({ id: 5, name: 'Hotel' });
const loose = buildPlace({ id: 6, name: 'Loose' });
const booked = buildAssignment({ place: hotel, day_id: 3, accommodation_id: 4 });
seedStore(useTripStore, { assignments: { '3': [booked] } });
render(<Host {...makeProps({ places: [hotel, loose], assignments: { '3': [] }, selectedDayId: 3 })} />);
expect([...S.inDaySet]).toEqual([5]);
expect(S.isAssignedToSelectedDay(5)).toBe(true);
expect(S.isAssignedToSelectedDay(6)).toBe(false);
});
});
// ── Scroll restoration and row refs ───────────────────────────────────────────
@@ -307,16 +307,24 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
lastAutoScrolledPlaceIdRef.current = props.selectedPlaceId
}, [filtered, props.selectedPlaceId])
const isAssignedToSelectedDay = (placeId) =>
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
const selectedDayIdRef = useRef<number | null>(selectedDayId)
useEffect(() => { selectedDayIdRef.current = selectedDayId }, [selectedDayId])
// The day list handed in is the one the planner shows, and in the day view that
// list leaves out the stop a booking wrote. The store still holds it, so the set
// that decides between "in the day" and the add button reads the day from there
// as well: on the list alone the hotel of a booked night offered "add to day" on
// its own check-in day, and taking that offer put a second row beside the night.
const storedDayAssignments = useTripStore((s) => (selectedDayId ? s.assignments[String(selectedDayId)] : undefined))
const inDaySet = useMemo(() => {
if (!selectedDayId) return new Set<number>()
return new Set<number>((assignments[String(selectedDayId)] || []).map((a: any) => a.place?.id).filter(Boolean))
}, [assignments, selectedDayId])
const ids = new Set<number>((assignments[String(selectedDayId)] || []).map((a: any) => a.place?.id).filter(Boolean))
for (const a of storedDayAssignments ?? []) if (a.place?.id) ids.add(a.place.id)
return ids
}, [assignments, storedDayAssignments, selectedDayId])
const isAssignedToSelectedDay = (placeId) => inDaySet.has(placeId)
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
const selDayId = selectedDayIdRef.current
@@ -145,14 +145,17 @@ describe('RoadtripLimitsCard', () => {
})
it('FE-ROADTRIP-LIMITS-008: without a way to save, the dialog is read-only', () => {
useSettingsStore.setState({
settings: { roadtrip_leg_minutes: 180, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'metric' } as never,
})
// `onSave` absent is how the caller says the reader may look but not change.
open(undefined)
const [leg] = inputs()
fireEvent.change(leg, { target: { value: '180' } })
// Nothing to assert but the absence of a crash: the commit path has no
// handler to call, and must not assume one.
expect(() => fireEvent.blur(leg)).not.toThrow()
// Disabled rather than accepting a number it would then drop: a field that took 400
// and sprang back to 180 on Enter, with no word about why, read as broken.
for (const field of inputs()) expect(field).toBeDisabled()
// What is stored is still there to be read.
expect(screen.getByTestId('limit-legMinutes')).toHaveValue(180)
})
it('FE-ROADTRIP-LIMITS-009: naming the kind of car brings out its own figures', () => {
@@ -275,6 +278,42 @@ describe('RoadtripLimitsCard', () => {
expect(onSave).toHaveBeenCalledWith('roadtrip_connect_days', false)
})
})
/**
* A member without the right to edit days gets the dialog without a way to save. Every
* control has to say so by being disabled: a switch that flips and stays off, or a
* field that takes a number and springs back, is a fault report waiting to happen.
*/
describe('read-only', () => {
it('FE-ROADTRIP-LIMITS-016: the kind of car and its figures cannot be changed', () => {
useSettingsStore.setState({
settings: { roadtrip_vehicle: 'electric', roadtrip_battery_kwh: 58, distance_unit: 'metric' } as never,
})
open(undefined)
for (const name of ['Either', 'Petrol', 'Electric']) {
expect(screen.getByRole('radio', { name })).toBeDisabled()
}
// The stored figure holds the disclosure open, so the car's own rows are on show.
expect(screen.getByTestId('limit-batteryKwh')).toBeDisabled()
expect(screen.getByTestId('limit-batteryKwh')).toHaveValue(58)
expect(screen.getByTestId('limit-fill')).toBeDisabled()
})
it('FE-ROADTRIP-LIMITS-017: the switches over the route line are not offered', () => {
useSettingsStore.setState({
settings: { roadtrip_connect_days: true, distance_unit: 'metric' } as never,
})
open(undefined)
expect(screen.queryByRole('button', { name: 'Connect the days' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'A colour per day' })).not.toBeInTheDocument()
// The rows stay in the dialog, the same way the service-stop and hazard rows do
// without a way to save: a switch withheld is not a setting that vanished.
expect(screen.getByText('Connect the days')).toBeInTheDocument()
expect(screen.getByText('A colour per day')).toBeInTheDocument()
})
})
})
vi.mock('../../hooks/useRoadtripSettings', () => ({
@@ -52,7 +52,7 @@ function parseLimit(raw: string): number {
* its shape either way, because a range that switched between an input and a line of text
* would move every row under it the moment a consumption was typed.
*/
function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived, testId, onDraft, onChange }: {
function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived, disabled, testId, onDraft, onChange }: {
icon: typeof Clock
label: string
suffix: string
@@ -60,6 +60,12 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
placeholder: string
step?: number
derived?: string
/**
* Readable but not editable, which is how a reader without the right to change the
* settings sees the dialog. Nothing this row could commit would be saved, and a field
* that takes a number and springs back to the old one reads as broken.
*/
disabled?: boolean
/**
* A stable handle for the tests, because the rows are no longer at fixed indices.
*
@@ -133,6 +139,7 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
placeholder={placeholder}
aria-label={`${label}, ${suffix}`}
data-testid={testId}
disabled={disabled}
onChange={e => { setDraft(e.target.value); onDraft?.(parseLimit(e.target.value)) }}
onBlur={commit}
// Committed here rather than by blurring and letting onBlur do it: a
@@ -141,7 +148,7 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
// The spinners are the other half of the width problem: they eat a third of a
// narrow field and nobody sets a driving limit by clicking an arrow.
className="w-full rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-end text-body tabular-nums text-content [appearance:textfield] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
className="w-full rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-end text-body tabular-nums text-content [appearance:textfield] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
)}
</span>
@@ -269,6 +276,11 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
const [open, setOpen] = useState(false)
const distanceUnit: DistanceUnit = unit === 'imperial' ? 'imperial' : 'metric'
const imperial = distanceUnit === 'imperial'
// No way to save is the caller saying the reader may look but not change. Every
// control below is disabled on that one answer: a member without the right to edit
// days gets the dialog with the trip's figures in it, not a form that takes a number
// and drops it.
const readOnly = !onSave
const legMinutes = settings.roadtrip_leg_minutes
const dayMinutes = settings.roadtrip_day_minutes
@@ -448,6 +460,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
suffix={t('roadtrip.limit.minutes')}
value={legMinutes}
placeholder={t('roadtrip.limit.off')}
disabled={readOnly}
testId="limit-legMinutes"
onChange={v => onSave?.('roadtrip_leg_minutes', v)}
/>
@@ -457,6 +470,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
suffix={t('roadtrip.limit.minutes')}
value={dayMinutes}
placeholder={t('roadtrip.limit.off')}
disabled={readOnly}
testId="limit-dayMinutes"
onChange={v => onSave?.('roadtrip_day_minutes', v)}
/>
@@ -478,7 +492,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
icon={icon}
label={label}
on={avoiding.includes(cls)}
disabled={!canAvoid}
disabled={!canAvoid || readOnly}
onToggle={() => toggleAvoid(cls)}
/>
))}
@@ -486,7 +500,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
<Panel icon={Signpost} title={t('roadtrip.stops.section')} note={t('roadtrip.stops.daysHint')}>
<AvoidRow icon={Link2} label={t('roadtrip.stops.inDays')}
on={settings.roadtrip_service_stops_in_days !== false}
disabled={!onSave}
disabled={readOnly}
onToggle={() => onSave?.('roadtrip_service_stops_in_days', settings.roadtrip_service_stops_in_days === false)}
/>
</Panel>
@@ -516,6 +530,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
name="roadtrip-vehicle"
className="peer sr-only"
checked={(settings.roadtrip_vehicle || '') === (key ?? '')}
disabled={readOnly}
onChange={() => {
// Never writes the car's figures away: switching kind leaves
// both sets stored, `rangeFromSpec` simply returns null for
@@ -526,7 +541,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
onSave?.('roadtrip_vehicle', key ?? '')
}}
/>
<span className="flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-center text-body text-content-secondary transition-colors hover:bg-surface-hover peer-checked:bg-accent peer-checked:text-accent-text peer-focus-visible:ring-2 peer-focus-visible:ring-accent">
<span className="flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-center text-body text-content-secondary transition-colors hover:bg-surface-hover peer-checked:bg-accent peer-checked:text-accent-text peer-focus-visible:ring-2 peer-focus-visible:ring-accent peer-disabled:pointer-events-none">
<Icon size={14} className="shrink-0" aria-hidden />
{t(labelKey)}
</span>
@@ -555,6 +570,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
value={rangeShown}
placeholder={t('roadtrip.limit.off')}
derived={computedKm ? t('roadtrip.limit.computed') : undefined}
disabled={readOnly}
testId="limit-range"
onDraft={v => setPreview({ key: 'range', value: v })}
onChange={setRange}
@@ -569,6 +585,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
suffix="%"
value={settings.roadtrip_fill_percent}
placeholder={t('roadtrip.limit.fillFull')}
disabled={readOnly}
testId="limit-fill"
onDraft={v => setPreview({ key: 'fill', value: v > 100 ? 100 : v })}
onChange={v => onSave?.('roadtrip_fill_percent', v > 100 ? 100 : v)}
@@ -611,6 +628,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
step={step}
value={showSpec(key, saved[key], imperial)}
placeholder={t('roadtrip.limit.off')}
disabled={readOnly}
testId={`limit-${key}`}
onDraft={v => setPreview({ key, value: v })}
onChange={v => onSave?.(setting, storeSpec(key, v, imperial))}
@@ -636,7 +654,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
icon={Link2}
label={t('roadtrip.line.connect')}
on={!!settings.roadtrip_connect_days}
disabled={false}
disabled={readOnly}
onToggle={() => onSave?.('roadtrip_connect_days', !settings.roadtrip_connect_days)}
/>}
{/* Which matters most once the line IS continuous: end to end it is one
@@ -645,12 +663,12 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
icon={Palette}
label={t('roadtrip.line.dayColors')}
on={!!settings.roadtrip_day_colors}
disabled={false}
disabled={readOnly}
onToggle={() => onSave?.('roadtrip_day_colors', !settings.roadtrip_day_colors)}
/>
</Panel>
<Panel icon={Signpost} title={t('roadtrip.hazards.current')} note={t('roadtrip.hazards.note')}>
<AvoidRow icon={Signpost} label={t('roadtrip.hazards.show')} on={settings.roadtrip_show_hazards === true} disabled={!onSave}
<AvoidRow icon={Signpost} label={t('roadtrip.hazards.show')} on={settings.roadtrip_show_hazards === true} disabled={readOnly}
onToggle={() => onSave?.('roadtrip_show_hazards', !settings.roadtrip_show_hazards)} />
</Panel>
@@ -322,7 +322,10 @@ describe('RoadtripSidebar', () => {
const add = within(chain).getByText('+')
fireEvent.click(add)
expect(onEditStay).toHaveBeenCalledWith({ placeId: 20, name: 'Berlin', minutes: null, arrival: null })
// The visit rides along with the place, so the dialog can reach its end time.
expect(onEditStay).toHaveBeenCalledWith({
placeId: 20, name: 'Berlin', minutes: null, arrival: null, departure: null, leaveAt: null, missedBy: null, assignmentId: 2, dayId: 1,
})
})
it('FE-ROADTRIP-SIDEBAR-034: a pause reached late says so, the same as a numbered stop', () => {
@@ -342,6 +345,53 @@ describe('RoadtripSidebar', () => {
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-049: a stop left at a set time shows the stay that makes, and until when', () => {
// The stay the place carries is 30 min; the End at two is what the drive keeps to.
const onEditStay = vi.fn()
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Lueneburg', dwellMinutes: 30, leaveAt: '14:00' }),
]
const schedule = {
entries: [
{ arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 },
{ arrival: '10:00', departure: '14:00', anchored: false, dayOffset: 0 },
],
warnings: [],
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} onEditStay={onEditStay} />)
expect(screen.getByText('4 h')).toBeInTheDocument()
expect(screen.getByText('until 14:00')).toBeInTheDocument()
expect(screen.queryByText('30 min')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Time at this stop: 4 h until 14:00' }))
expect(onEditStay).toHaveBeenCalledWith(expect.objectContaining({ leaveAt: '14:00', arrival: '10:00', departure: '14:00' }))
})
it('FE-ROADTRIP-SIDEBAR-050: a stop reached after the time it is left at says so, beside a late arrival', () => {
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', leaveAt: '14:00', time: '14:00' }),
]
const schedule = {
entries: stops.map(() => ({ arrival: '14:30', departure: '14:30', anchored: true, dayOffset: 0 })),
warnings: [
{ index: 1, code: 'late' as const, minutes: 30 },
{ index: 1, code: 'missedLeave' as const, minutes: 30 },
],
}
const onEditStay = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule, legs: [leg()] })] })} onEditStay={onEditStay} />)
expect(screen.getByLabelText('Arrives 30 min after the time you set')).toBeInTheDocument()
expect(screen.getByLabelText('Arrives 30 min after the time you set to leave')).toBeInTheDocument()
// Left the moment it is reached: a zero, until the time it was meant to go.
expect(screen.getByText('0 min')).toBeInTheDocument()
// The dialog is handed the schedule's own finding, so it can say the same.
fireEvent.click(screen.getByRole('button', { name: 'Time at this stop: 0 min until 14:00' }))
expect(onEditStay).toHaveBeenCalledWith(expect.objectContaining({ leaveAt: '14:00', missedBy: 30 }))
})
it('FE-ROADTRIP-SIDEBAR-033: without the right to edit, a stop with no stay shows nothing', () => {
const stops = [
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
@@ -22,7 +22,7 @@ import { spurWorthLabelling } from './accessSpur'
import StopKindPicker from './StopKindPicker'
import StopFillPicker from './StopFillPicker'
import { useVehicleRange } from './useVehicleRange'
import type { RoadtripStopType } from '@trek/shared'
import { MAX_TRIP_DAYS, type RoadtripStopType } from '@trek/shared'
import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
import type { SpillMark } from './nightSpill'
import { dayColor } from './dayColors'
@@ -31,6 +31,8 @@ import { FS } from './typeScale'
import type { RouteSegment } from '../../types'
import EmptyState from '../shared/EmptyState'
import AutomaticDayStop from './AutomaticDayStop'
import type { StayDraft } from './RoadtripStayModal'
import { missedLeaveOf, readStay, shownStay, stayDraftOf, type StayReading } from './stayReading'
interface RoadtripSidebarProps {
onFocusPoint?: (lat: number, lng: number) => void
@@ -62,7 +64,7 @@ interface RoadtripSidebarProps {
* Opens the dialog for how long a stop takes. Absent leaves every stay read-only —
* which is also what a viewer sees.
*/
onEditStay?: (stop: { placeId: number; name: string; minutes: number | null; arrival: string | null }) => void
onEditStay?: (stop: StayDraft) => void
/**
* Turns a stop into a pause on the drive, or back into a destination.
*
@@ -280,10 +282,17 @@ function FillBadge({ percent, own, onEdit }: {
)
}
function StayBadge({ minutes, onEdit }: { minutes: number | null; onEdit?: () => void }): React.ReactElement | null {
/**
* How long the stop takes. A stop the traveller leaves at a set time is stood at until
* then, so it reads the stay the schedule made of that and says until when.
*/
function StayBadge({ stay, onEdit }: { stay: StayReading; onEdit?: () => void }): React.ReactElement | null {
const { t } = useTranslation()
const text = minutes ? formatDurationShort(minutes * 60) : null
if (!text && !onEdit) return null
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const shown = shownStay(stay)
const text = shown === null ? null : formatDurationShort(shown * 60)
const until = stay.until ? t('roadtrip.stay.until', { time: formatClockTime(stay.until, is12h) }) : null
if (!text && !until && !onEdit) return null
const shell = 'inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge'
const label = (
@@ -301,7 +310,8 @@ function StayBadge({ minutes, onEdit }: { minutes: number | null; onEdit?: () =>
}`}
style={{ fontSize: FS.label }}
>
{text ?? '+'}
{text ?? (until ? null : '+')}
{until ? <span className={`font-medium text-content-faint ${text ? 'ms-1' : ''}`}>{until}</span> : null}
</span>
)
@@ -323,7 +333,7 @@ function StayBadge({ minutes, onEdit }: { minutes: number | null; onEdit?: () =>
e.stopPropagation()
onEdit()
}}
aria-label={text ? `${t('roadtrip.stop.stay')}: ${text}` : t('roadtrip.stay.add')}
aria-label={text || until ? `${t('roadtrip.stop.stay')}: ${[text, until].filter(Boolean).join(' ')}` : t('roadtrip.stay.add')}
className={`${shell} cursor-pointer transition-colors hover:border-content-faint`}
>
{label}{value}
@@ -749,9 +759,10 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onEditStay, onPickKind, onPickFill }: {
stop: RoadtripStop
entry: ScheduleEntry | undefined
/** How late the drive reaches a time pinned on this pause, the same finding a numbered
* stop shows. A fuel or charging halt can carry a pinned time like anything else. */
late: ScheduleWarning | undefined
/** How late the drive reaches a time pinned on this pause, or the time it was set to
* be left at: the same findings a numbered stop shows. A fuel or charging halt can
* carry a pinned time like anything else. */
late: ScheduleWarning[]
/** Findings about the drive that ARRIVES here. A charging halt is a stop like any other
* as far as the tank is concerned, so it carries them the same way a numbered one does. */
driveFindings?: ScheduleWarning[]
@@ -834,7 +845,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
<span className="min-w-0 truncate">{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}
</span>
<span className="flex flex-wrap items-center gap-1">
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
<StayBadge stay={readStay(stop, entry)} onEdit={onEditStay} />
{refuelsRange(stop.stopType, vehicleKind) ? (
<FillBadge
percent={effectiveFill(stop.fillPercent, fillPercent)}
@@ -844,7 +855,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
) : null}
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
<LateBadge late={late} />
{late.map(w => <LateBadge key={w.code} late={w} />)}
</span>
</span>
{entry?.arrival ? <Arrival entry={entry} /> : null}
@@ -985,7 +996,8 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
/** Position within the day — the same count the map badges its markers with. */
number: number
entry: ScheduleEntry | undefined
late: ScheduleWarning | undefined
/** Arriving after a pinned time, or after the time this stop was set to be left at. */
late: ScheduleWarning[]
/** Findings about the drive LEAVING this stop, when the limits are set and it goes over. */
driveFindings?: ScheduleWarning[]
selected: boolean
@@ -1089,7 +1101,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
enough to change the plan. The dashed line on the map already says there is
one; the number is for luggage, a gate, a track a hire car should not be on. */}
<span className="flex flex-wrap items-center gap-1">
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
<StayBadge stay={readStay(stop, entry)} onEdit={onEditStay} />
{refuelsRange(stop.stopType, vehicleKind) ? (
<FillBadge
percent={effectiveFill(stop.fillPercent, fillPercent)}
@@ -1099,7 +1111,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
) : null}
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
<LateBadge late={late} />
{late.map(w => <LateBadge key={w.code} late={w} />)}
</span>
</span>
{entry?.arrival ? <Arrival entry={entry} /> : null}
@@ -1118,10 +1130,9 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
* its own: they are all answers to "what does this stop cost", and a warning on its own row
* pushed every following stop down for a finding that fits in a pill.
*/
function LateBadge({ late }: { late: ScheduleWarning | undefined }): React.ReactElement | null {
function LateBadge({ late }: { late: ScheduleWarning }): React.ReactElement {
const { t } = useTranslation()
if (!late) return null
const label = t('roadtrip.warn.late', { minutes: late.minutes ?? 0 })
const label = t(late.code === 'missedLeave' ? 'roadtrip.warn.missedLeave' : 'roadtrip.warn.late', { minutes: late.minutes ?? 0 })
return (
<Tooltip label={label}>
<span
@@ -1348,8 +1359,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
const service = isServiceStopType(stop.stopType)
if (!service) counted += 1
// Every finding at this index is read on its own. Taking the first match let an
// overnight crossing swallow the "you arrive late" flag without a trace.
const marks = day.schedule.warnings.filter(w => w.index === i)
// overnight crossing swallow the "you arrive late" flag without a trace, and a stop
// can be late for the time it is reached and the time it is left at both at once.
const lateness = day.schedule.warnings.filter(w => w.index === i && (w.code === 'late' || w.code === 'missedLeave'))
const ownDay = stop.ownerDayId
const ownIndex = stop.ownerIndex
return (
@@ -1388,11 +1400,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
<ServiceStop
stop={stop}
entry={day.schedule.entries[i]}
late={marks.find(w => w.code === 'late')}
late={lateness}
driveFindings={findingsFor(i)}
selected={selectedAssignmentId === stop.assignmentId}
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined}
onEditStay={onEditStay ? () => onEditStay(stayDraftOf(stop, day.schedule.entries[i], missedLeaveOf(day, i))) : undefined}
onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined}
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
/>
@@ -1401,7 +1413,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
stop={stop}
number={counted}
entry={day.schedule.entries[i]}
late={marks.find(w => w.code === 'late')}
late={lateness}
driveFindings={findingsFor(i)}
selected={selectedAssignmentId === stop.assignmentId}
continues={i < last}
@@ -1409,7 +1421,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
onMove={onReorderStop ? delta => onReorderStop(ownDay, stop.assignmentId, ownIndex + delta) : undefined}
canMove={{ up: i > 0, down: i < last }}
onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined}
onEditStay={onEditStay ? () => onEditStay(stayDraftOf(stop, day.schedule.entries[i], missedLeaveOf(day, i))) : undefined}
onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined}
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
/>
@@ -1760,7 +1772,7 @@ export default function RoadtripSidebar({
<TripSummary routes={routes} />
{routes.dayWindowIssue ? (
<p role="status" className="mx-3.5 mt-2 rounded-xl bg-warning-soft p-3 text-caption text-content">
{t(`roadtrip.window.${routes.dayWindowIssue}`)}
{t(`roadtrip.window.${routes.dayWindowIssue}`, { days: MAX_TRIP_DAYS })}
</p>
) : null}
</div>
@@ -2,10 +2,14 @@ import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { usePermissionsStore } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import RoadtripStayModal, { type StayDraft } from './RoadtripStayModal'
/**
* FE-STAYMODAL-001..011 — how long the traveller stays at one stop.
* FE-STAYMODAL-001..022: how long the traveller stays at one stop.
*
* The value is `places.duration_minutes`, which the schedule has read for years
* and nothing could write. Two things are worth pinning: that clearing sends a
@@ -158,3 +162,93 @@ describe('RoadtripStayModal', () => {
expect(screen.queryByText('+1')).not.toBeInTheDocument()
})
})
describe('RoadtripStayModal for a stop left at a set time', () => {
// The visit's End is when the drive leaves it, so there is no length to choose: the
// stay is what the time leaves of it. What the dialog offers instead is taking the end
// time off, after which the stay is a choice again.
const leaving = { minutes: 30, arrival: '10:00', departure: '14:00', leaveAt: '14:00', assignmentId: 102, dayId: 11 }
let setAssignmentTimes: ReturnType<typeof vi.fn>
beforeEach(() => {
resetAllStores()
useSettingsStore.setState({ settings: { time_format: '24h' } as never })
setAssignmentTimes = vi.fn(async () => undefined)
seedStore(useAuthStore, { user: { id: 1, role: 'user' } })
seedStore(useTripStore, {
trip: { id: 4, user_id: 1 },
assignments: { '11': [{ id: 102, day_id: 11, place_id: 9, order_index: 0, assignment_time: null, assignment_end_time: '14:00' }] },
})
useTripStore.setState({ setAssignmentTimes, refreshDays: vi.fn(async () => undefined) } as never)
})
it('FE-STAYMODAL-015: shows the stay the time makes and when the drive leaves, with nothing to slide', () => {
open(leaving)
expect(screen.getByText('4 h')).toBeInTheDocument()
expect(screen.getByText('This visit has an end time, so the drive leaves at 14:00.')).toBeInTheDocument()
expect(screen.getByText('10:00')).toBeInTheDocument()
expect(screen.getByText('14:00')).toBeInTheDocument()
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^save$/i })).not.toBeInTheDocument()
})
it('FE-STAYMODAL-016: taking the end time off writes it through the visit and brings the slider back', async () => {
const { onSave } = open(leaving)
fireEvent.click(screen.getByRole('button', { name: 'Remove end time' }))
await waitFor(() => expect(setAssignmentTimes).toHaveBeenCalledWith(4, 11, 102, { place_time: null, end_time: null }))
// The stay the place carries is where the slider starts again.
await waitFor(() => expect(slider().value).toBe('30'))
expect(onSave).not.toHaveBeenCalled()
})
it('FE-STAYMODAL-017: a traveller who may not change the day can read it but not remove it', () => {
usePermissionsStore.setState({ permissions: { day_edit: 'trip_owner' } })
seedStore(useTripStore, { trip: { id: 4, user_id: 2 } })
open(leaving)
expect(screen.getByText('4 h')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Remove end time' })).not.toBeInTheDocument()
})
it('FE-STAYMODAL-018: without an arrival it can only say until when', () => {
open({ ...leaving, arrival: null, departure: '14:00' })
expect(screen.getByText('until 14:00')).toBeInTheDocument()
expect(screen.queryByText('10:00')).not.toBeInTheDocument()
})
it('FE-STAYMODAL-019: closing changes nothing', () => {
const { onClose, onSave } = open(leaving)
fireEvent.click(screen.getByRole('button', { name: /^close$/i }))
expect(onClose).toHaveBeenCalled()
expect(onSave).not.toHaveBeenCalled()
expect(setAssignmentTimes).not.toHaveBeenCalled()
})
it('FE-STAYMODAL-020: reached after the time, it says so and shows the departure the drive actually makes', () => {
open({ ...leaving, arrival: '14:30', departure: '14:30', missedBy: 30 })
expect(screen.getByText('0 min')).toBeInTheDocument()
expect(screen.getAllByText('14:30')).toHaveLength(2)
// One departure, not two: the promise of 14:00 is gone, the finding takes its place.
expect(screen.getByText('Arrives 30 min after the time you set to leave')).toBeInTheDocument()
expect(screen.queryByText('This visit has an end time, so the drive leaves at 14:00.')).not.toBeInTheDocument()
})
it('FE-STAYMODAL-021: travel hours over before the time say so, with no length and no departure', () => {
// The window shut at 18:00, the End is 20:00: the drive goes on the next morning.
open({ ...leaving, arrival: '10:30', departure: '18:00', leaveAt: '20:00' })
expect(screen.getByText('until 20:00')).toBeInTheDocument()
expect(screen.getByText('The travel day ends before 20:00, so the drive goes on the next morning.')).toBeInTheDocument()
expect(screen.queryByText('18:00')).not.toBeInTheDocument()
expect(screen.queryByText('7 h 30 min')).not.toBeInTheDocument()
})
it('FE-STAYMODAL-022: an End the place carries too goes with the removal, or the slider would not be the truth', async () => {
const updatePlace = vi.fn(async () => undefined)
seedStore(useTripStore, { places: [{ id: 9, name: 'Rasthof Dammer Berge', end_time: '14:00' }] })
useTripStore.setState({ updatePlace } as never)
open(leaving)
fireEvent.click(screen.getByRole('button', { name: 'Remove end time' }))
await waitFor(() => expect(updatePlace).toHaveBeenCalledWith(4, 9, { end_time: null }))
expect(setAssignmentTimes).toHaveBeenCalledWith(4, 11, 102, { place_time: null, end_time: null })
await waitFor(() => expect(slider().value).toBe('30'))
})
})
@@ -1,10 +1,11 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Hourglass, Minus, Plus, ArrowRight } from 'lucide-react'
import { AlertTriangle, Hourglass, Minus, Plus, ArrowRight } from 'lucide-react'
import Modal from '../shared/Modal'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDurationShort, formatClock, parseClock } from './roadtripModel'
import { formatClockTime } from '../../utils/formatters'
import { useLeaveMode, type LeaveMode } from './useLeaveMode'
/**
* The lengths a stop usually takes, so the common answer is one tap.
@@ -33,6 +34,15 @@ export interface StayDraft {
minutes: number | null
/** When the drive gets here, so the dialog can show what the stay pushes back. */
arrival?: string | null
/** When the schedule has it leave again. */
departure?: string | null
/** The time the visit is set to be left at, which makes the stay rather than taking one. */
leaveAt?: string | null
/** How many minutes after that time the drive gets here, when the schedule found it cannot make it. */
missedBy?: number | null
assignmentId?: number
/** The day the visit is stored on. */
dayId?: number
}
interface RoadtripStayModalProps {
@@ -75,6 +85,7 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [minutes, setMinutes] = useState(0)
const [saving, setSaving] = useState(false)
const leave = useLeaveMode(stop)
// Reopened on a different stop, so it starts from that stop's own value rather than
// from whatever the last one was left on.
@@ -97,6 +108,7 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
}, [stop?.arrival, minutes, is12h])
if (!stop) return null
if (leave.until) return <LeaveTime stop={stop} leave={leave} until={leave.until} is12h={is12h} onClose={onClose} />
const commit = async (value: number) => {
if (saving) return
@@ -163,31 +175,7 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
{/* What the stay costs the rest of the day. The arrival cannot move — the drive
decides it — so the arrow shows the one end this dialog does move. */}
{times ? (
<div className="flex items-center justify-center gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-3 py-2.5">
<span className="flex flex-col items-center gap-0.5">
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.arrive')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content-secondary">{times.arrive}</span>
</span>
<ArrowRight size={14} className="mt-3 shrink-0 text-content-faint" aria-hidden />
<span className="flex flex-col items-center gap-0.5">
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.leave')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">
{times.leave}
{times.leaveCarry > 0 ? (
<span className="ms-0.5">
{`+${times.leaveCarry}`}
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
</span>
) : null}
</span>
</span>
</div>
) : null}
{times ? <ArriveLeave arrive={times.arrive} leave={times.leave} carry={times.leaveCarry} /> : null}
<div className="flex flex-wrap justify-center gap-1.5">
{PRESETS.map(value => (
@@ -237,3 +225,106 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
</Modal>
)
}
/**
* The dialog for a stop the traveller leaves at a set time.
*
* Nothing to choose: the stay is whatever is left between the arrival and that time, so
* it is shown rather than offered. The one thing to do here is take the end time off the
* visit, and the ordinary dialog takes over the moment that lands.
*
* The sentence under the number says what the drive actually does. Reached too late it
* leaves on arrival, which is the case this dialog gets opened to fix; with the travel
* hours over first it goes on the next morning. Saying "leaves at 14:00" over a box that
* reads 14:30 gave two departures for one stop.
*/
function LeaveTime({ stop, leave, until: leaveAt, is12h, onClose }: {
stop: StayDraft
leave: LeaveMode
until: string
is12h: boolean
onClose: () => void
}): React.ReactElement {
const { t } = useTranslation()
const until = formatClockTime(leaveAt, is12h)
const arrive = stop.arrival ? formatClockTime(stop.arrival, is12h) : null
const departure = stop.departure ? formatClockTime(stop.departure, is12h) : until
return (
<Modal isOpen onClose={onClose} size="sm" title={
<span className="flex items-center gap-2">
<Hourglass size={15} className="text-content-faint" aria-hidden />
{t('roadtrip.stop.stay')}
</span>
}>
<div className="flex flex-col gap-5">
<p className="break-words text-center text-caption text-content-muted">{stop.name}</p>
<span className="text-center text-title font-semibold tabular-nums tracking-tight text-content">
{leave.minutes === null ? t('roadtrip.stay.until', { time: until }) : formatDurationShort(leave.minutes * 60)}
</span>
{leave.missedBy !== null ? (
<p className="flex items-center justify-center gap-1.5 text-center text-body font-medium text-warning">
<AlertTriangle size={14} className="shrink-0" aria-hidden />
{t('roadtrip.warn.missedLeave', { minutes: leave.missedBy })}
</p>
) : (
<p className="text-center text-body text-content-secondary">
{t(leave.dayEndsFirst ? 'roadtrip.stay.dayEndsFirst' : 'roadtrip.stay.leavesAt', { time: until })}
</p>
)}
{/* The day ending first means the row's departure is only where it stops for the
night, not a time it leaves. */}
{arrive && !leave.dayEndsFirst ? <ArriveLeave arrive={arrive} leave={departure} /> : null}
</div>
<div className="mt-6 flex items-center gap-2">
{leave.remove ? (
<button
type="button"
disabled={leave.removing}
onClick={() => void leave.remove?.()}
className="rounded-lg px-3 py-2 text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40"
>
{t('roadtrip.stay.clearLeave')}
</button>
) : null}
<button
type="button"
onClick={onClose}
className="ms-auto rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
{t('common.close')}
</button>
</div>
</Modal>
)
}
/** Arrival, arrow, departure: the two ends of the stay, with the day the second lands on. */
function ArriveLeave({ arrive, leave, carry = 0 }: { arrive: string; leave: string; carry?: number }): React.ReactElement {
const { t } = useTranslation()
return (
<div className="flex items-center justify-center gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-3 py-2.5">
<span className="flex flex-col items-center gap-0.5">
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.arrive')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content-secondary">{arrive}</span>
</span>
<ArrowRight size={14} className="mt-3 shrink-0 text-content-faint" aria-hidden />
<span className="flex flex-col items-center gap-0.5">
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.leave')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">
{leave}
{carry > 0 ? (
<span className="ms-0.5">
{`+${carry}`}
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
</span>
) : null}
</span>
</span>
</div>
)
}
@@ -17,3 +17,14 @@ it('keeps stay editing when daily travel times are off', () => {
expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort +' })).toBeEnabled()
expect(screen.queryByRole('button', { name: 'roadtrip.window.endHere' })).not.toBeInTheDocument()
})
it('reads the stay a set leave time makes, and says until when', () => {
render(<VisitControls stay={{ minutes: 240, until: '14:00', onEdit: vi.fn() }} />)
expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort 4 h roadtrip.stay.until' })).toBeEnabled()
})
it('says only until when while the drive has not timed the arrival', () => {
render(<VisitControls stay={{ minutes: null, until: '14:00' }} />)
expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort roadtrip.stay.until' })).toBeDisabled()
expect(screen.queryByText('+')).not.toBeInTheDocument()
})
@@ -1,23 +1,31 @@
import { Clock, Pencil } from 'lucide-react'
import EndDayControl, { type EndDayControlProps } from './EndDayControl'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatClockTime } from '../../utils/formatters'
import { formatDurationShort } from './roadtripModel'
export interface RoadtripStayControl {
minutes: number | null
/** The time the visit is set to be left at, which is what makes its stay. */
until?: string | null
onEdit?: () => void
}
export default function VisitControls({ endDay, stay }: { endDay?: EndDayControlProps; stay?: RoadtripStayControl }) {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const until = stay?.until ? t('roadtrip.stay.until', { time: formatClockTime(stay.until, is12h) }) : null
return (
<div className={`grid gap-2 ${endDay && stay ? 'grid-cols-2' : 'grid-cols-1'}`}>
{endDay && <EndDayControl {...endDay} />}
{stay && <button type="button" onClick={stay.onEdit} disabled={!stay.onEdit}
className="flex min-w-0 items-center justify-between gap-2 rounded-[10px] bg-surface-hover px-3 py-2 text-start enabled:hover:bg-surface-tertiary disabled:cursor-default">
<span className="flex items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium uppercase text-content-secondary"><Clock size={13} className="shrink-0 text-content-faint" aria-hidden />{t('roadtrip.stop.stayShort')}</span>
{' '}<span className="flex shrink-0 items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content-secondary">
{stay.minutes === null ? '+' : formatDurationShort(stay.minutes * 60)}{stay.onEdit && <Pencil size={12} className="text-content-faint" aria-hidden />}
{' '}<span className="flex min-w-0 items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content-secondary">
{stay.minutes === null ? (until ? null : '+') : formatDurationShort(stay.minutes * 60)}
{until ? <>{' '}<span className="truncate text-content-faint">{until}</span></> : null}
{stay.onEdit && <Pencil size={12} className="shrink-0 text-content-faint" aria-hidden />}
</span>
</button>}
</div>
@@ -15,7 +15,7 @@ import {
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
// FE-RTROW-001 to FE-RTROW-039
// FE-RTROW-001 to FE-RTROW-044
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
return {
@@ -277,6 +277,23 @@ describe('pickWarning', () => {
it('FE-RTROW-014: nothing to report is null, not a placeholder', () => {
expect(pickWarning([])).toBeNull()
})
it('FE-RTROW-043: missing the time a stop is left at ranks right after being late for it', () => {
const missed: ScheduleWarning = { index: 0, code: 'missedLeave', minutes: 30 }
expect(pickWarning([{ index: 0, code: 'range', sinceKm: 700 }, missed])).toBe(missed)
expect(pickWarning([missed, { index: 0, code: 'late', minutes: 5 }])?.code).toBe('late')
})
})
describe('a stop left at a set time', () => {
it('FE-RTROW-044: its row carries the stay the time makes, not the one the place has', () => {
const d = day([stop('A'), stop('Bremen', { dwellMinutes: 30, leaveAt: '14:00' })], {
schedule: { entries: [entry('09:00'), entry('10:00', { departure: '14:00' })], warnings: [] },
})
const [, bremen] = stopRows(roadtripRows(d))
expect(bremen.dwellMinutes).toBe(240)
expect(bremen.departure).toBe('14:00')
})
})
describe('stageOf', () => {
@@ -1,5 +1,6 @@
import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
import { readStay } from './stayReading'
/**
* A road trip day, flattened into the rows a screen draws, with no React in sight.
@@ -32,12 +33,13 @@ export interface StopRow {
entry: ScheduleEntry | undefined
/** The clock this row shows on the right: when the traveller gets there. */
time: string | null
/** When they leave again, which is arrival plus the stay. Null without a schedule. */
/** When they leave again: arrival plus the stay, or the time set to leave. Null without a schedule. */
departure: string | null
/** True when a person pinned that time, which is what the pin in front of it means. */
pinned: boolean
/** At most two marks fit a 375px row. This is the one warning that earned the second. */
warning: ScheduleWarning | null
/** How long the stop is stood at, which for one left at a set time is what that time leaves. */
dwellMinutes: number | null
offRoadMeters: number | null
}
@@ -47,14 +49,15 @@ export interface StopRow {
*
* The desktop stacks all of them, which is honest on a 420px rail and unreadable on a
* phone. The order is by what changes the next decision soonest: being late moves every
* arrival after it, running dry strands the car, and an over-long leg is a planning
* remark you can act on tonight.
* arrival after it, and so does missing the time a stop was meant to be left at; running
* dry strands the car; and an over-long leg is a planning remark you can act on tonight.
*/
const WARNING_RANK: Record<ScheduleWarning['code'], number> = {
late: 0,
range: 1,
leg: 2,
overnight: 3,
missedLeave: 1,
range: 2,
leg: 3,
overnight: 4,
}
export function pickWarning(warnings: readonly ScheduleWarning[]): ScheduleWarning | null {
@@ -92,7 +95,7 @@ function stopRow(
departure: entry?.departure ?? null,
pinned: !!entry?.anchored,
warning: pickWarning(mine),
dwellMinutes: stop.dwellMinutes,
dwellMinutes: readStay(stop, entry).minutes,
offRoadMeters: stop.offRoadMeters ?? null,
}
}
@@ -0,0 +1,144 @@
import { describe, expect, it, vi } from 'vitest'
import type { RoadtripDay, RoadtripStop } from '@trek/shared/roadtrip'
import { inspectorStay, locateStop, missedLeaveOf, readStay, shownStay, stayDraftOf } from './stayReading'
// FE-ROADTRIP-STAY-001..014: what a stay badge reads for a stop, and where the dialog
// finds the visit it opens on.
const stop = (over: Partial<RoadtripStop> = {}): RoadtripStop => ({
assignmentId: 7,
ownerDayId: 1,
ownerIndex: 1,
placeId: 70,
name: 'Lueneburg',
lat: 53.2,
lng: 10.4,
time: null,
dwellMinutes: 30,
legMode: null,
incomingLegMode: null,
stopType: null,
...over,
})
const entry = (arrival: string | null, departure: string | null) => ({ arrival, departure, anchored: false, dayOffset: 0 })
const day = (dayId: number, stops: RoadtripStop[], entries = stops.map(() => entry('10:00', '14:00'))): RoadtripDay =>
({ dayId, dayNumber: dayId, date: null, title: null, stops, schedule: { entries, warnings: [] } }) as unknown as RoadtripDay
describe('readStay', () => {
it('FE-ROADTRIP-STAY-001: an ordinary stop reads the stay the place carries', () => {
expect(readStay(stop(), entry('10:00', '10:30'))).toEqual({ minutes: 30, until: null })
})
it('FE-ROADTRIP-STAY-002: a stop left at a set time reads the stay the schedule made of it', () => {
expect(readStay(stop({ leaveAt: '14:00' }), entry('10:00', '14:00'))).toEqual({ minutes: 240, until: '14:00' })
})
it('FE-ROADTRIP-STAY-003: a stay across midnight is not read as a negative one', () => {
expect(readStay(stop({ leaveAt: '01:00' }), entry('23:30', '01:00')).minutes).toBe(90)
})
it('FE-ROADTRIP-STAY-004: without an arrival it can only say until when', () => {
expect(readStay(stop({ leaveAt: '14:00:00' }), entry(null, '14:00'))).toEqual({ minutes: null, until: '14:00' })
})
it('FE-ROADTRIP-STAY-005: a leave time that is not a clock is no leave time', () => {
expect(readStay(stop({ leaveAt: 'later' }), undefined)).toEqual({ minutes: 30, until: null })
})
it('FE-ROADTRIP-STAY-012: reached after the time, it is left on arrival and stays nothing', () => {
expect(readStay(stop({ leaveAt: '14:00' }), entry('14:30', '14:30'))).toEqual({ minutes: 0, until: '14:00' })
})
it('FE-ROADTRIP-STAY-013: travel hours that end first give no length, only until when', () => {
// Reached at half past ten, the window shuts at six, the End is at eight: the row's
// 18:00 is where the day stops, not a departure, so seven and a half hours would be
// a stay that ends neither at the End nor when the drive goes on the next morning.
expect(readStay(stop({ leaveAt: '20:00' }), entry('10:30', '18:00'))).toEqual({
minutes: null,
until: '20:00',
dayEndsFirst: true,
})
})
})
describe('shownStay', () => {
it('FE-ROADTRIP-STAY-006: no stay on an ordinary stop shows nothing, a zero until a time shows zero', () => {
expect(shownStay({ minutes: 0, until: null })).toBeNull()
expect(shownStay({ minutes: null, until: null })).toBeNull()
expect(shownStay({ minutes: 45, until: null })).toBe(45)
expect(shownStay({ minutes: 0, until: '14:00' })).toBe(0)
})
})
describe('locateStop', () => {
it('FE-ROADTRIP-STAY-007: finds the visit on the card that draws it, starting at the one it names', () => {
const drawn = stop({ ownerDayId: 1 })
const days = [day(1, [stop({ assignmentId: 1 })]), day(2, [drawn])]
const located = locateStop(days, 7, 1)
expect(located).toMatchObject({ index: 0, stop: drawn })
expect(located?.day.dayId).toBe(2)
expect(locateStop(days, 99)).toBeNull()
})
it('FE-ROADTRIP-STAY-008: an automatic night standing on the stop is not the stop', () => {
const night = stop({ automaticNight: { phase: 'end', fromDayNumber: 1 } })
expect(locateStop([day(1, [night])], 7)).toBeNull()
})
})
describe('the dialog draft', () => {
it('FE-ROADTRIP-STAY-009: carries the visit, its end time and both of its clocks', () => {
expect(stayDraftOf(stop({ leaveAt: '14:00' }), entry('10:00', '14:00'))).toEqual({
placeId: 70,
name: 'Lueneburg',
minutes: 30,
arrival: '10:00',
departure: '14:00',
leaveAt: '14:00',
missedBy: null,
assignmentId: 7,
dayId: 1,
})
})
it('FE-ROADTRIP-STAY-014: carries the finding of a missed time from the card, for the inspector too', () => {
const visit = stop({ leaveAt: '14:00' })
const late = {
...day(1, [stop({ assignmentId: 1 }), visit], [entry('13:30', '13:30'), entry('14:30', '14:30')]),
schedule: {
entries: [entry('13:30', '13:30'), entry('14:30', '14:30')],
warnings: [
{ index: 1, code: 'late' as const, minutes: 5 },
{ index: 1, code: 'missedLeave' as const, minutes: 30 },
],
},
} as unknown as RoadtripDay
expect(missedLeaveOf(late, 1)).toBe(30)
expect(missedLeaveOf(late, 0)).toBeNull()
expect(stayDraftOf(visit, entry('14:30', '14:30'), 30).missedBy).toBe(30)
const onEdit = vi.fn()
inspectorStay([late], visit, { id: 70, name: 'Lueneburg' }, onEdit).onEdit?.()
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ missedBy: 30, departure: '14:30' }))
})
it('FE-ROADTRIP-STAY-010: the inspector tile reads the drive the rail reads', () => {
const onEdit = vi.fn()
const visit = stop({ leaveAt: '14:00' })
const tile = inspectorStay([day(1, [visit])], visit, { id: 70, name: 'Lueneburg', duration_minutes: 30 }, onEdit)
expect(tile).toMatchObject({ minutes: 240, until: '14:00' })
tile.onEdit?.()
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ leaveAt: '14:00', arrival: '10:00', assignmentId: 7 }))
})
it('FE-ROADTRIP-STAY-011: a place not on the drive falls back to the stay it carries', () => {
const onEdit = vi.fn()
const tile = inspectorStay([], undefined, { id: 70, name: 'Lueneburg', duration_minutes: 45 }, onEdit)
expect(tile).toMatchObject({ minutes: 45, until: null })
tile.onEdit?.()
expect(onEdit).toHaveBeenCalledWith({ placeId: 70, name: 'Lueneburg', minutes: 45, arrival: null })
expect(inspectorStay([], stop({ dwellMinutes: 20 }), { id: 70, name: 'Lueneburg' }).onEdit).toBeUndefined()
expect(inspectorStay([], stop({ dwellMinutes: 20 }), { id: 70, name: 'Lueneburg' }).minutes).toBe(20)
})
})
@@ -0,0 +1,131 @@
import { formatClock, parseClock, type ScheduleEntry } from './roadtripModel'
import type { RoadtripDay, RoadtripStop } from '@trek/shared/roadtrip'
import type { StayDraft } from './RoadtripStayModal'
import type { RoadtripStayControl } from './VisitControls'
/**
* How long a stop is stood at, the way a stay badge shows it.
*
* For most stops that is the stay the place carries. A stop with a time set to leave it
* is stood at from whenever the drive gets there until that time, so its stay is what
* the schedule made of it and `until` says when it ends. The number is read here and
* never written back to the place: the stay belongs to the place, the end time to one
* visit of it, and a stored copy would be wrong the moment the arrival moved.
*/
export interface StayReading {
minutes: number | null
/** The time the stop is left at as it was set, or null for a stop that takes its stay. */
until: string | null
/**
* The daily travel hours close before `until` comes. The stop is where that day ends and
* the drive goes on the next morning, so it is left neither at that time nor at the one
* the row shows, and no length measured against either would be true.
*/
dayEndsFirst?: boolean
}
export function readStay(
stop: Pick<RoadtripStop, 'dwellMinutes' | 'leaveAt'>,
entry: { arrival?: string | null; departure?: string | null } | null | undefined,
): StayReading {
const leave = parseClock(stop.leaveAt)
if (leave === null) return { minutes: stop.dwellMinutes, until: null }
const until = formatClock(leave)
const arrival = parseClock(entry?.arrival)
const departure = parseClock(entry?.departure)
if (arrival === null || departure === null) return { minutes: null, until }
// A schedule leaves such a stop at the time, or on arrival when it got there too late
// for it. Any other departure is the window shutting first.
if (departure !== leave && departure !== arrival) return { minutes: null, until, dayEndsFirst: true }
return {
// Round the clock once: reached before midnight and left the morning after, the
// departure reads as the smaller of the two.
minutes: (departure - arrival + 1440) % 1440,
until,
}
}
/** How late the drive gets to a stop for the time it is set to be left at, or null. */
export function missedLeaveOf(day: Pick<RoadtripDay, 'schedule'>, index: number): number | null {
return day.schedule.warnings.find(w => w.index === index && w.code === 'missedLeave')?.minutes ?? null
}
/**
* The length a stay badge prints, or null for none.
*
* No stay on an ordinary stop is nothing to show. On a stop left at a set time a zero is
* an answer: it is left the moment the drive gets there.
*/
export function shownStay(stay: StayReading): number | null {
return stay.until ? stay.minutes : stay.minutes || null
}
/** One visit on the routed days, with the entry of the card that draws it. */
export interface LocatedStop {
day: RoadtripDay
index: number
stop: RoadtripStop
entry: ScheduleEntry | undefined
}
/**
* Finds a visit across the routed days.
*
* A stop reached after a night drive is drawn on the card of the day it is reached on,
* not the one it is stored on, so `preferDayId` is only where the search starts.
*/
export function locateStop(
days: readonly RoadtripDay[],
assignmentId: number,
preferDayId?: number | null,
): LocatedStop | null {
const preferred = days.find(d => d.dayId === preferDayId)
for (const day of preferred ? [preferred, ...days.filter(d => d !== preferred)] : days) {
const index = day.stops.findIndex(s => s.assignmentId === assignmentId && !s.automaticNight)
if (index >= 0) return { day, index, stop: day.stops[index], entry: day.schedule.entries[index] }
}
return null
}
/**
* What the stay dialog opens on for one stop of a card.
*
* `missedBy` is the card's own finding, carried rather than worked out again from the two
* clocks: which day a leave time falls on is the schedule's call, and a second reading of
* it here is a second answer.
*/
export function stayDraftOf(stop: RoadtripStop, entry: ScheduleEntry | undefined, missedBy: number | null = null): StayDraft {
return {
placeId: stop.placeId,
name: stop.name,
minutes: stop.dwellMinutes,
arrival: entry?.arrival ?? null,
departure: entry?.departure ?? null,
leaveAt: stop.leaveAt ?? null,
missedBy,
assignmentId: stop.assignmentId,
dayId: stop.ownerDayId,
}
}
/**
* The place inspector's Stay tile.
*
* With the visit on the drive it reads the same as the rail does. A place selected
* without one it can be pinned to (planned twice, say) falls back to the stay it carries.
*/
export function inspectorStay(
days: readonly RoadtripDay[],
stop: RoadtripStop | undefined,
place: { id: number; name: string; duration_minutes?: number | null },
onEdit?: (draft: StayDraft) => void,
): RoadtripStayControl {
const located = stop ? locateStop(days, stop.assignmentId, stop.ownerDayId) : null
const draft: StayDraft = located
? stayDraftOf(located.stop, located.entry, missedLeaveOf(located.day, located.index))
: { placeId: place.id, name: place.name, minutes: place.duration_minutes ?? null, arrival: null }
const reading = located
? readStay(located.stop, located.entry)
: { minutes: stop ? stop.dwellMinutes : place.duration_minutes ?? null, until: null }
return { ...reading, onEdit: onEdit ? () => onEdit(draft) : undefined }
}
@@ -0,0 +1,203 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useLeaveMode, type LeaveSubject } from './useLeaveMode'
import { useAuthStore } from '../../store/authStore'
import { usePermissionsStore } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories'
import { server } from '../../../tests/helpers/msw/server'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
// FE-ROADTRIP-LEAVE-001..014: the part of the stay dialog that is about a time set to
// leave a stop, and how it takes that time off the visit.
type AddToast = NonNullable<typeof window.__addToast>
const visit = {
id: 102,
day_id: 11,
place_id: 202,
order_index: 1,
assignment_time: '09:00',
assignment_end_time: '14:00',
place: { id: 202, name: 'Bremen', place_time: '09:00', end_time: '14:00' },
}
const subject: LeaveSubject = { leaveAt: '14:00', arrival: '10:00', departure: '14:00', assignmentId: 102, dayId: 11 }
let setAssignmentTimes: ReturnType<typeof vi.fn>
let updatePlace: ReturnType<typeof vi.fn>
let refreshDays: ReturnType<typeof vi.fn>
let addToast: ReturnType<typeof vi.fn<AddToast>>
beforeEach(() => {
resetAllStores()
setAssignmentTimes = vi.fn(async () => undefined)
updatePlace = vi.fn(async () => undefined)
refreshDays = vi.fn(async () => undefined)
addToast = vi.fn<AddToast>(() => 0)
window.__addToast = addToast
seedStore(useAuthStore, { user: { id: 1, role: 'user' } })
seedStore(useTripStore, { trip: { id: 4, user_id: 1 }, assignments: { '11': [visit] } })
useTripStore.setState({ setAssignmentTimes, updatePlace, refreshDays } as never)
})
afterEach(() => {
delete window.__addToast
})
describe('useLeaveMode', () => {
it('FE-ROADTRIP-LEAVE-001: reads the time the stop is left at and the stay that makes', () => {
const { result } = renderHook(() => useLeaveMode(subject))
expect(result.current).toMatchObject({ until: '14:00', minutes: 240, removing: false })
expect(result.current.remove).toBeTypeOf('function')
})
it('FE-ROADTRIP-LEAVE-002: an ordinary stay is nothing for it to answer', () => {
const { result } = renderHook(() => useLeaveMode({ ...subject, leaveAt: null }))
expect(result.current.until).toBeNull()
expect(renderHook(() => useLeaveMode(null)).result.current.until).toBeNull()
})
it('FE-ROADTRIP-LEAVE-003: removing clears the End through the time write and keeps the Start', async () => {
const { result } = renderHook(() => useLeaveMode(subject))
await act(async () => { await result.current.remove!() })
expect(setAssignmentTimes).toHaveBeenCalledWith(4, 11, 102, { place_time: '09:00', end_time: null })
// The day is read back as the place form reads it after the same write.
expect(refreshDays).toHaveBeenCalledWith(4)
expect(result.current.until).toBeNull()
})
it('FE-ROADTRIP-LEAVE-004: a refused write says so and leaves the end time standing', async () => {
setAssignmentTimes.mockRejectedValueOnce(new Error('Denied'))
const { result } = renderHook(() => useLeaveMode(subject))
await act(async () => { await result.current.remove!() })
expect(addToast).toHaveBeenCalledWith('Denied', 'error', undefined)
expect(result.current.until).toBe('14:00')
expect(refreshDays).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-LEAVE-005: nothing to remove for a traveller who may not change the day', () => {
usePermissionsStore.setState({ permissions: { day_edit: 'trip_owner' } })
seedStore(useTripStore, { trip: { id: 4, user_id: 2 } })
const reader = renderHook(() => useLeaveMode(subject))
expect(reader.result.current.remove).toBeUndefined()
reader.unmount()
// Nor for a visit still waiting for the id the server gives it.
seedStore(useTripStore, { trip: { id: 4, user_id: 1 } })
expect(renderHook(() => useLeaveMode({ ...subject, assignmentId: -5 })).result.current.remove).toBeUndefined()
})
it('FE-ROADTRIP-LEAVE-006: an End only the place carries is taken off the place', async () => {
// The visit's own time write cannot reach it, so clearing that alone would change nothing.
seedStore(useTripStore, {
places: [{ id: 202, name: 'Bremen', end_time: '14:00' }],
assignments: { '11': [{ ...visit, assignment_end_time: null }] },
})
const { result } = renderHook(() => useLeaveMode(subject))
expect(result.current.until).toBe('14:00')
await act(async () => { await result.current.remove!() })
expect(setAssignmentTimes).not.toHaveBeenCalled()
expect(updatePlace).toHaveBeenCalledWith(4, 202, { end_time: null })
expect(result.current.until).toBeNull()
})
it.each([['the same', '14:00'], ['another', '11:00']])(
'FE-ROADTRIP-LEAVE-009: with %s End on the place as well, removing clears both',
async (_label, placeEnd) => {
// A migration copied the place's End onto every visit, so older trips carry it
// twice. Clearing only the visit's left the stop leaving at the place's.
seedStore(useTripStore, { places: [{ id: 202, name: 'Bremen', end_time: placeEnd }] })
const { result } = renderHook(() => useLeaveMode(subject))
await act(async () => { await result.current.remove!() })
expect(setAssignmentTimes).toHaveBeenCalledWith(4, 11, 102, { place_time: '09:00', end_time: null })
expect(updatePlace).toHaveBeenCalledWith(4, 202, { end_time: null })
expect(result.current.until).toBeNull()
},
)
it('FE-ROADTRIP-LEAVE-010: not offered when another visit of the place still leans on its End', () => {
// Taking the place's End would take that visit's too, and keeping it keeps this one's.
const other = { id: 150, day_id: 12, place_id: 202, order_index: 0, assignment_time: null, assignment_end_time: null }
seedStore(useTripStore, {
places: [{ id: 202, name: 'Bremen', end_time: '14:00' }],
assignments: { '11': [visit], '12': [other] },
})
const leaning = renderHook(() => useLeaveMode(subject))
expect(leaning.result.current.until).toBe('14:00')
expect(leaning.result.current.remove).toBeUndefined()
leaning.unmount()
// One with an End of its own does not care what the place carries.
seedStore(useTripStore, { assignments: { '11': [visit], '12': [{ ...other, assignment_end_time: '16:00' }] } })
expect(renderHook(() => useLeaveMode(subject)).result.current.remove).toBeTypeOf('function')
})
it('FE-ROADTRIP-LEAVE-011: not offered to a traveller who may not edit the place its End sits on', () => {
usePermissionsStore.setState({ permissions: { place_edit: 'trip_owner' } })
seedStore(useTripStore, { trip: { id: 4, user_id: 2 }, places: [{ id: 202, name: 'Bremen', end_time: '14:00' }] })
expect(renderHook(() => useLeaveMode(subject)).result.current.remove).toBeUndefined()
})
it('FE-ROADTRIP-LEAVE-012: through the real store the stop has no End left afterwards', async () => {
// Nothing stubbed but the day read back: the visit's time write and the place write
// both go out, and the End the road trip reads for the visit is gone.
resetAllStores()
seedStore(useAuthStore, { user: { id: 1, role: 'user' } })
const pool = buildPlace({ id: 202, trip_id: 4, name: 'Bremen', place_time: null, end_time: '14:00' })
const stored = buildAssignment({
id: 102, day_id: 11, place_id: 202, assignment_time: '09:00', assignment_end_time: '14:00',
place: { ...pool, place_time: '09:00', end_time: '14:00' },
})
seedStore(useTripStore, { trip: { id: 4, user_id: 1 }, places: [pool], assignments: { '11': [stored] } })
useTripStore.setState({ refreshDays } as never)
server.use(
http.put('/api/trips/4/assignments/102/time', () =>
HttpResponse.json({ assignment: { ...stored, assignment_end_time: null, place: { ...pool, place_time: '09:00' } } })),
http.put('/api/trips/4/places/202', () => HttpResponse.json({ place: { ...pool, end_time: null } })),
)
const { result } = renderHook(() => useLeaveMode(subject))
await act(async () => { await result.current.remove!() })
const after = useTripStore.getState().assignments['11']![0]!
expect(after.assignment_end_time ?? after.place?.end_time ?? null).toBeNull()
expect(result.current.until).toBeNull()
expect(addToast).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-LEAVE-013: a refused write shows what the server said, not the request library', async () => {
setAssignmentTimes.mockRejectedValueOnce({
message: 'Request failed with status code 404',
response: { status: 404, data: { error: 'Assignment not found' } },
})
const { result } = renderHook(() => useLeaveMode(subject))
await act(async () => { await result.current.remove!() })
expect(addToast).toHaveBeenCalledWith('Assignment not found', 'error', undefined)
// With nothing from the server, the translated fallback rather than the English boilerplate.
setAssignmentTimes.mockRejectedValueOnce({ message: 'Request failed with status code 500', response: { status: 500 } })
await act(async () => { await result.current.remove!() })
expect(addToast).toHaveBeenLastCalledWith('common.unknownError', 'error', undefined)
})
it('FE-ROADTRIP-LEAVE-014: hands on the finding of a missed time and a day that ends first', () => {
expect(renderHook(() => useLeaveMode({ ...subject, arrival: '14:30', departure: '14:30', missedBy: 30 })).result.current)
.toMatchObject({ until: '14:00', minutes: 0, missedBy: 30, dayEndsFirst: false })
expect(renderHook(() => useLeaveMode({ ...subject, leaveAt: '20:00', arrival: '10:30', departure: '18:00' })).result.current)
.toMatchObject({ until: '20:00', minutes: null, missedBy: null, dayEndsFirst: true })
})
it('FE-ROADTRIP-LEAVE-008: a visit gone from the store by the time of the tap is left alone', async () => {
const { result } = renderHook(() => useLeaveMode(subject))
const remove = result.current.remove!
act(() => seedStore(useTripStore, { assignments: { '11': [] } }))
await act(async () => { await remove() })
expect(setAssignmentTimes).not.toHaveBeenCalled()
})
it('FE-ROADTRIP-LEAVE-007: opened on another visit, the removal of the last one is forgotten', async () => {
const { result, rerender } = renderHook(({ s }) => useLeaveMode(s), { initialProps: { s: subject } })
await act(async () => { await result.current.remove!() })
expect(result.current.until).toBeNull()
rerender({ s: { ...subject, assignmentId: 103, leaveAt: '16:00' } })
await waitFor(() => expect(result.current.until).toBe('16:00'))
})
})
@@ -0,0 +1,112 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from '../../i18n/TranslationContext'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { getApiErrorMessage } from '../../types'
import { useToast } from '../shared/Toast'
import { readStay } from './stayReading'
/** The visit a stay dialog is open on, as far as a time set to leave it goes. */
export interface LeaveSubject {
leaveAt?: string | null
arrival?: string | null
departure?: string | null
/** How many minutes after the time the drive gets here, as the schedule found it. */
missedBy?: number | null
assignmentId?: number
/** The day the visit is stored on, which is where the store files it. */
dayId?: number
}
export interface LeaveMode {
/** The time the stop is left at, or null when the dialog is about an ordinary stay. */
until: string | null
/** How long that makes the stay, when the drive says when the stop is reached. */
minutes: number | null
/** The drive gets here this many minutes after the time, and leaves on arrival. */
missedBy: number | null
/** The travel hours close the day before the time comes, see `StayReading`. */
dayEndsFirst: boolean
/**
* Takes the end time off the visit. Absent for a traveller who may not make the writes
* that takes, and for an End that cannot go without taking another visit's with it: a
* button that changes nothing is worse than none.
*/
remove?: () => Promise<void>
removing: boolean
}
/**
* The half of the stay dialog that is about a time set to leave a stop.
*
* Shared by the desktop dialog and the phone sheet, so the two cannot answer differently.
* A stop the traveller leaves at a set time has no length to choose: it is stood at until
* then. So the dialog says when the drive leaves and what stay that makes, and offers to
* take the end time off the visit, after which the stay is a choice again.
*
* The End a visit is left at is its own, or else the one its place carries: the server
* reads COALESCE(assignment_end_time, places.end_time), and so do mergeAssignmentPlace and
* the road trip's stops. A migration copied the place's End onto every visit there was,
* so on older trips both hold it, and clearing only the visit's column left the stop
* leaving at the same time. Removing therefore clears both. The place's End is only taken
* when no other visit of the place still leans on it; when one does, the removal is not
* offered, because it could not take the End off this visit alone.
*
* The visit's own End is cleared through its time write, the one the place form saves
* Start and End with. That write sets both, so the visit's Start goes along as it stands.
*/
export function useLeaveMode(subject: LeaveSubject | null): LeaveMode {
const { t } = useTranslation()
const toast = useToast()
const can = useCanDo()
const trip = useTripStore(s => s.trip)
const [removed, setRemoved] = useState(false)
const [removing, setRemoving] = useState(false)
const assignmentId = subject?.assignmentId
const dayId = subject?.dayId
const leaveAt = subject?.leaveAt ?? null
const ownEnd = useTripStore(s => s.assignments[String(dayId)]?.find(a => a.id === assignmentId)?.assignment_end_time ?? null)
const placeId = useTripStore(s => s.assignments[String(dayId)]?.find(a => a.id === assignmentId)?.place_id ?? null)
const placeEnd = useTripStore(s => (placeId === null ? null : s.places.find(p => p.id === placeId)?.end_time ?? null))
const leanedOn = useTripStore(s => placeEnd !== null && Object.values(s.assignments).some(list =>
list.some(a => a.place_id === placeId && a.id !== assignmentId && a.assignment_end_time == null)))
// Opened on another visit, or on one whose end time changed: nothing was removed there.
useEffect(() => { setRemoved(false) }, [assignmentId, leaveAt])
const remove = useCallback(async () => {
const store = useTripStore.getState()
const visit = store.assignments[String(dayId)]?.find(a => a.id === assignmentId)
// Gone between the render that offered this and the tap.
if (!trip || !visit || dayId == null) return
const place = store.places.find(p => p.id === visit.place_id)
setRemoving(true)
try {
if (visit.assignment_end_time != null) {
await store.setAssignmentTimes(trip.id, dayId, visit.id, { place_time: visit.assignment_time ?? null, end_time: null })
}
if (place?.end_time != null) await store.updatePlace(trip.id, place.id, { end_time: null })
setRemoved(true)
// As the place form does after the same write: a Start can reorder the day on the
// server, and only the day read back shows it.
await store.refreshDays(trip.id)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.unknownError')))
} finally {
setRemoving(false)
}
}, [trip, dayId, assignmentId, toast, t])
const reading = readStay({ dwellMinutes: null, leaveAt }, subject)
const allowed = !!trip && assignmentId != null && assignmentId > 0 && (ownEnd !== null || placeEnd !== null)
&& (ownEnd === null || can('day_edit', trip))
&& (placeEnd === null || (can('place_edit', trip) && !leanedOn))
return {
until: removed ? null : reading.until,
minutes: reading.minutes,
missedBy: subject?.missedBy ?? null,
dayEndsFirst: reading.dayEndsFirst === true,
remove: allowed ? remove : undefined,
removing,
}
}
@@ -33,6 +33,10 @@ interface StopSpec {
incoming?: string | null
stopType?: string | null
noCoords?: boolean
/** The visit's own End. */
end?: string | null
/** An End only the place carries, for a visit that has none of its own. */
placeEnd?: string | null
}
function day(id: number, number: number, extra: Partial<Day> = {}): Day {
@@ -45,6 +49,7 @@ function assignment(spec: StopSpec, order: number): Assignment {
place_id: spec.id * 10,
order_index: order,
assignment_time: spec.time ?? null,
assignment_end_time: spec.end ?? null,
leg_transport_mode: spec.legMode ?? null,
incoming_leg_transport_mode: spec.incoming ?? null,
place: {
@@ -53,6 +58,9 @@ function assignment(spec: StopSpec, order: number): Assignment {
lat: spec.noCoords ? null : spec.at[0],
lng: spec.noCoords ? null : spec.at[1],
place_time: null,
// Folded in the way the store keeps a visit (mergeAssignmentPlace): its own End,
// or else the place's.
end_time: spec.end ?? spec.placeEnd ?? null,
duration_minutes: spec.dwell ?? null,
stop_type: spec.stopType ?? null,
},
@@ -764,6 +772,80 @@ describe('useRoadtripRoutes', () => {
})
})
describe('a visit End on the road trip', () => {
/** An hour from Hamburg to Lueneburg and another on to Berlin. */
const hourly = () => ({
coordinates: [HAMBURG, LUENEBURG, BERLIN],
distance: 200000,
duration: 7200,
legs: [{ distance: 100000, duration: 3600, text: '' }, { distance: 100000, duration: 3600, text: '' }],
})
async function drive(stops: StopSpec[]) {
calculateRouteWithLegs.mockResolvedValue(hourly())
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops)))
await waitFor(() => expect(result.current.loading).toBe(false))
await waitFor(() => expect(result.current.days).toHaveLength(1))
return result.current.days[0]
}
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
it('FE-ROADTRIP-ROUTES-030: the drive leaves a stop at its End, not after its stay', async () => {
const d = await drive([
{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 },
{ id: 2, at: LUENEBURG, end: '14:00', dwell: 30 },
{ id: 3, at: BERLIN },
])
expect(d.stops[1].leaveAt).toBe('14:00')
expect(d.schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '14:00' })
expect(d.schedule.entries[2].arrival).toBe('15:00')
expect(d.schedule.warnings).toEqual([])
})
it('FE-ROADTRIP-ROUTES-031: an End the place carries counts for a visit without its own', async () => {
const d = await drive([
{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 },
{ id: 2, at: LUENEBURG, placeEnd: '13:00', dwell: 30 },
{ id: 3, at: BERLIN },
])
expect(d.schedule.entries[1].departure).toBe('13:00')
expect(d.schedule.entries[2].arrival).toBe('14:00')
})
it('FE-ROADTRIP-ROUTES-032: Start and End together make the stay the time between them', async () => {
const d = await drive([
{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 },
{ id: 2, at: LUENEBURG, time: '10:00', end: '14:00', dwell: 60 },
{ id: 3, at: BERLIN },
])
expect(d.schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '14:00', anchored: true })
expect(d.schedule.entries[2].arrival).toBe('15:00')
})
it('FE-ROADTRIP-ROUTES-033: a stop reached after its End says so instead of leaving quietly', async () => {
const d = await drive([
{ id: 1, at: HAMBURG, time: '13:30', dwell: 0 },
{ id: 2, at: LUENEBURG, end: '14:00', dwell: 30 },
{ id: 3, at: BERLIN },
])
expect(d.schedule.entries[1]).toMatchObject({ arrival: '14:30', departure: '14:30' })
expect(d.schedule.warnings).toEqual([{ index: 1, code: 'missedLeave', minutes: 30 }])
})
it('FE-ROADTRIP-ROUTES-034: daily travel times spend the hours until the End as well', async () => {
act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_day_start: '08:00', roadtrip_day_end: '20:00' } }))
const d = await drive([
{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 },
{ id: 2, at: LUENEBURG, end: '14:00', dwell: 30 },
{ id: 3, at: BERLIN },
])
expect(d.automaticSchedule).toBe(true)
expect(d.schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '14:00' })
expect(d.schedule.entries[2].arrival).toBe('15:00')
})
})
vi.mock('../../hooks/useRoadtripSettings', () => ({
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
}))

Some files were not shown because too many files have changed in this diff Show More