mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
Merge branch 'dev' into feat/orm
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.2.0",
|
||||
"version": "4.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' }]))
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user