mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
240 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e225548c79 | |||
| e5bda89faa | |||
| 815020dd58 | |||
| ebfa266ee0 | |||
| f450208db4 | |||
| d1d7857363 | |||
| e7a5acbd68 | |||
| e02cc7ecf7 | |||
| d3219af062 | |||
| ae2d522890 | |||
| b513dde39e | |||
| dee2603d42 | |||
| 633a2b38a4 | |||
| 2a66665935 | |||
| d2bc4cc297 | |||
| 4c65b17a72 | |||
| 2e1db584bd | |||
| 003c5826ab | |||
| dff79089ab | |||
| f9dabccf19 | |||
| f84564e2e9 | |||
| 105cce5deb | |||
| be5004e282 | |||
| b37d66a4c1 | |||
| c620b44a7b | |||
| c58818e8f8 | |||
| 5e1b588791 | |||
| b4ea23203d | |||
| b9d0f6df4b | |||
| aa482be840 | |||
| afa0dd0e33 | |||
| 4be4c4ea11 | |||
| 5db38e82f2 | |||
| 8cbea7ad47 | |||
| 49c598a384 | |||
| 1dd1ad838d | |||
| 7f5352efe5 | |||
| 96f5d9b587 | |||
| 049cc50fda | |||
| cd6f3b9d2c | |||
| 784237a02b | |||
| f9333d51a6 | |||
| 802ac51904 | |||
| f1120be3c1 | |||
| d3aad4c41e | |||
| 91801b10c0 | |||
| b2d9b25bb6 | |||
| 44d52ab215 | |||
| 239856f35c | |||
| 65be8db122 | |||
| 41e256bf0f | |||
| 1e1dd9b814 | |||
| c1f7587704 | |||
| 676c82a308 | |||
| 6fca238440 | |||
| 761ec4e66f | |||
| 620f4c6ecd | |||
| 36ac5de84c | |||
| 061259b3d9 | |||
| fc74991a15 | |||
| 49f97af3fb | |||
| 4215db2908 | |||
| 95c173a8a8 | |||
| 282e3c6b13 | |||
| ea9de65550 | |||
| e79a2591a1 | |||
| dfb038d90f | |||
| 56266138c6 | |||
| 11f4c740e1 | |||
| d6eac772aa | |||
| 67163e7224 | |||
| ba7bd1dd91 | |||
| c82a8f899f | |||
| 23bda0e1e1 | |||
| d1a13058a6 | |||
| 25249749ad | |||
| 9a0a6f8c3b | |||
| 7829043a00 | |||
| 7b5c8c1fae | |||
| eebe9a52e1 | |||
| f1fa155899 | |||
| 4f57c1ee13 | |||
| 3d252ac994 | |||
| e11ae6f056 | |||
| 1db55f734c | |||
| b339ef3f04 | |||
| f1eadd670d | |||
| d6955f20a6 | |||
| 6aa7884874 | |||
| 37179cb608 | |||
| 74258bd5d9 | |||
| 552bd61d11 | |||
| 647e972c8b | |||
| 07a4447bbd | |||
| 3902e77991 | |||
| 7cdd61e7fb | |||
| ed5769fc89 | |||
| b0aa7f8d95 | |||
| 48f0f29273 | |||
| 5aa36aaf3d | |||
| 273bac8917 | |||
| 2b7af6267b | |||
| d613c91e16 | |||
| 0991fa538b | |||
| 65eebec53f | |||
| 15c1a5ae06 | |||
| 4c52d16b7e | |||
| c0eb1c6e38 | |||
| fb363f22c8 | |||
| 3caafe0548 | |||
| bac85cb98a | |||
| 87a765b763 | |||
| 715993feaf | |||
| b3d69aa760 | |||
| 0a2fa32fc1 | |||
| 7f2a63e7f7 | |||
| 2a0ceb2687 | |||
| 8e2840b29d | |||
| 0454a383b3 | |||
| 18684f20f3 | |||
| 8913782694 | |||
| 52315234fd | |||
| 2572132117 | |||
| 755e9f1ca4 | |||
| b132adf3e3 | |||
| 94914295d1 | |||
| 85f5247a00 | |||
| 8a48131618 | |||
| 0a35ce83a5 | |||
| eaace6ada4 | |||
| e6b4df5213 | |||
| 2d315df8bf | |||
| 04b31ec2e5 | |||
| 8eda1475c3 | |||
| ea0589b21e | |||
| 523443f1d7 | |||
| 8c538db74d | |||
| 11a0780a5b | |||
| f75e9e9af8 | |||
| 3e532aab99 | |||
| 6953ede0e3 | |||
| f81aaa9f4b | |||
| 981cf0a143 | |||
| dc7d1f5e10 | |||
| 6f057ab9f4 | |||
| ce58de29a5 | |||
| c5208967ac | |||
| 53c246e8e5 | |||
| dba4434ef6 | |||
| 55f14c6a70 | |||
| 6d54ac629e | |||
| bafcd456c5 | |||
| 9ed88adf34 | |||
| bc10fba389 | |||
| 74c47a6158 | |||
| 8b291a9a7e | |||
| 07e4a558e6 | |||
| 9228ef2bce | |||
| 15f22058e9 | |||
| b383230928 | |||
| 6cbefd23e8 | |||
| 23401fcdb9 | |||
| a2cb6a12d1 | |||
| ffb6546e51 | |||
| b82962f95b | |||
| b1ce7c303d | |||
| c5d10dfd6f | |||
| ed3216f65d | |||
| 31fa8908fc | |||
| 1428637694 | |||
| 3f49ef8d21 | |||
| 348d584a02 | |||
| 4cd25d3047 | |||
| 293dc9e1f1 | |||
| c026c5b1c9 | |||
| a5d87d295a | |||
| 7c857173cf | |||
| c907e0d5e5 | |||
| 9cc427d156 | |||
| 088eef6c6b | |||
| e1af2049f9 | |||
| 5feced54d2 | |||
| 2ab2dcc3dc | |||
| fc6843528c | |||
| faa27e3608 | |||
| 5d849ec292 | |||
| b6a86275f6 | |||
| 1e6ab069cd | |||
| 17668e5ff2 | |||
| 700918d533 | |||
| 98b97edb00 | |||
| 639c40f8de | |||
| b7f9c1e8ae | |||
| 5e5fe288d8 | |||
| 2d460eadfb | |||
| 33e737e453 | |||
| 46a89e7a90 | |||
| cfe2b80a5d | |||
| da2c881259 | |||
| 5decdcfa13 | |||
| 1e6f1fe95c | |||
| 674dededd3 | |||
| 5f63736b5d | |||
| 2d6454976e | |||
| c4fbf03928 | |||
| 03bd5c77af | |||
| c21e86e3bd | |||
| d1aa573167 | |||
| 49b0ce28d5 | |||
| 2e0c496a93 | |||
| 2e00c998c0 | |||
| b9e8821114 | |||
| a96bebc8bb | |||
| 9f6cd3dc6d | |||
| 37d7365707 | |||
| 354a89f25b | |||
| 0a3ba7a4d7 | |||
| c59425947a | |||
| 4704bf5903 | |||
| d5a3427cbb | |||
| a0c089baa4 | |||
| fef5c39581 | |||
| 2834967919 | |||
| 3472d06e62 | |||
| 9bfdc19e68 | |||
| 595a76321f | |||
| 537c450653 | |||
| 92bb834f2d | |||
| 394201cd32 | |||
| 54b0e4eed4 | |||
| e615011e73 | |||
| e70d7397dd | |||
| f341efccc9 | |||
| 2cac1ccd4f | |||
| 9d33bd71da | |||
| a4f8fabde0 | |||
| 1984e151c1 | |||
| dce348b947 | |||
| 78548ca2ba | |||
| 8a0932df72 |
@@ -1,18 +1,17 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Guidance for Claude Code in this repository. This file holds the commands, the invariants, the gates and the philosophy. Inventories (file lists, counts, history) deliberately live in the code and its READMEs, not here — when this file disagrees with the code, the code wins; fix the doc.
|
||||
|
||||
## What TREK is
|
||||
|
||||
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with three workspaces:
|
||||
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo:
|
||||
|
||||
- **`shared/`** (`@trek/shared`) — Zod schemas that are the **single source of truth** for API contracts, consumed by both server and client. Also owns all i18n locale files. Must be built before server/client typecheck or run.
|
||||
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server (decorator-driven registration layer in `server/src/nest-mcp/`, formerly the `@trek/nest-mcp` workspace), sandboxed plugin runtime.
|
||||
- **`shared/`** (`@trek/shared`) — Zod schemas, the **single source of truth** for API contracts, plus all i18n locales. Must be built before server/client typecheck or run.
|
||||
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server, sandboxed plugin runtime.
|
||||
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
|
||||
- **`plugin-sdk/`** (`trek-plugin-sdk`) — **not a root workspace**: own lockfile, published to npm independently, must ship standalone. Run its commands from `plugin-sdk/`.
|
||||
|
||||
**`plugin-sdk/`** (`trek-plugin-sdk`) also lives in the repo but is **not a root workspace** — it has its own lockfile, is published to npm independently, and must ship standalone. Run its commands from `plugin-sdk/`, not the root.
|
||||
|
||||
Each package has its own `CLAUDE.md` with internals; this file covers the monorepo picture.
|
||||
Each package has its own `CLAUDE.md`; this file covers the monorepo picture.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -21,7 +20,7 @@ Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not
|
||||
```bash
|
||||
npm run dev # build shared, then watch shared + server + client concurrently
|
||||
npm run build # build shared → server → client (order matters)
|
||||
npm run lint # lint all three workspaces
|
||||
npm run lint # lint all three workspaces (server and shared run eslint --fix and rewrite files; client is check-only)
|
||||
npm run format # prettier --write all three
|
||||
```
|
||||
|
||||
@@ -29,17 +28,14 @@ Tests:
|
||||
|
||||
```bash
|
||||
npm run test # all workspaces
|
||||
npm run test --workspace=server # vitest run (server)
|
||||
npm run test:unit --workspace=server # tests/unit only
|
||||
npm run test:integration --workspace=server
|
||||
npm run test:ws --workspace=server # websocket tests
|
||||
npm run test:e2e # server e2e (boots Nest against temp SQLite)
|
||||
npm run test:cov # coverage (lcov), all four packages incl. shared + plugin-sdk
|
||||
npm run test --workspace=server # vitest run (server); also test:unit / test:integration / test:ws
|
||||
npm run test:e2e # server e2e (boots Nest against a temp SQLite)
|
||||
npm run test:cov # coverage (lcov) for all four packages incl. plugin-sdk
|
||||
cd client && npm run test # client vitest run
|
||||
cd client && npm run e2e # Playwright
|
||||
cd client && npm run lint:pages # enforce the Page pattern (see below)
|
||||
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (--strict in CI mindset)
|
||||
npm run typecheck --workspace=server # tsc --noEmit (also client/shared)
|
||||
cd client && npm run e2e # Playwright (local only)
|
||||
cd client && npm run lint:pages # enforce the Page pattern (CI gate)
|
||||
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only)
|
||||
npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests)
|
||||
```
|
||||
|
||||
Run a single test file or test:
|
||||
@@ -49,65 +45,84 @@ npx vitest run tests/unit/nest/weather.controller.test.ts # (from server/ or c
|
||||
npx vitest run -t "name of the test" # by test-name pattern
|
||||
```
|
||||
|
||||
i18n parity (enforced in CI — every non-`en` locale must have identical file set + top-level keys):
|
||||
i18n parity (CI gate — every non-`en` locale must have the identical file set and top-level keys):
|
||||
|
||||
```bash
|
||||
npm run i18n:parity --workspace=shared # audit (exit 0)
|
||||
npm run i18n:parity:strict --workspace=shared # CI gate (exit 1 on drift)
|
||||
```
|
||||
|
||||
Dev ports: server listens on **3001**, Vite dev server proxies `/api`, `/ws`, `/uploads`, `/oauth`, etc. to `http://localhost:3001` (see `client/vite.config.js`).
|
||||
Dev ports: the server listens on **3001**; the Vite dev server proxies the API, websocket, uploads, MCP and OAuth paths to it (see `client/vite.config.js`). There is no Node version pin — the CI workflows and the Dockerfile define what is tested. `server/.env.example` is the env-var reference.
|
||||
|
||||
## Server architecture
|
||||
## Server invariants
|
||||
|
||||
Nest owns everything: the strangler migration **completed 2026-08-10**, and the last seam — the pre-`app.init()` MCP/OAuth mount — moved behind the container 2026-08-11: **zero `*.bridge.ts` files remain**. Legacy `src/services/` is deleted (ESLint errors on any import of it) and all business logic lives in DI modules under `server/src/nest/<domain>/`. `server/src/nest/README.md` is the module blueprint. Every cron is Nest-owned (`server/src/nest/scheduling/`, `@nestjs/schedule`).
|
||||
Nest owns everything: every domain is a DI module under `server/src/nest/<domain>/` (`controller` + `service` + `module`, registered in `app.module.ts`), and ESLint refuses imports of the deleted legacy `services/` layer. `server/src/nest/README.md` is the module blueprint; **`weather/` is the reference implementation** — copy its shape. The domain's Zod contract lives in `shared/src/<domain>/`.
|
||||
|
||||
- **`bootstrap.ts` — `buildApp()`** is the single shared builder used by both production (`index.ts`) and the integration/e2e test harness. **Composition order is load-bearing**: global middleware → platform uploads → the discovery metadata middleware → static assets → the named body-parser wrappers are all registered on the raw Express instance **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. The transport surface (`/api/health`, `/mcp`, `/.well-known/*` documents, `/oauth/*`) is Nest controllers/middleware. The SPA `index.html` fallback and the global error envelope are `APP_FILTER`s (`SpaFallbackFilter`, `TrekExceptionFilter`).
|
||||
- **`/mcp` bodies are raw by design** — bootstrap's parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
|
||||
- **Per-domain module shape** (one folder under `server/src/nest/<domain>/`): `<domain>.controller.ts` + `<domain>.service.ts` + `<domain>.module.ts`, registered in `app.module.ts`. **`weather/` is the reference implementation** — copy its shape. The Zod contract for the domain lives in `shared/src/<domain>/<domain>.schema.ts`.
|
||||
- **Auth is default-deny**: `GlobalAuthGuard` + `MfaPolicyGuard` run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses unknown public routes. New domain logic goes in the module's `.service.ts` — never resurrect a `services/*`-style plain module.
|
||||
- **Trip-scoped routes** must verify trip access (404 on no access) and the relevant permission (403) — the standard shape is `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler (a few domains still check inline; see `roadmap.md`) — and forward `X-Socket-Id` to the WebSocket broadcast so the originating client doesn't echo its own change. Never gate a multipart upload with a guard (ECONNRESET instead of 403 — check in the handler).
|
||||
- **DB** (`server/src/db/`): `better-sqlite3`, WAL mode, FK on. `database.ts` initializes; `schema.ts` creates tables; `migrations.ts` + `seeds.ts` run on boot. In `NODE_ENV=test` each vitest worker gets an isolated `:memory:` DB; the Playwright harness uses `TREK_DB_FILE` for a throwaway file DB.
|
||||
- **WebSocket** (`server/src/websocket.ts`): JWT-authed, room-per-trip (`tripId → Set<WebSocket>`), heartbeat keep-alive, per-connection rate limiting, served on `/ws`.
|
||||
- **MCP** (`server/src/mcp/`): OAuth 2.1-authenticated MCP server exposing TREK to AI assistants (150+ tools in `tools/`, resources, scopes, session manager). See `MCP.md`.
|
||||
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts` (mcp, packing, budget, documents, vacay, atlas, collab, journey, airtrail, llm_parsing, collections).
|
||||
- **Plugins** (`server/src/nest/plugins/`): sandboxed third-party plugin runtime — one forked child process per plugin (`supervisor/`), permission-gated RPC (`host/rpc-host.ts` registers a handler only if the plugin holds the unlocking permission), install-time manifest/signature/egress checks (`install/`, `runtime/egress-policy.ts`). Author tooling lives in `plugin-sdk/`; the `trek-plugin-dev` skill covers plugin authoring.
|
||||
- **`bootstrap.ts` — `buildApp()`** is the single builder for production and the test harness. **Composition order is load-bearing**: everything registered on the raw Express instance must come **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and nothing registered after init is reachable. Don't add routes to that pre-init shell — it bypasses every global guard, interceptor and filter.
|
||||
- **`/mcp` bodies are raw by design** — the parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
|
||||
- **Auth is default-deny**: global guards run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses any public route not on its allow-list.
|
||||
- **Trip-scoped routes** verify trip access (404) and the permission (403) — `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler).
|
||||
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. Migrations are positional and append-only. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
|
||||
- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`; `server/src/websocket.ts` is a legacy re-export stub.
|
||||
- **MCP**: tools are declared in `<domain>.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`.
|
||||
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`.
|
||||
- **Plugins** (`server/src/nest/plugins/`): one forked child process per plugin, permission-gated RPC (a handler is registered only if the plugin holds the unlocking permission), install-time manifest/signature/egress/version-range gates. The protocol tables are **generated** into `plugin-sdk/` and `shared/` by `gen:plugin-facts`; `check:plugin-facts` is the CI gate. Author tooling is `plugin-sdk/`; the `trek-plugin-dev` skill covers authoring.
|
||||
- **Storage**: uploads go through the driver abstraction in `server/src/nest/storage/` (local / S3 / mirror). Never touch the filesystem or the S3 SDK from a domain service.
|
||||
- **Crons** go through `server/src/nest/scheduling/` only — never a `@Cron` decorator (it bypasses the test-time gate).
|
||||
|
||||
## Client architecture
|
||||
## Client invariants
|
||||
|
||||
Offline-first, with a layered data flow. A Page never owns state directly:
|
||||
|
||||
- **Page pattern** (enforced by `lint:pages`, see `client/src/pages/PATTERN.md`): a `*Page.tsx` default export is a **wiring container** — it composes a co-located `use<Page>()` hook into JSX and must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` in its own body. Stateful logic lives in the hook.
|
||||
- **State**: Zustand stores in `client/src/store/`. The big one is `tripStore.ts`, assembled from **slices** in `store/slices/` (places, packing, todo, budget, reservations, files, …). `slices/remoteEventHandler.ts` applies incoming WebSocket events to local state.
|
||||
- **Data flow (offline-first)**: `store → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Repos check `navigator.onLine`: online they hit the REST API and upsert into Dexie; offline they read from Dexie. Writes go through `sync/mutationQueue.ts` — optimistic Dexie write, then on reconnect `flush()` replays REST with an `X-Idempotency-Key` header so retried writes don't double-apply. `sync/tripSyncManager.ts` and `sync/syncTriggers.ts` drive reconnection sync; `sync/tilePrefetcher.ts` caches map tiles.
|
||||
- **Styling**: use the appearance tokens (`var(--token)` inline or `bg-[var(--...)]` Tailwind classes), never hardcoded color literals or numeric `fontSize` — `theme:lint` flags bypasses so user-chosen scheme/transparency/text-size keep working. Suppress intentional exceptions (map/PDF/brand colors) with a `theme-lint-disable` line comment.
|
||||
- **PWA**: `vite-plugin-pwa` + Workbox caches tiles/API/uploads. `prebuild` generates icons.
|
||||
- **Page pattern** (enforced by `lint:pages`, spec in `client/src/pages/PATTERN.md`): a `*Page.tsx` is a **wiring container** composing a co-located `use<Page>()` hook; it must not call React state/effect/memo hooks itself.
|
||||
- **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly.
|
||||
- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` flags bypasses; suppress intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment.
|
||||
- **PWA**: `vite-plugin-pwa` + Workbox cache tiles, API and uploads; `prebuild` generates icons.
|
||||
|
||||
## Shared contracts & i18n
|
||||
|
||||
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema in `shared/src/<domain>/`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up.
|
||||
- **i18n locale files live in `shared/src/i18n/<locale>/`**, one file per domain. `en/` is canonical. When you add/change a translation key, **add it to every locale** or `i18n:parity:strict` fails CI. The client resolves keys via `client/src/i18n/TranslationContext.tsx` (`t(key)`), which only reads top-level string keys.
|
||||
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema, rebuild shared, then server (validation + DTO types) and client (typed requests) pick it up.
|
||||
- Locale files live in `shared/src/i18n/<locale>/`, one file per domain; `en/` is canonical. When you add or change a key, **add a real translation to every locale** — parity fails CI on missing keys, and an English placeholder is not acceptable in a non-`en` locale.
|
||||
|
||||
## Project direction
|
||||
|
||||
These principles come out of a verified 2026 full-repo audit and shape how all new code should be written:
|
||||
These principles come out of a full-repo audit and shape all new code:
|
||||
|
||||
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
|
||||
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's the long tail of feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
|
||||
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (on the client: feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
|
||||
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip.
|
||||
- **DI over global mutable module state.** The `db` proxy, `ws` singleton, and event-sink globals are what force `require()`-to-dodge-cycles and load-bearing boot ordering. New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
|
||||
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (DI providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents of the same machinery.
|
||||
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not silently degrade. Never lower a quality gate (strict TS, lint severity, coverage, parity checks) to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`.
|
||||
- **DI over global mutable module state.** New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
|
||||
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents.
|
||||
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not degrade. Never lower a quality gate to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`, no lowered coverage thresholds.
|
||||
|
||||
## Conventions (from CONTRIBUTING.md)
|
||||
|
||||
- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes).
|
||||
- **Discuss first**: outside contributions must be pitched in the `#github-pr` Discord channel before any code is written — undiscussed PRs are closed.
|
||||
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and require a linked issue (`Closes #N`) for bug fixes or an approved feature-request discussion for features — no issue/discussion, no PR.
|
||||
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **Do not add Co-Authored-By or other tool-attribution trailers** to commit messages.
|
||||
- One focused change per PR; no breaking changes; no unrelated reformatting/refactors. Tests required — the project holds **80%+ coverage** (the `src/nest/**` vitest coverage gate enforces ≥80%).
|
||||
- When migrating/adding a route, **parity is law**: same URL, method, query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error strings (e.g. reproduce `{ error: 'Admin only' }` exactly rather than relying on a generic guard message). Note Nest defaults POST to 201; add `@HttpCode(200)` where the legacy contract returned 200. Declare static sub-routes (`/reorder`, `/in-app/all`) **before** `:id` param routes.
|
||||
- **Discuss first**: outside contributions are pitched in the `#github-pr` Discord channel before any code is written.
|
||||
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and need a linked issue (`Closes #N`) for fixes or an approved feature discussion for features.
|
||||
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **No Co-Authored-By or other tool-attribution trailers.**
|
||||
- One focused change per PR; no breaking changes; no unrelated reformatting. Tests ship in the same change — the project holds 80%+ coverage.
|
||||
- **Parity is law** when touching a route: same URL, method, query/body, HTTP status, `Set-Cookie` and JSON body, including bespoke error strings. Nest defaults POST to 201 — add `@HttpCode(200)` where the contract returns 200. Declare static sub-routes (`/reorder`) **before** `:id` param routes.
|
||||
|
||||
## Quality gates in CI
|
||||
|
||||
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), `lint:check`, `lint:pages`, `check:plugin-facts`, `i18n:parity:strict`, the S3 contract suite, coverage for all four packages, then a SonarCloud scan. `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint`, Playwright, `check:gl-split` — run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
|
||||
|
||||
**SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it:
|
||||
|
||||
| Condition (new code) | Must be |
|
||||
|---|---|
|
||||
| Coverage | ≥ 80% |
|
||||
| Duplicated lines | ≤ 3% |
|
||||
| Security / reliability / maintainability rating | A |
|
||||
| Security hotspots reviewed | 100% |
|
||||
|
||||
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors that are already heavily duplicated. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. Locale files are excluded from duplication, so i18n additions are free.
|
||||
- **Coverage is per new line across all four packages.** A new file without tests drags the PR under 80%. Files in `sonar.coverage.exclusions` don't count either way.
|
||||
- **Ratings are about new issues.** One new bug drops reliability; one new vulnerability drops security. Don't introduce hotspot patterns (`Math.random` in anything security-adjacent, `child_process` through PATH, backtracking regexes, secret-looking strings). Path-wide suppressions live only in `sonar-project.properties` with a written justification; one-off findings are resolved on SonarCloud, never with an inline `// NOSONAR`.
|
||||
- **Check it yourself.** The `sonarqube` MCP is connected: `list_pull_requests` → `get_project_quality_gate_status` shows each condition's value; `search_duplicated_files` + `get_duplications` show what tripped duplication; `search_sonar_issues_in_projects` lists new issues. Test files are excluded from analysis — when duplication fires, the source is the problem.
|
||||
|
||||
## Reference docs
|
||||
|
||||
- `MCP.md` — MCP server/tools/scopes. `README.md` — deployment, env vars, reverse-proxy setup. `server/src/nest/README.md` — per-module blueprint and test layout (unit / parity / e2e).
|
||||
- `MCP.md` (MCP server, tools, scopes) · `README.md` (deployment, env vars, reverse proxy) · `server/src/nest/README.md` (module blueprint, test layout) · `server/src/app-config/README.md` (env parsing quirks) · `plugin-sdk/README.md` (plugin author guide).
|
||||
- `wiki/` — end-user documentation. `Dockerfile` / `docker-compose*.yml` — the deployment path and the local S3 test stack.
|
||||
|
||||
+2
-1
@@ -8,7 +8,7 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
- Optional persistent storage for `/app/data` and `/app/uploads`
|
||||
- Configurable environment variables and secrets
|
||||
- Optional generic Ingress support
|
||||
- Health checks on `/api/health`
|
||||
- Configurable health checks (liveness/readiness probes) on `/api/health`
|
||||
|
||||
## Helm Repository
|
||||
|
||||
@@ -46,6 +46,7 @@ See `values.yaml` for more options.
|
||||
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
|
||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- 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.
|
||||
- `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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.1.1
|
||||
version: 4.2.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.1.1"
|
||||
appVersion: "4.2.0"
|
||||
|
||||
@@ -80,18 +80,14 @@ spec:
|
||||
mountPath: /app/data
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
{{- with (default dict .Values.probes).liveness }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with (default dict .Values.probes).readiness }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
|
||||
@@ -134,6 +134,25 @@ persistence:
|
||||
existingClaim: ""
|
||||
annotations: {}
|
||||
|
||||
# Readiness and liveness probes. Overrides are merged over these defaults, so a key you
|
||||
# do not set stays as shipped: --set probes.liveness.initialDelaySeconds=60 keeps the
|
||||
# httpGet handler. Switching handler means clearing the old one in the same override
|
||||
# (httpGet: null together with tcpSocket), otherwise the API rejects the pod with
|
||||
# "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe.
|
||||
probes:
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
|
||||
+45
-53
@@ -1,77 +1,69 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and `server/CLAUDE.md` for the API. This file covers client internals only.
|
||||
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and the philosophy, `server/CLAUDE.md` for the API. This file holds the client's commands, invariants and pitfalls; `ls` the directories for the inventory.
|
||||
|
||||
## Commands (run from `client/`)
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server; proxies /api,/ws,/uploads,/oauth → http://localhost:3001
|
||||
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
|
||||
npm run build # prebuild generates PWA icons, then vite build
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run lint # eslint .
|
||||
npm run lint:pages # enforce the Page pattern (scripts/check-page-pattern.mjs)
|
||||
npm run test # vitest run (tests/** + src/**/*.test.{ts,tsx})
|
||||
npm run test:unit # tests/unit
|
||||
npm run test:integration # tests/integration + co-located src tests
|
||||
npm run test:coverage # v8 coverage
|
||||
npm run e2e # Playwright
|
||||
npm run e2e:report # open last Playwright report
|
||||
npm run shots # Playwright screenshots project (shots:promote to accept)
|
||||
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1 on findings; not a CI gate)
|
||||
npm run typecheck # tsc --noEmit (CI)
|
||||
npm run lint # eslint . (lint:check in CI)
|
||||
npm run lint:pages # enforce the Page pattern (CI gate)
|
||||
npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
|
||||
npm run e2e # Playwright (local only; e2e:report opens the last report)
|
||||
npm run shots # Playwright screenshot project (shots:promote to accept)
|
||||
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1; local only)
|
||||
npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (local only)
|
||||
```
|
||||
|
||||
Single test:
|
||||
Single test: `npx vitest run src/store/slices/budgetSlice.test.ts`, or `npx vitest run -t "optimistically adds the place"`.
|
||||
|
||||
```bash
|
||||
npx vitest run src/store/slices/budgetSlice.test.ts
|
||||
npx vitest run -t "optimistically adds the place"
|
||||
npx vitest watch src/pages/DashboardPage.test.tsx
|
||||
```
|
||||
The dev server needs the API on **:3001** — run `npm run dev` at the repo root to start both.
|
||||
|
||||
The dev server needs the API on **:3001** — run the server (`npm run dev` from root or `server/`) alongside, or `npm run dev` at the repo root to start both.
|
||||
## Page pattern (enforced — `lint:pages` fails otherwise)
|
||||
|
||||
## Page pattern (enforced — `lint:pages` will fail otherwise)
|
||||
|
||||
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `<page>Model.ts` holds pure (React-free) types/helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep rendered JSX byte-identical — it's a refactor of where logic lives.
|
||||
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call React state/effect/memo/ref hooks — only context hooks like `useTranslation()`. An optional `<page>Model.ts` holds pure, React-free types and helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep the rendered JSX byte-identical — it is a refactor of where logic lives.
|
||||
|
||||
## Data flow — offline-first
|
||||
|
||||
The layering is **store → repo → api | Dexie**, and writes go through a mutation queue:
|
||||
The layering is **component → feature hook → store/slice → `repo/` → `api/` | Dexie**, and writes go through a mutation queue:
|
||||
|
||||
- **`src/store/`** — Zustand. `tripStore.ts` is composed from **slices** in `store/slices/` (places, days, packing, todo, budget, reservations, files, assignments, dayNotes). Other stores: auth, settings, addon, permissions, inAppNotification, journey, vacay, systemNotice, collection, saveToCollection, plugin, backgroundTasks. `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
|
||||
- **`src/repo/`** — per-entity repositories that mediate between the network and the offline cache. The pattern: if `navigator.onLine`, call the REST API and upsert into Dexie; if offline, read from Dexie. Always go through a repo for trip data, not the API directly.
|
||||
- **`src/api/client.ts`** — single Axios instance; request/response types imported from `@trek/shared` Zod schemas. `api/websocket.ts` manages the `/ws` connection.
|
||||
- **`src/db/offlineDb.ts`** — Dexie/IndexedDB schema: cached entities + the `QueuedMutation` table + `SyncMeta` (per-trip last-sync, prefetched-tiles bbox) + a `BlobCache` for offline file/photo access.
|
||||
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (with a temporary **negative id** for creates), then on reconnect `flush()` replays REST with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the Dexie row. `tripSyncManager.ts` / `syncTriggers.ts` / `connectivity.ts` drive reconnection sync; `tilePrefetcher.ts` pre-downloads map tiles.
|
||||
- **`src/store/`** — Zustand. `tripStore.ts` is composed from slices in `store/slices/`; `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
|
||||
- **`src/repo/`** — per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data.
|
||||
- **`src/api/client.ts`** — the single Axios instance, typed from `@trek/shared`. New API surface goes in a per-domain `api/<domain>.ts`, not the god module.
|
||||
- **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache.
|
||||
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).
|
||||
|
||||
## Direction (from the 2026 audit)
|
||||
## Rules for new code
|
||||
|
||||
The offline core is flagship work surrounded by a periphery that ignores it. New code must extend the core's discipline, not the periphery's shortcuts:
|
||||
The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts:
|
||||
|
||||
- **One data path, no layer skips**: component → feature hook → store/slice → `repo/` → `api/` | Dexie. **Never import `api/client` (or raw fetch/axios) from a component or modal** — especially never *write* from one. A long tail of existing files (~190 at last count) bypasses the layering and no lint ratchet holds the line yet; that's debt, not precedent. New API surface goes in a per-domain `api/<domain>.ts`, not the `client.ts` god module.
|
||||
- **Offline-first is the product promise, not a trip-planner feature.** New domains should get a repo with both read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry), extending the existing pattern rather than hand-rolling a variant. If a domain is deliberately online-only, mark it so explicitly.
|
||||
- **No God components or God hooks.** The page pattern is a floor, not a ceiling — a 900-line `use<Page>()` hook that satisfies the linter is still coupling. Decompose hooks by concern, split renders into memoizable sections, and don't pass 50-field prop bags with inline-arrow callbacks (it defeats `React.memo`).
|
||||
- **Prefer React 19 idioms over hand-rolled machinery**: `useOptimistic` for optimistic updates, `useActionState`/`useFormStatus` for forms, `use()` + Suspense for read paths, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference implementation).
|
||||
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`; silently keeping state the server rejected is a data-integrity bug.
|
||||
- **WS remote-event handling must match local slice reducers field-for-field.** If a slice reducer preserves embedded fields on update (e.g. `place_time`/`end_time`), the corresponding `remoteEventHandler` branch must apply the identical merge semantics — divergence is silent, cross-user data loss.
|
||||
- **Subscribe with selectors** (`useShallow` for multi-field reads); never `const s = useStore()` whole-store; no store getters that allocate per call. Use `getState()` for imperative access in effects.
|
||||
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` directly in feature code.
|
||||
- **Rendering security**: never interpolate user content into HTML strings (map tooltips/popups included — build via DOM + `textContent`); untrusted or cross-user markdown gets `rehype-sanitize`; never `rehype-raw` near those surfaces.
|
||||
- **No `window` CustomEvent buses or global mutable `window` state** — use the store or a typed emitter. No `any` at boundaries: WS payloads and map renderer props get real types, validated where they enter.
|
||||
- **Hygiene**: error boundaries around new shells/routes; every async `.then(setState)` needs a cancelled flag or `AbortController`; no `eslint-disable exhaustive-deps` as a routine tool (a missing memo dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops — batch; search for an existing utility/hook before writing a duplicate.
|
||||
- **Theming**: use the semantic Tailwind tokens (`bg-surface*`, `text-content`, `border-edge`, `bg-accent`, status colors) — no raw palette classes, hex literals, or invented CSS vars; only `applyAppearance()` mutates `<html>` styling.
|
||||
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal — especially never *write* from one. A long tail of existing files bypasses the layering and no lint ratchet holds the line yet; that is debt, not precedent.
|
||||
- **Offline-first is the product promise.** New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
|
||||
- **No god components or god hooks.** The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
|
||||
- **Prefer React 19 idioms**: `useOptimistic`, `useActionState`/`useFormStatus`, `use()` + Suspense, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference).
|
||||
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`.
|
||||
- **WS remote-event handling must match local slice reducers field-for-field.** Divergence is silent, cross-user data loss.
|
||||
- **Subscribe with selectors** (`useShallow` for multi-field reads); never whole-store subscriptions; `getState()` for imperative access in effects.
|
||||
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` in feature code.
|
||||
- **Rendering security**: never interpolate user content into HTML strings (map popups included — build via DOM + `textContent`, or `escapeHtml` from `@trek/shared`); untrusted markdown gets `rehype-sanitize`; never `rehype-raw` near it.
|
||||
- **No `window` event buses or global mutable `window` state.** No `any` at boundaries: WS payloads and map renderer props get real types.
|
||||
- **Hygiene**: error boundaries around new shells/routes (`components/shared/ErrorBoundary.tsx`); every async `.then(setState)` needs a cancelled flag or `AbortController`; no routine `eslint-disable exhaustive-deps` (a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate.
|
||||
- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `<html>` styling (see `src/theme/README.md`). `theme:lint` catches literals and `bg-[#...]`-style classes but not named palette classes — review those by hand.
|
||||
|
||||
## Other big-picture pieces
|
||||
## Big-picture pieces
|
||||
|
||||
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet (`MapView.tsx`) and Mapbox GL (`MapViewGL.tsx`), chosen at runtime by `MapViewAuto.tsx` based on the tile-source setting. GL-specific overlay/marker logic lives in the `*Mapbox.ts` siblings. Keep both renderers in sync when changing map features. **Offline maps**: only Leaflet supports full pre-download (raster tiles via `sync/tilePrefetcher.ts`, cached by the Workbox `map-tiles` rule). Mapbox GL is best-effort offline — its vector tiles are cached opportunistically as you browse online (the `mapbox-tiles` SW rule), not prefetched. Tile requests stay `no-cors` so self-hosted/custom tile providers without CORS headers keep working; `navigator.storage.persist()` (called at app init) keeps cached tiles from being evicted under storage pressure.
|
||||
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled synchronously as the fallback; every other locale is a **dynamic `import('@trek/shared/i18n/<locale>')`** so Vite code-splits one chunk per locale and only the active one is fetched. `t(key)` resolves top-level keys from `@trek/shared/i18n`. Add new strings to the locale files in **`shared/`**, not here. RTL handled via `isRtlLanguage`.
|
||||
- **Mobile shell** (`src/mobile/`): below 768px (`useIsPhone`, reactive via matchMedia) `App.tsx` wraps routes in `MobileShell`, and phone-specific screens under `mobile/screens/<domain>/` (trip, dashboard, atlas, vacay, settings, admin, …) take over; the desktop/tablet experience at ≥768px is untouched. UI changes to a domain that has an `M*` screen usually need both the desktop component and the mobile screen updated.
|
||||
- **Plugins UI** (`src/components/Plugins/`): third-party plugin surfaces render in a sandboxed iframe — `PluginFrame.tsx` owns the postMessage bridge and hands the plugin the current theme's design tokens (live re-sync on theme/accent change). Contribution points: `PluginWidgets.tsx` (dashboard), `PluginDaySchedule.tsx` (itinerary rows), `Map/MapPluginLayers.tsx`/`MapPluginMarkers.tsx` (map). `store/pluginStore.ts` holds installed-plugin state; admin install/manage in `components/Admin/AdminPluginsPanel.tsx`. The server-side runtime is `server/src/nest/plugins/`; plugin authoring is covered by the `trek-plugin-dev` skill.
|
||||
- **PWA** (`vite.config.js`): `VitePWA` with `registerType: 'autoUpdate'` and Workbox `runtimeCaching` for tiles/API/uploads. `prebuild` (`scripts/generate-icons.mjs`) generates app icons before build.
|
||||
- **Native shell** (`client/src/platform/`): the Capacitor apps are not served from the same origin as the API, so `getServerOrigin()` is the single source of truth for where the server lives — `''` in the web build (relative URLs stay relative), the user's chosen server in native. Requests go through `apiFetch` / the `apiClient` interceptor, assets through `resolveAssetUrl` / `<ServerImage>`; ESLint blocks bare `fetch('/api…')` and `/uploads` literals so the seam cannot reopen. Native authenticates with `Authorization: Bearer` because the session cookie is `sameSite: 'lax'` and a webview can never send it cross-site. Saved servers are "profiles" (`serverProfiles.ts`, Capacitor Preferences) with tokens in device secure storage (`serverTokens.ts`) and a per-profile Dexie namespace. **The web build must stay byte-identical — especially Dexie names**; see `offlineDb.naming.test.ts`.
|
||||
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other.
|
||||
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/<locale>')` so Vite emits one chunk per locale. Strings live in `shared/`, never here.
|
||||
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both — and the shared-hook rule below.
|
||||
- **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill.
|
||||
- **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there.
|
||||
|
||||
## Desktop ↔ mobile shells and the SonarQube duplication gate
|
||||
|
||||
Every desktop component with a phone twin is a deliberate copy that is already heavily duplicated. CI's SonarCloud gate allows **≤ 3% duplicated lines on the PR's new code**, so adding the same lines to both shells fails the PR by itself. Put logic in one shared hook/module (`useInstanceSettings`, `useRangeBypass` under `components/Admin/` are the pattern) and keep only markup in each shell. Full gate rules in the root `CLAUDE.md`.
|
||||
|
||||
## Tests
|
||||
|
||||
vitest with `@vitejs/plugin-react`, jsdom (custom `tests/environment/jsdom-native-abort.ts`), `forks` pool. Tests live both in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie in tests. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`). Playwright e2e config separate (`npm run e2e`).
|
||||
vitest with `@vitejs/plugin-react`, a custom jsdom environment (`tests/environment/`), `forks` pool. Tests live in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`).
|
||||
|
||||
@@ -41,6 +41,12 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
|
||||
}
|
||||
await page.reload()
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// An iPad is inside the 768-1023px band, where only one side panel is open at a
|
||||
// time so the map keeps a usable width (#2247). The places list is one tap away.
|
||||
// Located by the tab's own aria-label, not by role+name: the tab bar carries a
|
||||
// "Plan" button of its own.
|
||||
await page.locator('button[aria-label="Places"]').click()
|
||||
await expect(page.getByText('Place 1').first()).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// The context must really be the one from the bug report: coarse pointer, desktop
|
||||
@@ -74,6 +80,9 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
|
||||
})
|
||||
expect(['1', 'absent']).toContain(arrowOpacity)
|
||||
|
||||
// 4. The iPad must still get the desktop two-pane layout — isMobile stayed width-based.
|
||||
// 4. The iPad must still get the desktop shell — isMobile stayed width-based. One
|
||||
// panel at a time (#2247), so opening Places closed the day plan and left its
|
||||
// tab as the way back.
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible()
|
||||
await expect(page.locator('button[aria-label="Plan"]')).toBeVisible()
|
||||
})
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.1.1",
|
||||
"version": "4.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -37,6 +37,7 @@
|
||||
"@fontsource/museomoderno": "^5.3.0",
|
||||
"@fontsource/playfair-display": "^5.3.0",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* setRTLTextPlugin() takes the URL of a classic script that the GL worker pulls in
|
||||
* with importScripts. mapbox-gl-rtl-text ships exactly that as
|
||||
* dist/mapbox-gl-rtl-text.js, but its "exports" field publishes only the ESM/WASM
|
||||
* entry, so the resolver refuses the subpath. Resolving the entry it does publish
|
||||
* and stepping across to dist keeps this working wherever npm hoists the package.
|
||||
*
|
||||
* Shared by vite.config.js and vitest.config.ts: the build needs it to emit the
|
||||
* asset, and the test run needs it because a lazily imported engine module reaches
|
||||
* the same specifier.
|
||||
*
|
||||
* The pattern is a regex, not a string key: the import carries a `?url` suffix,
|
||||
* which a string alias will not match. It is left unanchored on purpose so the
|
||||
* query survives the rewrite and Vite still emits an asset URL.
|
||||
*/
|
||||
export const rtlTextAlias = {
|
||||
find: /^@mapbox\/mapbox-gl-rtl-text\/dist\/mapbox-gl-rtl-text\.js/,
|
||||
replacement: path.join(
|
||||
path.dirname(createRequire(import.meta.url).resolve('@mapbox/mapbox-gl-rtl-text')),
|
||||
'../dist/mapbox-gl-rtl-text.js',
|
||||
),
|
||||
};
|
||||
@@ -26,4 +26,25 @@ for (const { name, size } of sizes) {
|
||||
console.log(` \u2713 ${name} (${size}x${size})`);
|
||||
}
|
||||
|
||||
// Maskable variants: Android lays a maskable icon edge to edge under the round
|
||||
// launcher mask, so the glyph has to sit inside the safe zone (a circle of 80%
|
||||
// of the canvas). The master SVG draws the glyph nearly full bleed; shrink and
|
||||
// recenter it, keeping the brand gradient as the full background.
|
||||
const maskableSvg = svgBuffer
|
||||
.toString()
|
||||
.replace('translate(56,51) scale(0.267)', 'translate(81,81) scale(0.234)');
|
||||
|
||||
const maskableSizes = [
|
||||
{ name: 'icon-maskable-192x192.png', size: 192 },
|
||||
{ name: 'icon-maskable-512x512.png', size: 512 },
|
||||
];
|
||||
|
||||
for (const { name, size } of maskableSizes) {
|
||||
await sharp(Buffer.from(maskableSvg), { density: 300 })
|
||||
.resize(size, size)
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(join(iconsDir, name));
|
||||
console.log(` \u2713 ${name} (${size}x${size}, maskable)`);
|
||||
}
|
||||
|
||||
console.log('PWA icons generated.');
|
||||
|
||||
@@ -522,6 +522,83 @@ describe('Version cache-busting', () => {
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-026: a version bump never deletes the caches or unregisters the worker (#2228)', async () => {
|
||||
// The old handler wiped every Cache Storage bucket and every registration
|
||||
// here, which left the device with no app shell until a fresh ~22 MB
|
||||
// precache finished: anyone who closed the app or lost signal inside that
|
||||
// window could no longer start the PWA offline at all, and it also threw
|
||||
// away the map tiles and documents the user had downloaded on purpose.
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
const cachesDelete = vi.fn().mockResolvedValue(true)
|
||||
const unregister = vi.fn().mockResolvedValue(true)
|
||||
const update = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete })
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getRegistration: vi.fn().mockResolvedValue({ update, unregister }),
|
||||
getRegistrations: vi.fn().mockResolvedValue([{ unregister }]),
|
||||
addEventListener: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '2.9.10' })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
|
||||
// It asks the worker to fetch the new build; Workbox swaps the precache
|
||||
// atomically once that install completes.
|
||||
await waitFor(() => expect(update).toHaveBeenCalled())
|
||||
expect(cachesDelete).not.toHaveBeenCalled()
|
||||
expect(unregister).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-028: a version that does not look like a release tag is ignored', async () => {
|
||||
// The value arrives over the wire and is written to this device's storage,
|
||||
// so it is validated rather than trusted. An unusable one must also not be
|
||||
// compared against the stored marker, or every launch would see a mismatch
|
||||
// and try to update.
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
const reload = vi.fn()
|
||||
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '<script>alert(1)</script>', managed: false })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('common.loading')).not.toBeInTheDocument())
|
||||
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-027: the version marker is written even if the update path throws (#2228)', async () => {
|
||||
// The marker used to be written after the purge and outside its try, so a
|
||||
// throwing step left it unwritten and the whole destructive path repeated on
|
||||
// every launch, for good.
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: { getRegistration: vi.fn().mockRejectedValue(new Error('no worker')), addEventListener: vi.fn() },
|
||||
})
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '2.9.10' })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10'))
|
||||
})
|
||||
})
|
||||
|
||||
// Regression: a device that has never mirrored the preference must not treat
|
||||
|
||||
+50
-18
@@ -19,7 +19,7 @@ import ErrorBoundary from './components/shared/ErrorBoundary'
|
||||
import { lazyWithRetry } from './utils/lazyWithRetry'
|
||||
import { useIsPhone } from './mobile/useIsPhone'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi } from './api/client'
|
||||
import { authApi, isAuthPublicPath } from './api/client'
|
||||
import { tripRepo } from './repo/tripRepo'
|
||||
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
@@ -283,7 +283,7 @@ function RouteFallback() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
@@ -300,7 +300,7 @@ export default function App() {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
@@ -314,26 +314,49 @@ export default function App() {
|
||||
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
|
||||
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
|
||||
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
|
||||
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
if (config?.version) {
|
||||
// A version is a short release tag and nothing else. It arrives over the
|
||||
// wire and is written to this device's storage, so it is rebuilt from the
|
||||
// characters a tag may contain and kept only when nothing had to be
|
||||
// stripped. Anything else is ignored, which also keeps a malformed value
|
||||
// from being compared against the stored marker and starting an update on
|
||||
// every launch.
|
||||
const reportedVersion = typeof config?.version === 'string' ? config.version : ''
|
||||
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64)
|
||||
if (version && version === reportedVersion) {
|
||||
const storedVersion = localStorage.getItem('trek_app_version')
|
||||
if (storedVersion && storedVersion !== config.version) {
|
||||
// Record the version BEFORE acting on it. The old code wrote the marker
|
||||
// after the purge and outside its try, so a throwing setItem (private
|
||||
// mode, blocked site data, quota) left the caches deleted, the marker
|
||||
// unwritten and the reload unreached, and the purge then repeated on
|
||||
// every single launch, permanently (#2228).
|
||||
try { localStorage.setItem('trek_app_version', version) } catch { /* site data blocked */ }
|
||||
if (storedVersion && storedVersion !== version) {
|
||||
// A newer build is deployed. Ask the service worker to fetch it and
|
||||
// hand over; Workbox ('autoUpdate' + skipWaiting + clientsClaim)
|
||||
// installs the new precache and only then drops the outdated one, so
|
||||
// there is never a moment without an app shell.
|
||||
//
|
||||
// This used to delete EVERY Cache Storage bucket and unregister EVERY
|
||||
// worker instead. That left the device with no shell and no worker
|
||||
// until a fresh ~22 MB precache finished (minutes on mobile data),
|
||||
// and anyone who closed the app or lost signal in that window was
|
||||
// left with a PWA that could no longer start offline at all. It also
|
||||
// threw away the map tiles and file blobs the user had deliberately
|
||||
// downloaded for offline use (#2228).
|
||||
try {
|
||||
if ('caches' in window) {
|
||||
const names = await caches.keys()
|
||||
await Promise.all(names.map(n => caches.delete(n)))
|
||||
const reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
|
||||
if (reg) {
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { once: true })
|
||||
await reg.update()
|
||||
return
|
||||
}
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(regs.map(r => r.unregister()))
|
||||
}
|
||||
} catch {}
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
} catch { /* fall through to a plain reload */ }
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
@@ -380,13 +403,22 @@ export default function App() {
|
||||
|| location.pathname.startsWith('/register')
|
||||
|| location.pathname.startsWith('/forgot-password')
|
||||
|| location.pathname.startsWith('/reset-password')
|
||||
// No session on these, so authenticated-only widgets (system notices,
|
||||
// background tasks, save-to-collection) have nothing to do and would only fire
|
||||
// a doomed authenticated request on mount.
|
||||
//
|
||||
// Off isAuthPublicPath rather than a second hand-kept list: the two had already
|
||||
// drifted, this one naming only /public/journey/ while the response
|
||||
// interceptor's covers all of /public/. A route that is public to one and not
|
||||
// to the other is exactly the seam that puts a 401 back.
|
||||
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
{!isAuthPage && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
|
||||
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
|
||||
{!isAuthPage && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||
{!isAuthPage && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||
across navigations. react-router runs location updates inside a transition,
|
||||
|
||||
@@ -230,7 +230,8 @@ describe('client > proxy auth challenges', () => {
|
||||
return getRegistration
|
||||
}
|
||||
|
||||
it('FE-APIWIRE-011: an HTML 401 unregisters the service worker and reloads', async () => {
|
||||
it('FE-APIWIRE-011: an HTML 401 behind a confirmed proxy wall unregisters the service worker and reloads', async () => {
|
||||
probeNow.mockResolvedValue('proxy-wall')
|
||||
const unregister = vi.fn(async () => true)
|
||||
installServiceWorker(unregister)
|
||||
server.use(http.get('/api/auth/me', () =>
|
||||
@@ -243,6 +244,25 @@ describe('client > proxy auth challenges', () => {
|
||||
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBe('1')
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-016: an HTML 401 from TREK itself keeps the service worker (#2228)', async () => {
|
||||
// text/html is not proof of a proxy: several of TREK's own routes answer
|
||||
// res.status(401).send('Authentication required'), which Express labels
|
||||
// text/html. Tearing the worker down for one of those costs the user
|
||||
// offline mode for a wall that is not there, so confirm reachability first.
|
||||
probeNow.mockResolvedValue('online')
|
||||
const unregister = vi.fn(async () => true)
|
||||
installServiceWorker(unregister)
|
||||
server.use(http.get('/api/auth/me', () =>
|
||||
new HttpResponse('Authentication required', { status: 401, headers: { 'Content-Type': 'text/html' } })))
|
||||
|
||||
await captureError(() => apiClient.get('/auth/me'))
|
||||
|
||||
expect(probeNow).toHaveBeenCalled()
|
||||
expect(unregister).not.toHaveBeenCalled()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-012: the reauth reload only fires once per session', async () => {
|
||||
installServiceWorker(vi.fn(async () => true))
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
|
||||
@@ -301,6 +301,9 @@ describe('client > endpoint wiring', () => {
|
||||
{ n: 'pluginRescan', r: () => adminApi.pluginRescan(), e: 'POST /api/admin/plugins/rescan' },
|
||||
{ n: 'pluginLink', r: () => adminApi.pluginLink('/srv/plugin'), e: 'POST /api/admin/plugins/link' },
|
||||
{ n: 'pluginReload', r: () => adminApi.pluginReload('koffi'), e: 'POST /api/admin/plugins/koffi/reload' },
|
||||
{ n: 'pluginConfig', r: () => adminApi.pluginConfig('koffi'), e: 'GET /api/admin/plugins/koffi/config' },
|
||||
{ n: 'pluginSaveConfig', r: () => adminApi.pluginSaveConfig('koffi', { apiUrl: 'x' }), e: 'PUT /api/admin/plugins/koffi/config' },
|
||||
{ n: 'runPluginAction', r: () => adminApi.runPluginAction('koffi', 'purge cache'), e: 'POST /api/admin/plugins/koffi/actions/purge%20cache' },
|
||||
{ n: 'pluginEgressHosts', r: () => adminApi.pluginEgressHosts('koffi'), e: 'GET /api/admin/plugins/koffi/egress-hosts' },
|
||||
{ n: 'pluginSetEgressHosts', r: () => adminApi.pluginSetEgressHosts('koffi', ['a.example']), e: 'PUT /api/admin/plugins/koffi/egress-hosts' },
|
||||
{ n: 'pluginErrors', r: () => adminApi.pluginErrors('koffi'), e: 'GET /api/admin/plugins/koffi/errors' },
|
||||
@@ -862,4 +865,21 @@ describe('client > multipart uploads', () => {
|
||||
expect(post.mock.calls[1][0]).toBe('/backup/upload-restore')
|
||||
expect(((post.mock.calls[1][1] as FormData).get('backup') as File).name).toBe('backup.zip')
|
||||
})
|
||||
|
||||
it('FE-APISURF-053: every channel test outlives the 8s global timeout', async () => {
|
||||
const post = spyPost()
|
||||
|
||||
// The server budgets up to 20s for an SMTP dial and 10s for a webhook or
|
||||
// ntfy ping; aborting at 8s threw away the reason and left the admin with a
|
||||
// bare "failed" (#2196).
|
||||
await notificationsApi.testSmtp('a@b.c')
|
||||
await notificationsApi.testWebhook('https://hook')
|
||||
await notificationsApi.testNtfy({ topic: 't' })
|
||||
await notificationsApi.testChannel('plugin/ch')
|
||||
|
||||
expect(post.mock.calls).toHaveLength(4)
|
||||
for (const call of post.mock.calls) {
|
||||
expect(call[2]).toMatchObject({ timeout: 40000 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+261
-31
@@ -1,6 +1,8 @@
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Place } from '../types'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
import { randomId } from '../utils/randomId'
|
||||
import {
|
||||
weatherResultSchema, type WeatherResult,
|
||||
@@ -30,7 +32,7 @@ import {
|
||||
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
|
||||
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
|
||||
type TodoCreateItemRequest, type TodoUpdateItemRequest,
|
||||
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
|
||||
type AssignmentCreateRequest, type AssignmentNotesRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
|
||||
type PlaceBulkDeleteRequest,
|
||||
type PlaceBulkUpdateRequest,
|
||||
type DayNoteCreateRequest, type DayNoteUpdateRequest,
|
||||
@@ -53,6 +55,18 @@ import {
|
||||
type StorageConfigPut,
|
||||
type StorageTestResponse,
|
||||
type StorageUsage,
|
||||
type PluginSettingsField,
|
||||
type PluginInstanceConfigResponse,
|
||||
type PluginInstanceConfigUpdated,
|
||||
type PluginActionDescriptor,
|
||||
type PluginActionResult,
|
||||
type PluginInstallRequest,
|
||||
RoadtripDayTrack,
|
||||
RoadtripVia,
|
||||
RoadtripViaBatchRequest,
|
||||
RoadtripViaCreateRequest,
|
||||
RoadtripViaReanchorRequest,
|
||||
RoadtripViaUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
@@ -122,7 +136,7 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||
function translateRateLimit(): string {
|
||||
const fallback = RATE_LIMIT_MESSAGES['en']!
|
||||
try {
|
||||
const lang = localStorage.getItem('app_language') || 'en'
|
||||
const lang = localStorage.getItem('app_language') || localStorage.getItem('app_language_server') || 'en'
|
||||
return RATE_LIMIT_MESSAGES[lang] ?? fallback
|
||||
} catch {
|
||||
return fallback
|
||||
@@ -208,16 +222,24 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
// Pangolin header-auth extended compatibility mode: returns 401 with an
|
||||
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
|
||||
// always application/json, so checking for text/html is unambiguous.
|
||||
// HTML body (a JS redirect page) instead of a 302.
|
||||
//
|
||||
// A text/html 401 is NOT unambiguous on its own: several of TREK's own
|
||||
// routes answer with res.status(401).send('Authentication required'),
|
||||
// which Express labels text/html. Tearing the service worker down for one
|
||||
// of those would cost the user offline mode for a proxy wall that isn't
|
||||
// there, so confirm a reachable proxy first, exactly like the no-response
|
||||
// branch above (#2228).
|
||||
if (error.response?.status === 401) {
|
||||
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
|
||||
if (ct.includes('text/html')) {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
if (await probeNow() === 'proxy-wall') {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,7 +440,10 @@ export const daysApi = {
|
||||
|
||||
export const placesApi = {
|
||||
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
|
||||
create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||
// Typed: an untyped `r.data` is what let `{ place }` be read as a bare place,
|
||||
// so every hotel booking minted an unlinked duplicate (#2243).
|
||||
create: (tripId: number | string, data: PlaceCreateRequest): Promise<{ place: Place }> =>
|
||||
apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
@@ -447,8 +472,15 @@ export const placesApi = {
|
||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||
return postMultipart(`/trips/${tripId}/places/import/map`, fd)
|
||||
},
|
||||
// A longer timeout than the shared 8 s, like the other routes here that wait
|
||||
// on somebody else's service. A directions link whose stops are only named
|
||||
// has to be geocoded one at a time behind a 1.1 s throttle, so a route with
|
||||
// eight stops needs about ten seconds. Giving up at eight left the server
|
||||
// finishing the import and writing the places while the browser reported a
|
||||
// failure, and a retry then spent the whole geocoding budget again only to
|
||||
// have the dedupe skip every stop.
|
||||
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest, { timeout: 60000 }).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
@@ -467,6 +499,8 @@ export const assignmentsApi = {
|
||||
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
|
||||
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
|
||||
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
||||
// Day-specific note on an assignment (#2163) — null clears it.
|
||||
updateNotes: (tripId: number | string, id: number, data: AssignmentNotesRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/notes`, data).then(r => r.data),
|
||||
// Per-segment travel mode (#1281): mode of the leg leaving this stop (null = inherit day default).
|
||||
// direction defaults to 'outgoing' server-side, so only send it for the incoming (boundary-leg) case
|
||||
// and keep the outgoing payload byte-for-byte identical to the pre-#1281 shape.
|
||||
@@ -535,8 +569,8 @@ export const adminApi = {
|
||||
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
|
||||
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
|
||||
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
|
||||
pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) =>
|
||||
apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
|
||||
pluginInstall: (id: string, opts?: Pick<PluginInstallRequest, 'version' | 'constraint' | 'withDependencies'>) =>
|
||||
apiClient.post('/admin/plugins/install', { id, ...opts } satisfies PluginInstallRequest).then(r => r.data),
|
||||
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
|
||||
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
||||
// `version` pins the exact version to install (the rollback path); omitted, the server
|
||||
@@ -556,6 +590,17 @@ export const adminApi = {
|
||||
// Dev-link (dev-only): register a plugin from a local built dir + hot-reload it.
|
||||
pluginLink: (path: string) => apiClient.post('/admin/plugins/link', { path }).then(r => r.data),
|
||||
pluginReload: (id: string) => apiClient.post(`/admin/plugins/${id}/reload`).then(r => r.data),
|
||||
// The admin-owned `scope:'instance'` settings: the declared fields plus the stored
|
||||
// values (secrets masked). Saving may RESTART a running plugin — the child gets its
|
||||
// config once at init — and `restarted` reports whether that happened.
|
||||
pluginConfig: (id: string): Promise<PluginInstanceConfigResponse> =>
|
||||
apiClient.get(`/admin/plugins/${id}/config`).then(r => r.data),
|
||||
pluginSaveConfig: (id: string, config: Record<string, unknown>): Promise<PluginInstanceConfigUpdated> =>
|
||||
apiClient.put(`/admin/plugins/${id}/config`, config).then(r => r.data),
|
||||
// Run a `scope:'instance'` action the plugin declared ("Purge cache"). Runs AS the
|
||||
// clicking admin. 404 when the plugin is inactive (no child process to run it).
|
||||
runPluginAction: (id: string, key: string): Promise<PluginActionResult> =>
|
||||
apiClient.post(`/admin/plugins/${id}/actions/${encodeURIComponent(key)}`).then(r => r.data),
|
||||
// Operator-supplied egress hosts: a plugin talking to a SELF-HOSTED service can't name
|
||||
// the operator's hostname in its manifest, so the admin adds it here. Saving re-spawns
|
||||
// the plugin with the widened allow-list.
|
||||
@@ -616,12 +661,18 @@ export const adminApi = {
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
|
||||
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
|
||||
getTrekPlaces: () => apiClient.get('/admin/trek-places').then(r => r.data),
|
||||
updateTrekPlaces: (enabled: boolean) => apiClient.put('/admin/trek-places', { enabled }).then(r => r.data),
|
||||
getPlaceShadow: () => apiClient.get('/admin/place-shadow').then(r => r.data),
|
||||
updatePlaceShadow: (enabled: boolean) => apiClient.put('/admin/place-shadow', { enabled }).then(r => r.data),
|
||||
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
|
||||
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
|
||||
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
|
||||
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
|
||||
getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
|
||||
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data),
|
||||
getTransitProvider: () => apiClient.get('/admin/transit-provider').then(r => r.data),
|
||||
updateTransitProvider: (provider: TransitProvider) => apiClient.put('/admin/transit-provider', { provider }).then(r => r.data),
|
||||
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
|
||||
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
|
||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||
@@ -782,16 +833,13 @@ export interface PluginAtlasLayer {
|
||||
countries: Array<{ code: string; tone: 'default' | 'success' | 'warn' | 'danger'; label?: string }>
|
||||
}
|
||||
|
||||
export interface PluginUserSettingField {
|
||||
key: string; label?: string | null; input_type?: string; placeholder?: string | null;
|
||||
hint?: string | null; required?: boolean; secret?: boolean;
|
||||
options?: Array<{ value: string; label: string }>
|
||||
}
|
||||
/** The settings-field descriptor is the SHARED contract (both scopes emit the same
|
||||
* shape) — aliased so existing per-user settings consumers keep their import path. */
|
||||
export type PluginUserSettingField = PluginSettingsField
|
||||
|
||||
/** A button a plugin contributes to its own settings page ("Test connection"). */
|
||||
export interface PluginAction {
|
||||
key: string; label: string; hint?: string; danger: boolean
|
||||
}
|
||||
/** A button a plugin contributes to a settings form ("Test connection", "Purge cache").
|
||||
* `scope` says which form: the user tab or the admin instance-settings dialog. */
|
||||
export type PluginAction = PluginActionDescriptor
|
||||
|
||||
export const pluginsApi = {
|
||||
// Active plugins the client renders (page nav entries, dashboard widgets).
|
||||
@@ -862,7 +910,7 @@ export const pluginsApi = {
|
||||
// caller, so it reads the caller's own settings.
|
||||
runAction: (id: string, key: string) =>
|
||||
apiClient.post(`/plugin-settings/${id}/actions/${encodeURIComponent(key)}`)
|
||||
.then(r => r.data as { ok: boolean; message?: string }),
|
||||
.then(r => r.data as PluginActionResult),
|
||||
saveUserSettings: (id: string, config: Record<string, unknown>) =>
|
||||
apiClient.post(`/plugin-settings/${id}`, { config }).then(r => r.data as { config: Record<string, unknown> }),
|
||||
// Host-brokered outbound OAuth (the host owns the tokens; the plugin only triggers).
|
||||
@@ -1006,11 +1054,134 @@ export const memoriesApi = {
|
||||
}).then(r => r.data),
|
||||
}
|
||||
|
||||
/**
|
||||
* Place search that still answers with no network.
|
||||
*
|
||||
* The cache is what `sync/placePrefetcher` stored for the trip's area, and this
|
||||
* is the one place it is read from, so all ten call sites got the offline path
|
||||
* without any of them learning about it. `source` says 'offline-cache' rather
|
||||
* than pretending to be the index — several screens show it, and a stale answer
|
||||
* that claims to be live is worse than one that says what it is.
|
||||
*
|
||||
* Only network-level failures fall through. A 4xx or 5xx means the server did
|
||||
* answer and the caller has to see it.
|
||||
*/
|
||||
/**
|
||||
* The same offline treatment as `withCachedPlaces`, for the single-place lookup.
|
||||
*
|
||||
* Separate because the shape is different: this one is keyed by id rather than
|
||||
* by a query, and a miss has to stay a miss — a place the cache does not hold
|
||||
* must fail the way it did before, not answer with somebody else's record.
|
||||
*/
|
||||
/**
|
||||
* The rejection both search surfaces already know to swallow.
|
||||
*
|
||||
* They filter on the axios cancel code and on the DOMException name, so an
|
||||
* abort from the cache path has to look like one or it surfaces as a toast.
|
||||
*/
|
||||
function abortedError(): Error & { code: string } {
|
||||
const err = new Error('canceled') as Error & { code: string }
|
||||
err.name = 'CanceledError'
|
||||
err.code = 'ERR_CANCELED'
|
||||
return err
|
||||
}
|
||||
|
||||
async function withCachedPlace(
|
||||
placeId: string,
|
||||
online: () => Promise<{ place: Record<string, unknown> | null }>,
|
||||
): Promise<{ place: Record<string, unknown> | null }> {
|
||||
const fromCache = async (): Promise<{ place: Record<string, unknown> | null } | null> => {
|
||||
const { getCachedPlace, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
|
||||
const hit = await getCachedPlace(placeId)
|
||||
return hit ? { place: cachedToPlaceRecord(hit) } : null
|
||||
}
|
||||
|
||||
if (isEffectivelyOffline()) {
|
||||
const cached = await fromCache()
|
||||
if (cached) return cached
|
||||
return online()
|
||||
}
|
||||
try {
|
||||
return await online()
|
||||
} catch (err) {
|
||||
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
|
||||
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
|
||||
if (!neverArrived) throw err
|
||||
const cached = await fromCache()
|
||||
if (!cached) throw err
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
async function withCachedPlaces<T>(
|
||||
query: string,
|
||||
shape: (places: Record<string, unknown>[]) => T,
|
||||
online: () => Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const fromCache = async (): Promise<Record<string, unknown>[]> => {
|
||||
const { searchCachedPlaces, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
|
||||
const found = (await searchCachedPlaces(query)).map(cachedToPlaceRecord)
|
||||
// The keystroke this answers may already be two keystrokes old. Both search
|
||||
// surfaces order their suggestions by aborting the previous request and
|
||||
// discarding the rejection — there is no request counter — so an offline
|
||||
// answer that resolves regardless of the signal can overwrite a newer list.
|
||||
// The two cache reads are also very unequal: a prefix hit comes off the
|
||||
// index, the substring pass walks every cached place of every trip.
|
||||
if (signal?.aborted) throw abortedError()
|
||||
return found
|
||||
}
|
||||
|
||||
if (isEffectivelyOffline()) return shape(await fromCache())
|
||||
try {
|
||||
return await online()
|
||||
} catch (err) {
|
||||
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
|
||||
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
|
||||
if (!neverArrived) throw err
|
||||
const cached = await fromCache()
|
||||
if (!cached.length) throw err
|
||||
return shape(cached)
|
||||
}
|
||||
}
|
||||
|
||||
export const mapsApi = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
/**
|
||||
* `locationBias` is what tells the search which "Hase-dera" is meant, and it
|
||||
* is the difference between finding the temple the user stands next to and
|
||||
* one 400km away. The route has always accepted it; nothing passed it.
|
||||
*
|
||||
* It also decides whether the index answers at all: a common single word
|
||||
* without coordinates is refused upstream as too expensive, and the search
|
||||
* then falls back to Nominatim alone.
|
||||
*/
|
||||
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
|
||||
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), () =>
|
||||
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search'))),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||
withCachedPlaces(
|
||||
input,
|
||||
(places) => ({
|
||||
suggestions: places.map((p) => ({
|
||||
placeId: String(p.osm_id),
|
||||
mainText: String(p.name),
|
||||
secondaryText: String(p.address || ''),
|
||||
})),
|
||||
source: 'offline-cache',
|
||||
}),
|
||||
() => apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
signal,
|
||||
),
|
||||
// Answered from the cache when the network is not there, for the ids the
|
||||
// offline suggestion list hands out. Without it, picking an offline
|
||||
// suggestion failed here and the callers fell back to searching for
|
||||
// "name, address" — which the cache matches on the folded NAME alone, so it
|
||||
// found nothing and the user got an error toast for a place that was sitting
|
||||
// in the cache all along.
|
||||
details: (placeId: string, lang?: string, sessionToken?: string) =>
|
||||
withCachedPlace(placeId, () =>
|
||||
apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } })
|
||||
.then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details'))),
|
||||
// Pictures and a description for a place that is being looked at but not yet
|
||||
// saved. Fans out to several providers server-side, so it takes a signal and
|
||||
// the caller is expected to abort it when the selection changes, and a longer
|
||||
@@ -1030,10 +1201,60 @@ export const mapsApi = {
|
||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
||||
// timeout than the global default instead of aborting at 8s and showing nothing.
|
||||
/**
|
||||
* Every place in a box, for the offline cache. One call per trip area, not
|
||||
* per keystroke, so the timeout is generous where the search ones are short.
|
||||
*/
|
||||
area: (
|
||||
bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number },
|
||||
limit?: number,
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
apiClient
|
||||
.get('/maps/area', { params: { ...bbox, limit }, signal, timeout: 30000 })
|
||||
.then(
|
||||
(r) =>
|
||||
r.data as {
|
||||
results: Record<string, unknown>[]
|
||||
truncated: boolean
|
||||
unavailable?: boolean
|
||||
},
|
||||
),
|
||||
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||
}
|
||||
|
||||
/**
|
||||
* Road-trip via points (#1797): the places a day's drive is routed through without
|
||||
* stopping. Separate from places on purpose — a via bends the route, a stop is somewhere
|
||||
* you go.
|
||||
*/
|
||||
export const roadtripApi = {
|
||||
/** Every via of the trip, so all days can be routed without a request per day. */
|
||||
listVias: (tripId: number | string) =>
|
||||
apiClient.get(`/trips/${tripId}/roadtrip/vias`).then(r => r.data as { vias: RoadtripVia[]; tracks: RoadtripDayTrack[] }),
|
||||
addVia: (tripId: number | string, dayId: number | string, body: RoadtripViaCreateRequest) =>
|
||||
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { via: RoadtripVia }),
|
||||
/**
|
||||
* Lay a chain of vias on one day in one write. Anything that derives its anchors from a
|
||||
* line produces dozens of them, and one request each would re-route the whole trip once
|
||||
* per point at better than a second apart.
|
||||
*/
|
||||
addVias: (tripId: number | string, dayId: number | string, body: RoadtripViaBatchRequest) =>
|
||||
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias/batch`, body).then(r => r.data as { vias: RoadtripVia[] }),
|
||||
/**
|
||||
* Re-pin a day's vias in one write, after its stops changed shape. One request, not one
|
||||
* per via: the anchors are only correct as a set.
|
||||
*/
|
||||
reanchorVias: (tripId: number | string, dayId: number | string, body: RoadtripViaReanchorRequest) =>
|
||||
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { vias: RoadtripVia[] }),
|
||||
moveVia: (tripId: number | string, dayId: number | string, id: number, body: RoadtripViaUpdateRequest) =>
|
||||
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`, body).then(r => r.data as { via: RoadtripVia }),
|
||||
removeVia: (tripId: number | string, dayId: number | string, id: number) =>
|
||||
apiClient.delete(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
|
||||
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
|
||||
@@ -1108,7 +1329,8 @@ export const healthApi = {
|
||||
|
||||
export const weatherApi = {
|
||||
// `time` (HH:MM) makes a past date answer for that hour instead of the day (#1614).
|
||||
get: (lat: number, lng: number, date: string, time?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
|
||||
// `lang` localizes the description; when omitted the server keeps its own default (#2167).
|
||||
get: (lat: number, lng: number, date: string, lang?: string, time?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date, lang, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
|
||||
getCurrent: (lat: number, lng: number, lang?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
|
||||
}
|
||||
@@ -1202,11 +1424,13 @@ export const shareApi = {
|
||||
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
|
||||
// Public transit routing (#1065) — proxied through the server, which picks the
|
||||
// backend (Transitous/MOTIS, or Google since #1699). `lang` reaches the Google
|
||||
// backend as the languageCode, so station names come back in the user's script.
|
||||
export const transitApi = {
|
||||
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
|
||||
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
|
||||
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
|
||||
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number; lang?: string }) =>
|
||||
apiClient.get('/transit/plan', { params }).then(r => r.data),
|
||||
}
|
||||
|
||||
@@ -1220,15 +1444,21 @@ export const tripInviteApi = {
|
||||
accept: (token: string) => apiClient.post(`/trip-invites/${token}/accept`).then(r => r.data),
|
||||
}
|
||||
|
||||
// A channel test dials a third party the admin just typed in, and the server
|
||||
// budgets for it: up to 20s for a wrong SMTP port, 10s for a webhook or ntfy
|
||||
// endpoint. The 8s instance timeout aborted those before the reason came back,
|
||||
// so the toast could only ever say "failed" (#2196).
|
||||
const CHANNEL_TEST_TIMEOUT = 40000
|
||||
|
||||
export const notificationsApi = {
|
||||
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
|
||||
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
|
||||
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }, { timeout: CHANNEL_TEST_TIMEOUT }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
|
||||
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }, { timeout: CHANNEL_TEST_TIMEOUT }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
|
||||
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload, { timeout: CHANNEL_TEST_TIMEOUT }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
|
||||
// Generic channel test — this is how a PLUGIN channel's "Send test" button works.
|
||||
testChannel: (channelId: string) =>
|
||||
apiClient.post(`/notifications/test/${encodeURIComponent(channelId)}`)
|
||||
apiClient.post(`/notifications/test/${encodeURIComponent(channelId)}`, undefined, { timeout: CHANNEL_TEST_TIMEOUT })
|
||||
.then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testChannel')),
|
||||
}
|
||||
|
||||
|
||||
@@ -59,23 +59,26 @@ describe('collectionsApi', () => {
|
||||
expect(res.collection.name).toBe('Tokyo')
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-003: create() posts the create payload', async () => {
|
||||
server.use(http.post(BASE, record({ collection })))
|
||||
// Both handlers answer with the BARE collection, the way the controller does —
|
||||
// stubbing a { collection } envelope here is what let the client keep reading
|
||||
// `.collection` off a response that never had one.
|
||||
it('FE-API-COLLECTIONS-003: create() posts the create payload and returns the list itself', async () => {
|
||||
server.use(http.post(BASE, record(collection)))
|
||||
|
||||
const res = await collectionsApi.create({ name: 'Tokyo', color: '#111827' })
|
||||
|
||||
expect(requestBody).toEqual({ name: 'Tokyo', color: '#111827' })
|
||||
expect(res.collection.id).toBe(1)
|
||||
expect(res.id).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-004: update() patches the list by id', async () => {
|
||||
server.use(http.patch(`${BASE}/:id`, record({ collection })))
|
||||
server.use(http.patch(`${BASE}/:id`, record(collection)))
|
||||
|
||||
const res = await collectionsApi.update(1, { name: 'Tokyo 2026' })
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/1`)
|
||||
expect(requestBody).toEqual({ name: 'Tokyo 2026' })
|
||||
expect(res.collection).toEqual(collection)
|
||||
expect(res).toEqual(collection)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-005: uploadCover() posts multipart to the cover endpoint', async () => {
|
||||
|
||||
@@ -53,9 +53,13 @@ export const collectionsApi = {
|
||||
ax.get(base).then((r: AxiosResponse) => r.data),
|
||||
get: (id: number): Promise<CollectionDetailResponse> =>
|
||||
ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data),
|
||||
create: (body: CollectionCreateRequest): Promise<{ collection: Collection }> =>
|
||||
// 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
|
||||
// undefined for every caller.
|
||||
create: (body: CollectionCreateRequest): Promise<Collection> =>
|
||||
ax.post(base, body satisfies CollectionCreateRequest).then((r: AxiosResponse) => r.data),
|
||||
update: (id: number, body: CollectionUpdateRequest): Promise<{ collection: Collection }> =>
|
||||
update: (id: number, body: CollectionUpdateRequest): Promise<Collection> =>
|
||||
ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
uploadCover: (id: number, formData: FormData): Promise<Collection> =>
|
||||
postMultipart(`${base}/${id}/cover`, formData),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { recordPlacePick } from './placeShadow'
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The point of this module is that it never gets in the way: it must not fire
|
||||
* when the instance has the log switched off, must not throw when the request
|
||||
* fails, and must not send coordinates finer than the ranking needs.
|
||||
*/
|
||||
|
||||
const PICK: PlaceShadowPickRequest = {
|
||||
query: ' losteria rostock ',
|
||||
lang: 'de',
|
||||
biasLat: 54.088712,
|
||||
biasLng: 12.140493,
|
||||
source: 'search:nominatim',
|
||||
liveRank: 0,
|
||||
liveCount: 8,
|
||||
pickedName: "L'Osteria",
|
||||
pickedLat: 54.0891234,
|
||||
pickedLng: 12.1372987,
|
||||
pickedPlaceId: 'osm:node/1',
|
||||
}
|
||||
|
||||
/** Resolves with the body the module posted, or null if it never posted. */
|
||||
function capture(status = 200) {
|
||||
let seen: unknown = null
|
||||
let resolve: (v: unknown) => void = () => {}
|
||||
const posted = new Promise<unknown>(r => { resolve = r })
|
||||
server.use(
|
||||
http.post('/api/place-shadow/pick', async ({ request }) => {
|
||||
seen = await request.json()
|
||||
resolve(seen)
|
||||
return status === 200
|
||||
? HttpResponse.json({ recorded: true })
|
||||
: HttpResponse.json({ error: 'nope' }, { status })
|
||||
}),
|
||||
)
|
||||
return { posted, seen: () => seen }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ placeShadowEnabled: true })
|
||||
})
|
||||
|
||||
describe('recordPlacePick', () => {
|
||||
it('sends nothing at all when the instance has the log switched off', async () => {
|
||||
useAuthStore.setState({ placeShadowEnabled: false })
|
||||
const cap = capture()
|
||||
recordPlacePick(PICK)
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(cap.seen()).toBeNull()
|
||||
})
|
||||
|
||||
it('trims the query and rounds every coordinate to three decimals', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick(PICK)
|
||||
expect(await cap.posted).toMatchObject({
|
||||
query: 'losteria rostock',
|
||||
pickedLat: 54.089,
|
||||
pickedLng: 12.137,
|
||||
biasLat: 54.089,
|
||||
biasLng: 12.14,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves an absent bias absent rather than sending a zero', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick({ ...PICK, biasLat: undefined, biasLng: undefined })
|
||||
const body = (await cap.posted) as Record<string, unknown>
|
||||
expect(body.biasLat).toBeUndefined()
|
||||
expect(body.biasLng).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a pick with no query, no name or an impossible rank before sending', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick({ ...PICK, query: ' ' })
|
||||
recordPlacePick({ ...PICK, pickedName: '' })
|
||||
recordPlacePick({ ...PICK, liveRank: 8, liveCount: 8 })
|
||||
recordPlacePick({ ...PICK, pickedLat: Number.NaN })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(cap.seen()).toBeNull()
|
||||
})
|
||||
|
||||
it('does not record a pick made out of the offline cache', async () => {
|
||||
// Such a row describes the local cache's ordering, not a provider's, and
|
||||
// the two figures this corpus exists for — how often the live answer was
|
||||
// first, and how often it was in the top five — are averaged over every
|
||||
// row. Mixing these in moves exactly the number a candidate index is later
|
||||
// judged against. Not merely an offline concern either: offline is a
|
||||
// setting, so the network is usually reachable and the POST would land.
|
||||
let posted = false
|
||||
server.use(http.post('/api/place-shadow/pick', () => { posted = true; return HttpResponse.json({ recorded: true }) }))
|
||||
|
||||
recordPlacePick({ ...PICK, source: 'search:offline-cache' })
|
||||
recordPlacePick({ ...PICK, source: 'autocomplete:offline-cache' })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
expect(posted).toBe(false)
|
||||
})
|
||||
|
||||
it('swallows a server error instead of surfacing it to the pick that triggered it', async () => {
|
||||
const cap = capture(500)
|
||||
const onUnhandled = vi.fn()
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
expect(() => recordPlacePick(PICK)).not.toThrow()
|
||||
await cap.posted
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
expect(onUnhandled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
|
||||
/**
|
||||
* Records which search result the user picked, for the local shadow corpus.
|
||||
*
|
||||
* Fire and forget in the strongest sense: it never throws, never blocks the
|
||||
* pick it is describing, and does nothing at all unless the instance switched
|
||||
* the log on. Adding a place must not get slower or more fragile because
|
||||
* somebody is collecting a measurement.
|
||||
*
|
||||
* The store flag is checked first so a switched-off instance makes no request
|
||||
* at all; the server checks it again, because a client is not a permission.
|
||||
*/
|
||||
|
||||
/** About 100 m. Enough to reproduce a ranking, too coarse to place someone. */
|
||||
function coarse(value: number): number {
|
||||
return Math.round(value * 1000) / 1000
|
||||
}
|
||||
|
||||
export function recordPlacePick(pick: PlaceShadowPickRequest): void {
|
||||
if (!useAuthStore.getState().placeShadowEnabled) return
|
||||
if (!Number.isFinite(pick.pickedLat) || !Number.isFinite(pick.pickedLng)) return
|
||||
if (pick.liveRank >= pick.liveCount) return
|
||||
// A pick out of the offline cache describes the cache's ordering, not a
|
||||
// provider's, and the two numbers this corpus exists for — how often the live
|
||||
// answer was first, and how often it was in the top five — are averaged over
|
||||
// every row. Mixing local rows in would move exactly the figure a candidate
|
||||
// index is later judged against.
|
||||
//
|
||||
// Not merely an offline concern: the offline switch is a setting, so the
|
||||
// network is usually reachable and the POST would go through.
|
||||
if (pick.source.endsWith('offline-cache')) return
|
||||
|
||||
const body: PlaceShadowPickRequest = {
|
||||
...pick,
|
||||
query: pick.query.trim().slice(0, 200),
|
||||
pickedName: pick.pickedName.slice(0, 300),
|
||||
pickedLat: coarse(pick.pickedLat),
|
||||
pickedLng: coarse(pick.pickedLng),
|
||||
biasLat: pick.biasLat === undefined ? undefined : coarse(pick.biasLat),
|
||||
biasLng: pick.biasLng === undefined ? undefined : coarse(pick.biasLng),
|
||||
}
|
||||
// The contract requires both; an unnamed result is not worth a row anyway.
|
||||
if (!body.query || !body.pickedName) return
|
||||
|
||||
void apiClient.post('/place-shadow/pick', body).catch(() => {
|
||||
// Deliberately silent. The user was adding a place, not running a study.
|
||||
})
|
||||
}
|
||||
@@ -30,6 +30,11 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// In-app notifications — hooks/useInAppNotificationListener
|
||||
'notification:new',
|
||||
'notification:updated',
|
||||
// Bag weights (#2191) — content-free ping; components/Packing/usePackingListPanel
|
||||
// and mobile/screens/trip/tabs/MPackingListTab re-read the bags on it. It carries
|
||||
// no state to apply, which is the point: the totals are summed server-side across
|
||||
// members whose items this client may not see.
|
||||
'packing:bag-totals',
|
||||
// Collections — pages/collections/useCollections ('collections:' prefix listener)
|
||||
'collections:updated',
|
||||
'collections:accepted',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -44,20 +44,16 @@ function modelsRoute(names: string[], seen?: (string | null)[]) {
|
||||
});
|
||||
}
|
||||
|
||||
/** The pill toggle of a top-level addon row. */
|
||||
/** Every switch is labelled with the thing it controls, top-level tile or shelf
|
||||
* row alike, so one lookup covers both. */
|
||||
function addonToggle(name: string): HTMLElement {
|
||||
const row = screen.getByText(name).closest('.px-6.py-4') as HTMLElement;
|
||||
return within(row).getByRole('button');
|
||||
return screen.getByRole('button', { name });
|
||||
}
|
||||
|
||||
/** The pill toggle of an indented sub-row (bag tracking, collab feature, photo provider). */
|
||||
function subToggle(label: string): HTMLElement {
|
||||
const row = screen.getByText(label).closest('.flex.items-center.gap-4') as HTMLElement;
|
||||
return within(row).getByRole('button');
|
||||
}
|
||||
const subToggle = addonToggle;
|
||||
|
||||
function isOn(toggle: HTMLElement): boolean {
|
||||
return toggle.style.background === 'var(--text-primary)';
|
||||
return toggle.getAttribute('aria-pressed') === 'true';
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -82,6 +78,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.documentElement.classList.remove('dark');
|
||||
});
|
||||
|
||||
describe('AddonManager', () => {
|
||||
@@ -144,13 +141,10 @@ describe('AddonManager', () => {
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
// Get toggle button - use getAllByRole since there might be multiple buttons
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
|
||||
expect(toggleBtn).toBeInTheDocument();
|
||||
const toggleBtn = addonToggle('Todo List');
|
||||
expect(isOn(toggleBtn)).toBe(false);
|
||||
|
||||
// Before click - disabled state (border-primary bg)
|
||||
await user.click(toggleBtn!);
|
||||
await user.click(toggleBtn);
|
||||
|
||||
// After click - success toast
|
||||
await screen.findByText('Addon updated');
|
||||
@@ -169,17 +163,14 @@ describe('AddonManager', () => {
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
|
||||
await user.click(toggleBtn!);
|
||||
await user.click(addonToggle('Todo List'));
|
||||
|
||||
// Error toast appears
|
||||
await screen.findByText('Failed to update addon');
|
||||
|
||||
// The disabled text should be back after rollback
|
||||
// The switch is back off after rollback
|
||||
await waitFor(() => {
|
||||
const disabledTexts = screen.getAllByText('Disabled');
|
||||
expect(disabledTexts.length).toBeGreaterThan(0);
|
||||
expect(isOn(addonToggle('Todo List'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,13 +186,7 @@ describe('AddonManager', () => {
|
||||
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />
|
||||
);
|
||||
await screen.findByText('Bag Tracking');
|
||||
const bagTrackingToggle = screen.getAllByRole('button').find(b =>
|
||||
b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking')
|
||||
);
|
||||
// Click the bag tracking toggle button (the h-6 w-11 button near "Bag Tracking")
|
||||
const allBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
|
||||
// There should be two toggle buttons: one for the addon, one for bag tracking
|
||||
await user.click(allBtns[allBtns.length - 1]);
|
||||
await user.click(subToggle('Bag Tracking'));
|
||||
expect(mockToggle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -251,9 +236,10 @@ describe('AddonManager', () => {
|
||||
// Journey addon is rendered
|
||||
expect(screen.getByText('Journey')).toBeInTheDocument();
|
||||
|
||||
// Toggle buttons: journey toggle + 2 provider toggles
|
||||
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
|
||||
expect(toggleBtns.length).toBe(3);
|
||||
// Journey's own switch plus one per provider, each labelled with what it controls
|
||||
expect(isOn(addonToggle('Journey'))).toBe(true);
|
||||
expect(isOn(subToggle('Unsplash'))).toBe(true);
|
||||
expect(isOn(subToggle('Pexels'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {
|
||||
@@ -278,7 +264,8 @@ describe('AddonManager', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-013: dark mode swaps the wordmark in the header', async () => {
|
||||
seedStore(useSettingsStore, { settings: { dark_mode: 'dark' } });
|
||||
// The wordmark reads the .dark class, the same source applyAppearance() writes.
|
||||
document.documentElement.classList.add('dark');
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('No addons available');
|
||||
@@ -309,11 +296,11 @@ describe('AddonManager', () => {
|
||||
|
||||
await screen.findByText('Immich');
|
||||
// immich and synologyphotos ship a vendor glyph, unsplash does not
|
||||
const immichRow = screen.getByText('Immich').closest('.flex.items-center.gap-4') as HTMLElement;
|
||||
const immichRow = screen.getByText('Immich').closest('li') as HTMLElement;
|
||||
expect(immichRow.querySelector('svg')).toBeInTheDocument();
|
||||
const synologyRow = screen.getByText('Synology Photos').closest('.flex.items-center.gap-4') as HTMLElement;
|
||||
const synologyRow = screen.getByText('Synology Photos').closest('li') as HTMLElement;
|
||||
expect(synologyRow.querySelector('svg')).toBeInTheDocument();
|
||||
const unsplashRow = screen.getByText('Unsplash').closest('.flex.items-center.gap-4') as HTMLElement;
|
||||
const unsplashRow = screen.getByText('Unsplash').closest('li') as HTMLElement;
|
||||
expect(unsplashRow.querySelector('svg')).not.toBeInTheDocument();
|
||||
|
||||
expect(isOn(subToggle('Immich'))).toBe(true);
|
||||
@@ -361,6 +348,76 @@ describe('AddonManager', () => {
|
||||
await waitFor(() => expect(isOn(subToggle('Unsplash'))).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-030: provider sub-rows stay hidden while Journey itself is off', async () => {
|
||||
server.use(addonsRoute([
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
|
||||
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }),
|
||||
buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }),
|
||||
]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Journey');
|
||||
expect(screen.queryByText('Immich')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Synology Photos')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-031: switching Journey off and on again shows the cascaded providers as off', async () => {
|
||||
const user = userEvent.setup();
|
||||
let loads = 0;
|
||||
let journeyOn = true;
|
||||
let immichOn = true;
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => {
|
||||
loads += 1;
|
||||
return HttpResponse.json({
|
||||
addons: [
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: journeyOn }),
|
||||
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: immichOn }),
|
||||
],
|
||||
});
|
||||
}),
|
||||
http.put('/api/admin/addons/journey', async ({ request }) => {
|
||||
journeyOn = (await request.json() as { enabled: boolean }).enabled;
|
||||
// Journey off takes its providers with it, see admin.service.ts.
|
||||
if (!journeyOn) immichOn = false;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Immich');
|
||||
|
||||
await user.click(addonToggle('Journey'));
|
||||
// The toast closes the whole toggle, re-read included.
|
||||
await screen.findByText('Addon updated');
|
||||
expect(loads).toBe(2);
|
||||
expect(screen.queryByText('Immich')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(addonToggle('Journey'));
|
||||
|
||||
await screen.findByText('Immich');
|
||||
expect(isOn(subToggle('Immich'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-032: a toggle the server does not cascade keeps the snapshot it has', async () => {
|
||||
const user = userEvent.setup();
|
||||
let loads = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => {
|
||||
loads += 1;
|
||||
return HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] });
|
||||
}),
|
||||
http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
await user.click(addonToggle('Todo List'));
|
||||
|
||||
await screen.findByText('Addon updated');
|
||||
expect(loads).toBe(1);
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleCollabFeature = vi.fn();
|
||||
@@ -396,8 +453,21 @@ describe('AddonManager', () => {
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('AI Parsing');
|
||||
expect(screen.getByText('Extract bookings from files')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Connection')).not.toBeInTheDocument();
|
||||
// The catalog key now exists, so the tile shows the translation rather than falling
|
||||
// back to the English description the server sent.
|
||||
expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Provider')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-020b: the config lives in the tile shelf, not as a band below the grid', async () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]));
|
||||
render(<AddonManager />);
|
||||
|
||||
const label = await screen.findByText('Provider');
|
||||
// Inside the AI Parsing tile's <article>, like the collab toggles sit in theirs.
|
||||
const tile = label.closest('article');
|
||||
expect(tile).not.toBeNull();
|
||||
expect(tile!.textContent).toContain('AI Parsing');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
|
||||
@@ -615,7 +685,7 @@ describe('AddonManager', () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Connection');
|
||||
await screen.findByText('Provider');
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useEffect, useState, type ComponentType } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
|
||||
// Keys are the `icon` column from the addons table (see server seeds.ts); anything
|
||||
// unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which
|
||||
// used to land on the fallback.
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark,
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
|
||||
}
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
@@ -60,6 +66,14 @@ function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
||||
return <Icon size={size} />
|
||||
}
|
||||
|
||||
/** What each type means, shown on the tile itself now that the sections are gone. The
|
||||
* hint rides along as the tooltip, so "Global" still explains itself. */
|
||||
const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; labelKey: string; hintKey: string }> = {
|
||||
trip: { icon: Briefcase, labelKey: 'admin.addons.type.trip', hintKey: 'admin.addons.tripHint' },
|
||||
global: { icon: Globe, labelKey: 'admin.addons.type.global', hintKey: 'admin.addons.globalHint' },
|
||||
integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' },
|
||||
}
|
||||
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
|
||||
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
@@ -71,42 +85,43 @@ const COLLAB_SUB_FEATURES = [
|
||||
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const dark = useIsDark()
|
||||
const toast = useToast()
|
||||
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
|
||||
const [addons, setAddons] = useState<Addon[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadAddons()
|
||||
loadAddons().finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const loadAddons = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.addons()
|
||||
setAddons(data.addons)
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistic flip, per-row rollback on failure. Rolling the whole addons
|
||||
* snapshot back instead would undo a toggle the user hit in parallel. */
|
||||
const handleToggle = async (addon: Addon) => {
|
||||
const newEnabled = !addon.enabled
|
||||
// Optimistic update
|
||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
||||
try {
|
||||
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
||||
refreshGlobalAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch (err: unknown) {
|
||||
// Rollback
|
||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a))
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
return
|
||||
}
|
||||
refreshGlobalAddons()
|
||||
// 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()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
}
|
||||
|
||||
const isPhotoProviderAddon = (addon: Addon) => {
|
||||
@@ -118,24 +133,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
|
||||
}
|
||||
|
||||
const handleTogglePhotoProvider = async (providerAddon: Addon) => {
|
||||
const enableProvider = !providerAddon.enabled
|
||||
const prev = addons
|
||||
|
||||
setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
|
||||
|
||||
try {
|
||||
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
|
||||
refreshGlobalAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch {
|
||||
setAddons(prev)
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
|
||||
const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon)
|
||||
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')
|
||||
@@ -144,173 +142,160 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
label: provider.name,
|
||||
description: provider.description,
|
||||
enabled: provider.enabled,
|
||||
toggle: () => handleTogglePhotoProvider(provider),
|
||||
toggle: () => handleToggle(provider),
|
||||
}))
|
||||
const photosDerivedEnabled = providerOptions.some(p => p.enabled)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" style={{ borderTopColor: 'var(--text-primary)' }}></div>
|
||||
<div className="grid place-items-center py-16">
|
||||
<Loader2 size={22} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Bag tracking, the collab features and the photo providers all hang off their
|
||||
* parent as shelf rows, and each shelf only shows while its parent is on —
|
||||
* a provider toggled under a disabled Journey would hit the server's 409. */
|
||||
const shelfFor = (addon: Addon) => {
|
||||
if (addon.id === 'packing' && addon.enabled && onToggleBagTracking) {
|
||||
return (
|
||||
<AddonSubRow
|
||||
icon={<Luggage size={14} />}
|
||||
title={t('admin.bagTracking.title')}
|
||||
description={t('admin.bagTracking.subtitle')}
|
||||
enabled={!!bagTrackingEnabled}
|
||||
onToggle={onToggleBagTracking}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature) {
|
||||
return COLLAB_SUB_FEATURES.map(feat => {
|
||||
const Icon = feat.icon
|
||||
return (
|
||||
<AddonSubRow
|
||||
key={feat.key}
|
||||
icon={<Icon size={14} />}
|
||||
title={t(feat.titleKey)}
|
||||
description={t(feat.subtitleKey)}
|
||||
enabled={collabFeatures[feat.key]}
|
||||
onToggle={() => onToggleCollabFeature(feat.key)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) {
|
||||
return providerOptions.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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
// The AI-parsing settings hang off their tile like every other sub-feature,
|
||||
// just as a form instead of toggle rows.
|
||||
if (addon.id === 'llm_parsing' && addon.enabled) {
|
||||
return <LlmParsingConfig addon={addon} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const tile = (addon: Addon) => {
|
||||
const label = getAddonLabel(t, addon)
|
||||
return (
|
||||
<AddonTile
|
||||
key={addon.id}
|
||||
icon={<AddonIcon name={addon.icon} size={18} />}
|
||||
name={label.name}
|
||||
description={label.description}
|
||||
enabled={addon.enabled}
|
||||
onToggle={() => handleToggle(addon)}
|
||||
>
|
||||
{shelfFor(addon)}
|
||||
</AddonTile>
|
||||
)
|
||||
}
|
||||
|
||||
/* One column per type, side by side, instead of three stacked sections. Stacked, each
|
||||
section opened its own set of columns and the tall tiles (Collab 261px, Journey
|
||||
185px) set a section's height while its neighbours ran out early. Side by side the
|
||||
column heading carries the type, so the tiles need no badge of their own. */
|
||||
const groups = [
|
||||
{ key: 'trip', addons: tripAddons },
|
||||
{ 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 totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="px-6 py-4 border-b border-edge-secondary">
|
||||
<h2 className="font-semibold text-content">{t('admin.addons.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<div>
|
||||
<h2 className="text-subtitle font-semibold tracking-tight text-content">{t('admin.addons.title')}</h2>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-1 text-caption text-content-muted">
|
||||
{t('admin.addons.subtitleBefore')}
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, verticalAlign: 'middle', opacity: 0.7 }} />
|
||||
{t('admin.addons.subtitleAfter')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{addons.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-content-faint">
|
||||
{t('admin.addons.noAddons')}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Trip Addons */}
|
||||
{tripAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Briefcase size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
|
||||
</span>
|
||||
</div>
|
||||
{tripAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<Luggage size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium text-content-secondary">{t('admin.bagTracking.title')}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{t('admin.bagTracking.subtitle')}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button type="button" onClick={onToggleBagTracking}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
|
||||
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{COLLAB_SUB_FEATURES.map(feat => {
|
||||
const enabled = collabFeatures[feat.key]
|
||||
const Icon = feat.icon
|
||||
return (
|
||||
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
<Icon size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium text-content-secondary">{t(feat.titleKey)}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{t(feat.subtitleKey)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button type="button" onClick={() => onToggleCollabFeature(feat.key)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Addons */}
|
||||
{globalAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Globe size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
|
||||
</span>
|
||||
</div>
|
||||
{globalAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{/* Memories providers as sub-items under Journey addon */}
|
||||
{addon.id === 'journey' && providerOptions.length > 0 && (
|
||||
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{providerOptions.map(provider => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||
return (
|
||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
{ProviderIcon && <span className="text-content-faint"><ProviderIcon size={14} /></span>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium text-content-secondary">{provider.label}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{provider.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button type="button"
|
||||
onClick={provider.toggle}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integration Addons */}
|
||||
{integrationAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Link2 size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
|
||||
</span>
|
||||
</div>
|
||||
{integrationAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{addon.id === 'llm_parsing' && addon.enabled && (
|
||||
<LlmParsingConfig addon={addon} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{totalCount > 0 && (
|
||||
<span
|
||||
className="shrink-0 text-caption tabular-nums text-content-faint"
|
||||
title={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}
|
||||
>
|
||||
{enabledCount}/{totalCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{addons.length === 0 ? (
|
||||
<EmptyState scene="idle" title={t('admin.addons.noAddons')} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 items-start gap-x-4 gap-y-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map(g => {
|
||||
const meta = TYPE_META[g.key]
|
||||
return (
|
||||
<section key={g.key}>
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
|
||||
<div className="space-y-3">{g.addons.map(tile)}</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
icon: ComponentType<{ size?: number; className?: string }>
|
||||
label: string
|
||||
hint: string
|
||||
addons: Addon[]
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}) {
|
||||
const enabled = addons.filter(a => a.enabled).length
|
||||
return (
|
||||
<div
|
||||
className="mb-3 rounded-xl border border-edge-secondary bg-surface-secondary px-3 py-2"
|
||||
title={t('admin.addons.group.count', { enabled, total: addons.length })}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={13} className="shrink-0 text-content-muted" />
|
||||
<h3 className="min-w-0 flex-1 truncate text-caption font-semibold uppercase tracking-[0.06em] text-content">{label}</h3>
|
||||
<span className="shrink-0 rounded-full bg-surface px-1.5 py-0.5 text-caption font-medium tabular-nums text-content-muted">
|
||||
{enabled}/{addons.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* Says what the type means — the only place that still explains it. */}
|
||||
<p className="mt-0.5 text-caption text-content-faint">{hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -408,9 +393,8 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
}
|
||||
}
|
||||
|
||||
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
|
||||
const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary'
|
||||
const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint'
|
||||
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-2.5 py-1.5 text-caption text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
|
||||
const labelCls = 'mb-1 block text-caption font-medium text-content-secondary'
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
|
||||
@@ -418,131 +402,113 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
|
||||
]
|
||||
|
||||
/* Lives in the tile's shelf like the collab toggles, so everything is caption-
|
||||
sized and single-column — the band this used to be had a whole page width. */
|
||||
return (
|
||||
<div className="border-b border-edge-secondary bg-surface-secondary py-5 pr-6 pl-[70px]">
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<p className="text-xs text-content-faint">
|
||||
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
|
||||
</p>
|
||||
<li className="space-y-2.5 py-1.5">
|
||||
<p className="text-caption text-content-faint">
|
||||
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
|
||||
</p>
|
||||
|
||||
{/* Connection */}
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Connection</div>
|
||||
<div>
|
||||
<span className={labelCls}>Provider</span>
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
<div>
|
||||
<span className={labelCls}>Provider</span>
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
</div>
|
||||
{provider !== 'anthropic' && (
|
||||
<label className="block">
|
||||
<span className={labelCls}>Base URL</span>
|
||||
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
||||
</label>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className={labelCls}>API key</span>
|
||||
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</label>
|
||||
{provider === 'anthropic' && (
|
||||
<p className="text-caption text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className={labelCls}>Model</span>
|
||||
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</label>
|
||||
|
||||
{/* Local model management (Ollama) */}
|
||||
{provider === 'local' && (
|
||||
<div className="space-y-2 rounded-lg border border-edge-secondary bg-surface p-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-caption font-medium text-content-secondary">Installed on the server</span>
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-caption text-content-muted underline disabled:opacity-60">
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{provider !== 'anthropic' && (
|
||||
<label className="block">
|
||||
<span className={labelCls}>Base URL</span>
|
||||
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
||||
</label>
|
||||
{modelsErr && <p className="text-caption text-danger">{modelsErr}</p>}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<p className="text-caption text-content-faint">No models installed yet — pull one below.</p>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className={labelCls}>API key</span>
|
||||
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</label>
|
||||
{provider === 'anthropic' && (
|
||||
<p className="text-xs text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Model */}
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Model</div>
|
||||
<label className="block">
|
||||
<span className="sr-only">Model</span>
|
||||
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</label>
|
||||
|
||||
{/* Local model management (Ollama) */}
|
||||
{provider === 'local' && (
|
||||
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{installed.map(name => (
|
||||
<button type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
className={`max-w-full truncate rounded-full border px-2 py-0.5 text-caption transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
</div>
|
||||
{modelsErr && <p className="text-xs text-rose-600">{modelsErr}</p>}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<p className="text-xs text-content-faint">No models installed yet — pull one below.</p>
|
||||
)}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
className={`max-w-full truncate rounded-full border px-2.5 py-1 text-xs transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-edge-secondary pt-3">
|
||||
<div className="mb-2 text-xs font-medium text-content-secondary">Pull a recommended model</div>
|
||||
<div className="space-y-1">
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className={`flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors ${active ? 'border-edge-secondary bg-surface-secondary' : 'border-transparent'}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-content">{m.label}</span>
|
||||
{m.recommended && (
|
||||
<span className="rounded-md bg-[rgba(16,185,129,0.15)] px-1.5 py-px text-[10px] font-semibold text-emerald-600">Recommended</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-content-faint">{m.note}</div>
|
||||
{isPulling && (
|
||||
<div className="mt-1.5">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{installedHere ? (
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
|
||||
<div className="border-t border-edge-secondary pt-2">
|
||||
<div className="mb-1.5 text-caption font-medium text-content-secondary">Pull a recommended model</div>
|
||||
<div className="space-y-1">
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-caption text-content">{m.label}</span>
|
||||
{m.recommended && (
|
||||
<span className="shrink-0 rounded-md bg-success-soft px-1.5 py-px text-caption font-semibold text-success">Recommended</span>
|
||||
)}
|
||||
{installedHere ? (
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-2 py-1 text-caption font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-2 py-1 text-caption font-medium text-accent-text disabled:opacity-60">
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isPulling && (
|
||||
<div className="mt-1">
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-0.5 text-caption text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-3 py-1.5 text-caption font-medium text-accent-text transition-opacity disabled:opacity-60">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
interface AddonRowProps {
|
||||
addon: Addon
|
||||
onToggle: (addon: Addon) => void
|
||||
t: (key: string) => string
|
||||
statusOverride?: boolean
|
||||
hideToggle?: boolean
|
||||
}
|
||||
|
||||
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
||||
const nameKey = `admin.addons.catalog.${addon.id}.name`
|
||||
const descKey = `admin.addons.catalog.${addon.id}.description`
|
||||
@@ -555,56 +521,3 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string
|
||||
}
|
||||
}
|
||||
|
||||
function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
|
||||
const isComingSoon = false
|
||||
const label = getAddonLabel(t, addon)
|
||||
const displayName = nameOverride || label.name
|
||||
const displayDescription = descriptionOverride || label.description
|
||||
const enabledState = statusOverride ?? addon.enabled
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95 border-edge-secondary" style={{ opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-surface-secondary text-content">
|
||||
<AddonIcon name={addon.icon} size={20} />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-content">{displayName}</span>
|
||||
{isComingSoon && (
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full text-content-faint bg-surface-tertiary">
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-surface-secondary text-content-muted">
|
||||
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5 text-content-muted">{displayDescription}</p>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${(enabledState && !isComingSoon) ? 'text-content' : 'text-content-faint'}`}>
|
||||
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
{!hideToggle && (
|
||||
<button type="button"
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform bg-surface-card"
|
||||
style={{
|
||||
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
|
||||
/**
|
||||
* One child row inside an {@link AddonTile}'s sub-shelf: bag tracking under
|
||||
* Lists, a collab feature under Collab, a photo provider under Journey.
|
||||
*
|
||||
* A photo provider without a vendor mark has to stay free of `<svg>`
|
||||
* (FE-ADMIN-ADDON-015), which is why `icon` falls back to a fixed-width spacer
|
||||
* rather than a placeholder glyph. Nothing else belongs in this row either: no
|
||||
* chevron, no status dot, no lucide fallback.
|
||||
*
|
||||
* The row carries its title only; the description rides along as the `title`
|
||||
* tooltip, because a second line of text is what makes a child weigh as much as
|
||||
* an addon.
|
||||
*/
|
||||
export default function AddonSubRow({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: {
|
||||
/** Vendor mark or lucide glyph at 14px. Omit it and the slot keeps its width. */
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<li className="flex min-h-[36px] items-center gap-3">
|
||||
{icon ? <span className="shrink-0 text-content-faint">{icon}</span> : <span className="w-3.5 shrink-0" aria-hidden />}
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-medium text-content-secondary" title={description}>
|
||||
{title}
|
||||
</span>
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={title} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
|
||||
/**
|
||||
* One addon in the admin grid: a card whose fill carries the on/off state, with
|
||||
* an optional shelf at the foot holding its sub-features.
|
||||
*
|
||||
* The fill ladder is `--bg-secondary` (on) against `--bg-card` (the panel it
|
||||
* sits on) — that pair reads in both themes, because secondary is *darker* than
|
||||
* the card in light and *lighter* in dark. The obvious alternative, on =
|
||||
* `bg-surface-card`, inverts in dark and turns the disabled tiles into the loud
|
||||
* ones.
|
||||
*
|
||||
* Hover and focus ride on border and fill rather than a shadow: `--shadow-card`
|
||||
* is only declared in `:root`, so the `.dark` palette never overrides it and a
|
||||
* shadow-based affordance would be invisible there.
|
||||
*
|
||||
* The card is not a click target — only the switches are. No lift, no pointer
|
||||
* cursor, no `role="button"`.
|
||||
*/
|
||||
export default function AddonTile({
|
||||
icon,
|
||||
name,
|
||||
description,
|
||||
enabled,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
name: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
onToggle: () => void
|
||||
/** The sub-shelf: `<AddonSubRow>` children. */
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<article
|
||||
className={`flex flex-col overflow-hidden rounded-xl border transition-[background-color,border-color] duration-150 focus-within:ring-2 focus-within:ring-accent ${
|
||||
enabled
|
||||
? 'border-edge bg-surface-secondary'
|
||||
: 'border-edge-secondary bg-surface hover:border-edge hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<div
|
||||
className={`grid h-10 w-10 shrink-0 place-items-center rounded-xl ${
|
||||
enabled ? 'border border-accent bg-accent-subtle text-accent-on' : 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* The name stays full-strength when off: it is the tile's identity, not its
|
||||
state. Greying it out reads as "broken" rather than "not enabled". */}
|
||||
<h4 className="truncate text-body font-semibold text-content" title={name}>
|
||||
{name}
|
||||
</h4>
|
||||
<p
|
||||
className={`mt-0.5 line-clamp-2 text-caption ${enabled ? 'text-content-muted' : 'text-content-faint'}`}
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={name} />
|
||||
</div>
|
||||
|
||||
{children && (
|
||||
<div className="border-t border-edge-secondary bg-surface-tertiary px-4 py-2.5">
|
||||
{/* Sitting inside the parent on its own fill is enough to say these belong
|
||||
to it — no rail, no indent guides. */}
|
||||
<ul className="space-y-0.5">{children}</ul>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -129,7 +129,9 @@ describe('AdminPluginsPanel — Discover modal, operator-egress pill', () => {
|
||||
* the UI. It must escape every overflow ancestor, and flip up when the bottom is tight.
|
||||
*/
|
||||
describe('AdminPluginsPanel — row ⋯ menu is never clipped (#1523)', () => {
|
||||
const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: false })
|
||||
// operatorEgress so the menu shows every action, Allowed hosts included (it is
|
||||
// gated on the declaration, like the row chip).
|
||||
const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: true })
|
||||
const realRect = HTMLButtonElement.prototype.getBoundingClientRect
|
||||
afterEach(() => { HTMLButtonElement.prototype.getBoundingClientRect = realRect })
|
||||
|
||||
@@ -281,6 +283,10 @@ describe('AdminPluginsPanel — a refused update', () => {
|
||||
expect(await screen.findByText(/key it was installed with/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/key it is offering now/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument()
|
||||
// The dialog body scrolls; the decision buttons sit outside it, so they never leave the viewport (#2159).
|
||||
const body = screen.getByText(/key it is offering now/i).closest('.overflow-y-auto')
|
||||
expect(body).not.toBeNull()
|
||||
expect(body).not.toContainElement(screen.getByRole('button', { name: /trust the new key/i }))
|
||||
})
|
||||
|
||||
// D2, at the UI. An invalid signature means the bytes are not what the author signed.
|
||||
@@ -843,6 +849,30 @@ describe('AdminPluginsPanel — row actions', () => {
|
||||
expect(screen.getByText('View error log')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Restart')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-049: Allowed hosts is offered only to a plugin that declared operatorEgress', async () => {
|
||||
// An admin must never be invited to widen egress for a plugin that didn't ask for it
|
||||
// — same rule the row chip already follows.
|
||||
await openRowMenu(plugin({ operatorEgress: false }))
|
||||
|
||||
expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open
|
||||
expect(screen.queryByText('Allowed hosts')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-050: a long host list scrolls inside the dialog instead of growing past the viewport (#2159)', async () => {
|
||||
server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
|
||||
HttpResponse.json({ supported: true, hosts: ['gotify.lan', 'ntfy.lan'] })))
|
||||
await openRowMenu()
|
||||
|
||||
fireEvent.click(screen.getByText('Allowed hosts'))
|
||||
|
||||
// The card is capped and its body scrolls, so the input, the Add button and the
|
||||
// restart note below the list stay reachable however many hosts the admin entered.
|
||||
const body = (await screen.findByText('gotify.lan')).closest('.overflow-y-auto')
|
||||
expect(body).not.toBeNull()
|
||||
expect(body).toContainElement(screen.getByPlaceholderText('gotify.example.com'))
|
||||
expect(body!.parentElement!.className).toContain('max-h-[88vh]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdminPluginsPanel — enabling a plugin', () => {
|
||||
@@ -947,7 +977,11 @@ describe('AdminPluginsPanel — enabling a plugin', () => {
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
fireEvent.click(rowToggle('Gotify'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^cancel$/i }))
|
||||
// The dependency list scrolls; Cancel sits outside it, so it never leaves the viewport (#2159).
|
||||
const body = (await screen.findByText('trek-core')).closest('.overflow-y-auto')
|
||||
expect(body).not.toBeNull()
|
||||
expect(body).not.toContainElement(screen.getByRole('button', { name: /^cancel$/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument())
|
||||
expect(installs).toBe(0)
|
||||
@@ -967,6 +1001,11 @@ describe('AdminPluginsPanel — enabling a plugin', () => {
|
||||
// An unknown permission is still shown — as its raw code
|
||||
expect(screen.getByText('weird:perm')).toBeInTheDocument()
|
||||
expect(screen.getByText('api.acme.io')).toBeInTheDocument()
|
||||
// A long permission list scrolls inside the dialog; the decision buttons sit outside
|
||||
// the scroll container, so they never leave the viewport (#2159).
|
||||
const body = screen.getByText('Read trips the acting user can access').closest('.overflow-y-auto')
|
||||
expect(body).not.toBeNull()
|
||||
expect(body).not.toContainElement(screen.getByRole('button', { name: /keep off for now/i }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /keep off for now/i }))
|
||||
expect(await screen.findByText(/left off until you approve/i)).toBeInTheDocument()
|
||||
@@ -1439,6 +1478,18 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
|
||||
expect(await screen.findByText('Could not load plugin details.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-041a: the card opts out of the global press-scale (#2158)', async () => {
|
||||
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
|
||||
// the card shifted the Install button out from under the pointer, so the click
|
||||
// retargeted onto the card and opened the detail modal instead of installing.
|
||||
// The data-no-press attribute is the pin.
|
||||
discoverWith({})
|
||||
render(<AdminPluginsPanel />)
|
||||
await clickDiscover()
|
||||
|
||||
expect((await screen.findByText('Gotify')).closest('[role="button"]')).toHaveAttribute('data-no-press')
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-042: the detail modal lists setup fields, metadata and a separate homepage', async () => {
|
||||
discoverWith(
|
||||
{ downloadCount: 999_500, reviewedAt: '2026-01-05T00:00:00Z', homepage: 'https://gotify.example', trek: '>=3.2.0' },
|
||||
@@ -1763,7 +1814,9 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
|
||||
it('FE-W5PLG-011: a plugin whose runtime cannot take extra hosts says so', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPanel([row()])
|
||||
// Declares operatorEgress (so the menu item shows), but the runtime lookup
|
||||
// disagrees — the dialog's unsupported notice is the truth-teller.
|
||||
mockPanel([row({ operatorEgress: true })])
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ supported: false, hosts: [] })),
|
||||
)
|
||||
@@ -1778,7 +1831,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
|
||||
it('FE-W5PLG-012: a failing egress lookup still opens the dialog in the unsupported state', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPanel([row()])
|
||||
mockPanel([row({ operatorEgress: true })])
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
||||
)
|
||||
@@ -2134,3 +2187,357 @@ describe('AdminPluginsPanel — update all', () => {
|
||||
await waitFor(() => expect(started).toEqual(['a-widget', 'b-widget']))
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The admin-owned `scope:'instance'` settings form. The fields are declared manifest
|
||||
* data (validated at install), the values live in the plugin's instance config, and a
|
||||
* save may RESTART a running plugin — the child reads config once, at init — so the
|
||||
* admin must be told when that happened rather than wondering why the plugin blinked.
|
||||
*/
|
||||
describe('AdminPluginsPanel — instance settings', () => {
|
||||
const FIELDS = [
|
||||
{ key: 'apiUrl', label: 'API URL', input_type: 'text', required: true, secret: false },
|
||||
{ key: 'apiKey', label: 'API key', input_type: 'text', required: false, secret: true },
|
||||
]
|
||||
|
||||
async function openRowMenu(p: Record<string, unknown>) {
|
||||
mockList(p)
|
||||
render(<><ToastContainer /><AdminPluginsPanel /></>)
|
||||
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
|
||||
}
|
||||
|
||||
async function openSettings(config: Record<string, unknown> = { apiUrl: 'https://gotify.mydomain.com', apiKey: '••••••••' }) {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ fields: FIELDS, config })),
|
||||
)
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 2 }))
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 })
|
||||
}
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-001: a plugin with instance fields offers the menu item, opening the form', async () => {
|
||||
await openSettings()
|
||||
// Both declared fields render, the secret as a masked password input.
|
||||
expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('••••••••')).toHaveAttribute('type', 'password')
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-002: a plugin with NO instance fields gets no menu item', async () => {
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 0 }))
|
||||
expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open
|
||||
expect(screen.queryByText('Instance settings')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-008: a plugin with instance ACTIONS but no fields still offers the menu item', async () => {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () =>
|
||||
HttpResponse.json({ fields: [], config: {}, actions: [{ key: 'ping', label: 'Ping server', danger: false, scope: 'instance' }] })),
|
||||
)
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 1 }))
|
||||
expect(screen.getByText('Instance settings')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
expect(await screen.findByRole('button', { name: 'Ping server' }, { timeout: 5000 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-003: saving sends the edits but never the untouched secret mask', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ config: body, restarted: false })
|
||||
}),
|
||||
)
|
||||
await openSettings()
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
// The mask is a display artifact, not a value — sending it would overwrite the stored secret.
|
||||
expect(body).toEqual({ apiUrl: 'https://new.example' })
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-004: a save that restarted the running plugin says so', async () => {
|
||||
server.use(
|
||||
http.put('*/api/admin/plugins/trek-gotify/config', () =>
|
||||
HttpResponse.json({ config: { apiUrl: 'https://gotify.mydomain.com' }, restarted: true })),
|
||||
)
|
||||
await openSettings()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
expect(await screen.findByText('Settings saved — plugin restarted', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-005: renders every declared field type and saves the edited values', async () => {
|
||||
const RICH = [
|
||||
{ key: 'mode', label: 'Mode', input_type: 'select', options: [{ value: 'fast', label: 'Fast' }, { value: 'slow', label: 'Slow' }], hint: 'Pick one' },
|
||||
{ key: 'enabled', input_type: 'checkbox' }, // no label — the key stands in
|
||||
{ key: 'retries', label: 'Retries', input_type: 'number', required: true },
|
||||
]
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () =>
|
||||
HttpResponse.json({ fields: RICH, config: { mode: 'slow', enabled: true } })),
|
||||
http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ config: body, restarted: false })
|
||||
}),
|
||||
)
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 3 }))
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
|
||||
// Stored values land in the right controls; an unset value renders empty.
|
||||
const select = await screen.findByRole('combobox', {}, { timeout: 5000 })
|
||||
expect(select).toHaveValue('slow')
|
||||
expect(screen.getByRole('checkbox')).toBeChecked()
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(null)
|
||||
expect(screen.getByText('Pick one')).toBeInTheDocument() // hint
|
||||
expect(screen.getByText('enabled')).toBeInTheDocument() // label falls back to the key
|
||||
|
||||
fireEvent.change(select, { target: { value: 'fast' } })
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(body).toEqual({ mode: 'fast', enabled: false, retries: '3' })
|
||||
})
|
||||
|
||||
const ACTIONS = [
|
||||
{ key: 'ping', label: 'Ping server', danger: false, scope: 'instance' },
|
||||
{ key: 'purge', label: 'Purge cache', hint: 'Drops every cached tile', danger: true, scope: 'instance' },
|
||||
]
|
||||
|
||||
async function openWithActions(p: Record<string, unknown> = plugin({ instanceSettingsCount: 2 })) {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () =>
|
||||
HttpResponse.json({ fields: FIELDS, config: { apiUrl: 'https://gotify.mydomain.com' }, actions: ACTIONS })),
|
||||
)
|
||||
await openRowMenu(p)
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 })
|
||||
}
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-010: instance actions render below the fields and run against the admin route', async () => {
|
||||
let posted = ''
|
||||
server.use(
|
||||
http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => {
|
||||
posted = String(params.key)
|
||||
return HttpResponse.json({ ok: true, message: 'pong' })
|
||||
}),
|
||||
)
|
||||
await openWithActions()
|
||||
expect(screen.getByText('Drops every cached tile')).toBeInTheDocument() // hint
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ping server' }))
|
||||
expect(await screen.findByText('pong', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(posted).toBe('ping')
|
||||
// The dialog stays open — the admin may run another one.
|
||||
expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-011: a danger action asks first; confirming runs it', async () => {
|
||||
let posted = ''
|
||||
server.use(
|
||||
http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => {
|
||||
posted = String(params.key)
|
||||
return HttpResponse.json({ ok: false, message: 'cache locked' })
|
||||
}),
|
||||
)
|
||||
await openWithActions()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Purge cache' }))
|
||||
expect(await screen.findByText('Run this action?')).toBeInTheDocument()
|
||||
expect(posted).toBe('')
|
||||
fireEvent.click(screen.getByRole('button', { name: /^confirm$/i }))
|
||||
expect(await screen.findByText('cache locked', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(posted).toBe('purge')
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-012: an inactive plugin shows the buttons disabled with the activation hint', async () => {
|
||||
await openWithActions(plugin({ instanceSettingsCount: 2, status: 'inactive', enabled: 0 }))
|
||||
expect(screen.getByRole('button', { name: 'Ping server' })).toBeDisabled()
|
||||
expect(screen.getByText('Activate the plugin to run its actions')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-013: a dirty form is saved before the action runs', async () => {
|
||||
const calls: string[] = []
|
||||
server.use(
|
||||
http.put('*/api/admin/plugins/trek-gotify/config', () => { calls.push('save'); return HttpResponse.json({ config: {}, restarted: false }) }),
|
||||
http.post('*/api/admin/plugins/trek-gotify/actions/:key', () => { calls.push('run'); return HttpResponse.json({ ok: true }) }),
|
||||
)
|
||||
await openWithActions()
|
||||
fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ping server' }))
|
||||
await waitFor(() => expect(calls).toEqual(['save', 'run']), { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-006: a failed config fetch is a toast, not a broken dialog', async () => {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
||||
)
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 2 }))
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
|
||||
expect(await screen.findByText('Error', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-007: a rejected save shows the server reason and keeps the dialog open', async () => {
|
||||
server.use(
|
||||
http.put('*/api/admin/plugins/trek-gotify/config', () =>
|
||||
HttpResponse.json({ error: 'config refused' }, { status: 400 })),
|
||||
)
|
||||
await openSettings()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
expect(await screen.findByText('config refused', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
// Still open — the admin's edits are not thrown away on a refusal.
|
||||
expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-008: the dialog closes without saving', async () => {
|
||||
await openSettings()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^close$/i }))
|
||||
|
||||
expect(screen.queryByDisplayValue('https://gotify.mydomain.com')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-009: pre-fills a declared default when no value is stored', async () => {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({
|
||||
fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: true, secret: false, default: 'https://auth.openbnb.org/authorize' }],
|
||||
config: {},
|
||||
})),
|
||||
)
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 1 }))
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
|
||||
expect(await screen.findByDisplayValue('https://auth.openbnb.org/authorize', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-010: a stored value wins over the default', async () => {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({
|
||||
fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: false, secret: false, default: 'https://auth.openbnb.org/authorize' }],
|
||||
config: { oauth_authorize_url: 'https://mine.example' },
|
||||
})),
|
||||
)
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 1 }))
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
|
||||
expect(await screen.findByDisplayValue('https://mine.example', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(screen.queryByDisplayValue('https://auth.openbnb.org/authorize')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-011: blocks Save while a required field is empty and names it', async () => {
|
||||
let called = false
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({
|
||||
fields: [{ key: 'client_id', label: 'Client ID', input_type: 'text', required: true, secret: false }],
|
||||
config: {},
|
||||
})),
|
||||
http.put('*/api/admin/plugins/trek-gotify/config', () => { called = true; return HttpResponse.json({ config: {}, restarted: false }) }),
|
||||
)
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 1 }))
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
await screen.findByText('Client ID', {}, { timeout: 5000 })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
expect(await screen.findByText('"Client ID" is required', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-012: Save paints its label with the accent-text token, so a light accent stays readable', async () => {
|
||||
await openSettings()
|
||||
|
||||
// `--accent-text` flips per scheme and per light/dark; a hard white label vanishes on
|
||||
// the near-white accent the dark theme uses by default.
|
||||
const save = screen.getByRole('button', { name: /^save$/i })
|
||||
expect(save.className).toContain('text-accent-text')
|
||||
expect(save.className).not.toContain('text-white')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* TREK_PLUGINS_IGNORE_TREK_RANGE. The server turns its version gates into warnings; the
|
||||
* panel's job is to make sure the admin actually SEES the warning — before a registry
|
||||
* install, after a sideload/link that could not be asked first, and on the row for as
|
||||
* long as the plugin keeps running outside the range its author vouched for.
|
||||
*/
|
||||
describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
|
||||
function bypassPanel(plugins: Record<string, unknown>[], entry: Record<string, unknown> | null) {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, ignoreTrekRange: true, plugins })),
|
||||
http.get('*/api/admin/plugins/registry', () => HttpResponse.json(entry ? [entry] : [])),
|
||||
)
|
||||
}
|
||||
const incompatibleEntry = () => registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
|
||||
|
||||
it('FE-PLG-BYPASS-001: says so in the header', async () => {
|
||||
bypassPanel([plugin()], null)
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText('Version checks off')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => {
|
||||
let posted: unknown = null
|
||||
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
|
||||
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
||||
posted = await request.json()
|
||||
return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: { trekRange: '>=4.0.0', hostVersion: '3.3.0' } })
|
||||
}))
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
|
||||
const btn = await screen.findByRole('button', { name: /^install anyway$/i })
|
||||
expect(btn).toBeEnabled()
|
||||
fireEvent.click(btn)
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
|
||||
expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument()
|
||||
expect(within(dialog).getByText(/corrupt trek data/i)).toBeInTheDocument()
|
||||
expect(posted).toBeNull() // nothing sent until the admin accepts the risk
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /^install anyway$/i }))
|
||||
await waitFor(() => expect(posted).toEqual({ id: 'trek-gotify' }))
|
||||
})
|
||||
|
||||
it('FE-PLG-BYPASS-003: cancelling the warning installs nothing', async () => {
|
||||
let posted = false
|
||||
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
|
||||
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: /outside its supported trek versions/i })).not.toBeInTheDocument())
|
||||
expect(posted).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-PLG-BYPASS-004: a sideload that landed outside its range is warned about afterwards', async () => {
|
||||
bypassPanel([plugin()], null)
|
||||
server.use(http.post('*/api/admin/plugins/upload', () =>
|
||||
HttpResponse.json({ id: 'trek-new', version: '1.0.0', replaced: false, trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0' } })))
|
||||
const { container } = withToast()
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
await userEvent.upload(container.querySelector('input[type="file"]') as HTMLInputElement, new File(['zip'], 'plugin.zip', { type: 'application/zip' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /installed outside its supported trek versions/i })
|
||||
expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument()
|
||||
expect(within(dialog).getByText(/trek-new/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLG-BYPASS-005: a plugin running outside its range keeps a warning chip on its row', async () => {
|
||||
bypassPanel([plugin({
|
||||
dependencyStatus: 'ok', trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0',
|
||||
trekRangeBypassed: { trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' },
|
||||
})], null)
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText(/outside its trek range \(>=3\.2\.0 <4\.0\.0\)/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
|
||||
Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle,
|
||||
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette, Bot,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useInstanceSettings } from './useInstanceSettings'
|
||||
import { bypassChip, bypassOffer, useRangeBypass, type RangeWarning, type TrekRangeBypass } from './useRangeBypass'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -49,6 +52,12 @@ interface PluginRow {
|
||||
operatorEgress?: boolean
|
||||
/** How many hosts the admin has added — 0 means the plugin can't reach anything yet. */
|
||||
egressHostCount?: number
|
||||
/** How many `scope:'instance'` settings fields the plugin declares — gates the
|
||||
* "Instance settings" menu item without a per-plugin fetch. */
|
||||
instanceSettingsCount?: number
|
||||
/** How many `scope:'instance'` actions the plugin declares — a plugin with actions
|
||||
* but no settings fields still needs the menu item to run them. */
|
||||
instanceActionsCount?: number
|
||||
dependencies?: PluginDependencies
|
||||
dependencyStatus?: DependencyStatus
|
||||
dependencyIssues?: DependencyIssues
|
||||
@@ -56,6 +65,8 @@ interface PluginRow {
|
||||
trekRange?: string | null
|
||||
/** The TREK this server is running — the server does the semver, the client just shows it. */
|
||||
hostVersion?: string
|
||||
/** Non-null while the plugin runs outside its declared range on the operator's say-so. */
|
||||
trekRangeBypassed?: TrekRangeBypass | null
|
||||
/** The author's signature was verified and their key pinned at install. False means the
|
||||
* bytes matched the registry's sha256 and nothing more — one fewer guarantee. */
|
||||
signed?: boolean
|
||||
@@ -265,7 +276,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
return out
|
||||
}
|
||||
|
||||
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean }
|
||||
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean; warn?: boolean }
|
||||
|
||||
// A plugin's declared dependencies as chips — a required addon (amber when that
|
||||
// addon is disabled), a plugin dependency (amber when missing / version-mismatched),
|
||||
@@ -283,6 +294,8 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
|
||||
blocked: true,
|
||||
})
|
||||
}
|
||||
const bypassed = bypassChip(p.trekRangeBypassed, t) // TREK_PLUGINS_IGNORE_TREK_RANGE: a warning, not a blocker
|
||||
if (bypassed) out.push(bypassed)
|
||||
for (const a of p.dependencies?.requiredAddons ?? []) {
|
||||
out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) })
|
||||
}
|
||||
@@ -302,11 +315,12 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
|
||||
* has outrun this TREK but an older one still fits, offer THAT version rather than a dead
|
||||
* grey button. The plugin is perfectly usable, just not at its newest.
|
||||
*/
|
||||
function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: string; label: string; title?: string } {
|
||||
function installOffer(item: RegistryItem, t: T, ignoreTrekRange = false): { blocked: boolean; version?: string; label: string; title?: string; warn?: RangeWarning } {
|
||||
if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') }
|
||||
const title = item.trek
|
||||
? t('admin.plugins.dep.trekIncompatible', { range: item.trek, host: item.hostVersion ?? '?' })
|
||||
: t('admin.plugins.dep.trekUnknown')
|
||||
if (ignoreTrekRange) return bypassOffer(item, t, title) // TREK_PLUGINS_IGNORE_TREK_RANGE: "Install anyway"
|
||||
if (item.latestCompatible) {
|
||||
return { blocked: false, version: item.latestCompatible, label: t('admin.plugins.installCompatible', { version: item.latestCompatible }), title }
|
||||
}
|
||||
@@ -390,6 +404,8 @@ export default function AdminPluginsPanel() {
|
||||
const toast = useToast()
|
||||
const [runtimeOn, setRuntimeOn] = useState(false)
|
||||
const [devLink, setDevLink] = useState(false) // dev-link enabled server-side (TREK_PLUGINS_DEV_LINK)
|
||||
const [ignoreTrekRange, setIgnoreTrekRange] = useState(false) // TREK_PLUGINS_IGNORE_TREK_RANGE set server-side
|
||||
const bypass = useRangeBypass() // its warning dialog/sheet state — shared with the other shell
|
||||
const [linkPath, setLinkPath] = useState('')
|
||||
const [plugins, setPlugins] = useState<PluginRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -408,6 +424,8 @@ export default function AdminPluginsPanel() {
|
||||
const [detailFor, setDetailFor] = useState<RegistryItem | null>(null)
|
||||
const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null)
|
||||
const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null)
|
||||
// The admin-owned scope:'instance' settings form — shared logic with the phone shell.
|
||||
const settings = useInstanceSettings()
|
||||
const [egressDraft, setEgressDraft] = useState('')
|
||||
const [egressSaving, setEgressSaving] = useState(false)
|
||||
const [egressError, setEgressError] = useState('')
|
||||
@@ -462,9 +480,10 @@ export default function AdminPluginsPanel() {
|
||||
// (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5).
|
||||
void usePluginStore.getState().loadPlugins()
|
||||
adminApi.plugins()
|
||||
.then((d: { enabled: boolean; devLink?: boolean; plugins: PluginRow[] }) => {
|
||||
.then((d: { enabled: boolean; devLink?: boolean; ignoreTrekRange?: boolean; plugins: PluginRow[] }) => {
|
||||
setRuntimeOn(!!d.enabled)
|
||||
setDevLink(!!d.devLink)
|
||||
setIgnoreTrekRange(!!d.ignoreTrekRange)
|
||||
setPlugins(d.plugins || [])
|
||||
if ((d.plugins || []).length) {
|
||||
adminApi.pluginBrowse().then(indexRegistry).catch(() => {})
|
||||
@@ -530,6 +549,7 @@ export default function AdminPluginsPanel() {
|
||||
const res = await adminApi.pluginUpload(file)
|
||||
setView('installed')
|
||||
toast.success(t('admin.plugins.uploaded', { name: res.id }))
|
||||
bypass.notice(res.id, res.trekRangeBypassed)
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
|
||||
} finally {
|
||||
@@ -573,6 +593,11 @@ export default function AdminPluginsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const openInstanceSettings = (p: { id: string; status: string }) => {
|
||||
setMenu(null)
|
||||
settings.open(p.id, p.status === 'active')
|
||||
}
|
||||
|
||||
const openErrors = (id: string) => {
|
||||
setMenu(null)
|
||||
adminApi.pluginErrors(id)
|
||||
@@ -595,7 +620,11 @@ export default function AdminPluginsPanel() {
|
||||
const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null)
|
||||
return range ? { version: reg.latest, range } : null
|
||||
}
|
||||
const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed'))
|
||||
// `warn` is the pre-install confirm for an entry the registry already flagged as
|
||||
// incompatible; an artifact whose OWN manifest turns out to be out of range (the index
|
||||
// was only a pre-download filter) is caught by the marker on the response instead.
|
||||
const install = (id: string, version?: string, warn?: RangeWarning) => bypass.guard(warn, () =>
|
||||
act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined).then(r => { if (!warn) bypass.notice(id, r?.trekRangeBypassed) }), t('admin.plugins.installed')))
|
||||
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
|
||||
// Dev-link: register a plugin from a local built directory (dev only). Reuses the
|
||||
// same busy/toast/refresh loop as uploadPlugin; the server gates it.
|
||||
@@ -608,6 +637,7 @@ export default function AdminPluginsPanel() {
|
||||
setView('installed')
|
||||
setLinkPath('')
|
||||
toast.success(t('admin.plugins.devLinkLinked', { id: res.id }))
|
||||
bypass.notice(res.id, res.trekRangeBypassed)
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
|
||||
} finally {
|
||||
@@ -667,9 +697,10 @@ export default function AdminPluginsPanel() {
|
||||
if (busy === parent.id) return
|
||||
setBusy(parent.id)
|
||||
adminApi.pluginInstall(depId, { constraint, withDependencies: true })
|
||||
.then((r: { installed?: string[]; requiredAddons?: string[] }) => {
|
||||
.then((r: { installed?: string[]; requiredAddons?: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
|
||||
toast.success(t('admin.plugins.dep.downloaded', { id: depId }))
|
||||
if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') }))
|
||||
bypass.notice(depId, r?.trekRangeBypassed)
|
||||
return attemptActivate(parent)
|
||||
})
|
||||
// The DEPENDENCY is what's being downloaded, so a signature refusal here is about the
|
||||
@@ -687,9 +718,10 @@ export default function AdminPluginsPanel() {
|
||||
const runUpdate = (p: PluginRow, version?: string) => {
|
||||
setBusy(p.id); setMenu(null)
|
||||
return adminApi.pluginUpdate(p.id, version)
|
||||
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
|
||||
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
|
||||
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
|
||||
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
|
||||
bypass.notice(p.name, r.trekRangeBypassed)
|
||||
})
|
||||
.catch(e => {
|
||||
const { error, code } = errBody(e)
|
||||
@@ -813,11 +845,19 @@ export default function AdminPluginsPanel() {
|
||||
<h2 className="text-lg font-semibold tracking-tight text-content">{t('admin.plugins.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted max-w-xl">{t('admin.plugins.subtitle')}</p>
|
||||
</div>
|
||||
{runtimeOn && (
|
||||
<span className="inline-flex items-center gap-2 shrink-0 text-[11px] font-semibold text-success bg-success-soft px-2.5 py-1.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {t('admin.plugins.runtimeOn')}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{runtimeOn && ignoreTrekRange && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-warning bg-warning-soft px-2.5 py-1.5 rounded-full"
|
||||
title={t('admin.plugins.rangeBypass.pillHint')}>
|
||||
<AlertTriangle size={12} /> {t('admin.plugins.rangeBypass.pill')}
|
||||
</span>
|
||||
)}
|
||||
{runtimeOn && (
|
||||
<span className="inline-flex items-center gap-2 text-[11px] font-semibold text-success bg-success-soft px-2.5 py-1.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {t('admin.plugins.runtimeOn')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -927,7 +967,7 @@ export default function AdminPluginsPanel() {
|
||||
) : error ? (
|
||||
<div className="py-14 text-center text-sm text-danger">{t('admin.plugins.loadError')}</div>
|
||||
) : !runtimeOn ? null : view === 'discover' ? (
|
||||
<RegistryGrid items={shownRegistry} busy={busy} t={t} installedIds={installedIds}
|
||||
<RegistryGrid items={shownRegistry} busy={busy} t={t} installedIds={installedIds} ignoreTrekRange={ignoreTrekRange}
|
||||
onInstall={install} onOpenDetail={setDetailFor} filtered={anyFilter} />
|
||||
) : plugins.length === 0 ? (
|
||||
<EmptyState t={t} onDiscover={openDiscover} />
|
||||
@@ -962,6 +1002,7 @@ export default function AdminPluginsPanel() {
|
||||
code: p.updateBlock!.code, detail: p.updateBlock!.detail,
|
||||
})}
|
||||
onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)}
|
||||
onSettings={() => openInstanceSettings(p)}
|
||||
onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} />
|
||||
))}
|
||||
</div>
|
||||
@@ -972,10 +1013,12 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
{/* Registry detail dialog */}
|
||||
{detailFor && (
|
||||
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy}
|
||||
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy} ignoreTrekRange={ignoreTrekRange}
|
||||
installed={installedIds.has(detailFor.id)} onInstall={install} onClose={() => setDetailFor(null)} />
|
||||
)}
|
||||
|
||||
{bypass.copy && <RangeBypassDialog copy={bypass.copy} t={t} onConfirm={bypass.confirm} onClose={bypass.dismiss} />}
|
||||
|
||||
{/* Error-log modal */}
|
||||
{errorsFor && (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
|
||||
@@ -1001,12 +1044,12 @@ export default function AdminPluginsPanel() {
|
||||
{/* Operator-supplied egress hosts */}
|
||||
{egressFor && (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEgressFor(null)}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg max-h-[88vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="shrink-0 px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-content flex items-center gap-2"><Globe size={15} /> {egressFor.id} — {t('admin.plugins.allowedHosts')}</span>
|
||||
<button type="button" onClick={() => setEgressFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="p-5 space-y-3 min-h-0 flex-1 overflow-y-auto">
|
||||
{!egressFor.supported ? (
|
||||
<p className="text-sm text-content-faint">{t('admin.plugins.allowedHosts.unsupported')}</p>
|
||||
) : (
|
||||
@@ -1048,6 +1091,110 @@ export default function AdminPluginsPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instance-wide settings (the admin-owned scope:'instance' fields) */}
|
||||
{settings.form && (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={settings.close}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg max-h-[80vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-content flex items-center gap-2"><SlidersHorizontal size={15} /> {settings.form.id} — {t('admin.plugins.instanceSettings')}</span>
|
||||
<button type="button" aria-label={t('common.close')} onClick={settings.close} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4 overflow-y-auto">
|
||||
{settings.form.fields.map(f => (
|
||||
<label key={f.key} className="block">
|
||||
<span className="block text-sm font-medium text-content-secondary mb-1">
|
||||
{f.label || f.key}{f.required && <span className="text-danger"> *</span>}
|
||||
</span>
|
||||
{f.input_type === 'checkbox' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.form.values[f.key] === true}
|
||||
onChange={e => settings.setValue(f.key, e.target.checked)}
|
||||
className="h-4 w-4 rounded border-edge"
|
||||
/>
|
||||
) : f.input_type === 'select' && f.options ? (
|
||||
<select
|
||||
value={String(settings.form.values[f.key] ?? '')}
|
||||
onChange={e => settings.setValue(f.key, e.target.value)}
|
||||
className="w-full rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={f.secret ? 'password' : (f.input_type === 'number' ? 'number' : 'text')}
|
||||
value={String(settings.form.values[f.key] ?? '')}
|
||||
placeholder={f.placeholder || ''}
|
||||
autoComplete={f.secret ? 'new-password' : 'off'}
|
||||
onChange={e => settings.setValue(f.key, e.target.value)}
|
||||
className="w-full rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content"
|
||||
/>
|
||||
)}
|
||||
{f.hint && <span className="block text-xs text-content-muted mt-1">{f.hint}</span>}
|
||||
</label>
|
||||
))}
|
||||
{(() => {
|
||||
const form = settings.form
|
||||
if (!form || form.actions.length === 0) return null
|
||||
return (
|
||||
<div className="border-t border-edge-secondary pt-4">
|
||||
<span className="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">
|
||||
{t('admin.plugins.actions')}
|
||||
</span>
|
||||
{!form.active && (
|
||||
<p className="text-xs text-content-muted mb-2">{t('admin.plugins.actions.inactive')}</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{form.actions.map(a => {
|
||||
const res = settings.actionResult[a.key]
|
||||
return (
|
||||
<div key={a.key} className="flex flex-wrap items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={() => settings.runAction(a)}
|
||||
disabled={!form.active || settings.runningAction !== null || settings.saving}
|
||||
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-semibold disabled:opacity-60 ${
|
||||
a.danger ? 'border-danger text-danger' : 'border-edge text-content'
|
||||
}`}
|
||||
>
|
||||
{settings.runningAction === a.key && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{a.label}
|
||||
</button>
|
||||
{a.hint && <span className="text-xs text-content-muted">{a.hint}</span>}
|
||||
{res && (
|
||||
<span aria-live="polite" className={`text-xs font-medium ${res.ok ? 'text-success' : 'text-danger'}`}>
|
||||
{res.message || (res.ok ? t('common.success') : t('common.error'))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{settings.error && <p className="text-xs text-danger">{settings.error}</p>}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary flex justify-end">
|
||||
<button type="button"
|
||||
disabled={settings.saving || settings.runningAction !== null}
|
||||
onClick={() => void settings.save()}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-text disabled:opacity-60"
|
||||
>{t('common.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={settings.pendingAction !== null}
|
||||
onClose={settings.cancelPendingAction}
|
||||
onConfirm={settings.confirmPendingAction}
|
||||
title={settings.pendingAction?.label ?? ''}
|
||||
message={t('admin.plugins.actions.confirm')}
|
||||
confirmLabel={t('common.confirm')}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!confirmUninstall}
|
||||
onClose={() => setConfirmUninstall(null)}
|
||||
@@ -1185,11 +1332,11 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onUninstall }: {
|
||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: {
|
||||
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
|
||||
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
|
||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
|
||||
onErrors: () => void; onEgress: () => void; onUninstall: () => void
|
||||
onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void
|
||||
}) {
|
||||
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
|
||||
const deps = deriveDeps(p, t)
|
||||
@@ -1304,8 +1451,8 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
|
||||
<div className="hidden sm:flex items-center gap-1.5 flex-wrap mt-1.5">
|
||||
{deps.map((d, i) => (
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border ${
|
||||
d.blocked ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
|
||||
<d.icon size={12} className={d.blocked ? 'text-warning' : 'text-content-muted'} />{d.label}
|
||||
d.blocked || d.warn ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
|
||||
<d.icon size={12} className={d.blocked || d.warn ? 'text-warning' : 'text-content-muted'} />{d.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -1334,8 +1481,18 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
|
||||
{p.enabled === 1 && (
|
||||
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
|
||||
)}
|
||||
{/* A plugin gets the item if it declares scope:'instance' fields OR actions —
|
||||
an action-only plugin still needs the dialog to run its buttons. */}
|
||||
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
|
||||
<MenuItem icon={<SlidersHorizontal size={14} />} label={t('admin.plugins.instanceSettings')} onClick={onSettings} />
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
|
||||
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
|
||||
{/* Only a plugin that DECLARED operatorEgress gets the item — an admin must
|
||||
never be invited to widen egress for a plugin that didn't ask for it
|
||||
(same rule the row's egress chip follows). */}
|
||||
{p.operatorEgress && (
|
||||
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
|
||||
)}
|
||||
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
|
||||
{isRegistrySourced(p.source_repo) && (
|
||||
<MenuItem icon={<History size={14} />} label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} />
|
||||
@@ -1405,14 +1562,15 @@ function Screenshot({ url, className, iconSize = 28 }: { url: string | null; cla
|
||||
)
|
||||
}
|
||||
|
||||
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered }: {
|
||||
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered, ignoreTrekRange }: {
|
||||
items: RegistryItem[] | null
|
||||
onInstall: (id: string, version?: string) => void
|
||||
onInstall: (id: string, version?: string, warn?: RangeWarning) => void
|
||||
onOpenDetail: (item: RegistryItem) => void
|
||||
busy: string | null
|
||||
t: T
|
||||
installedIds: Set<string>
|
||||
filtered: boolean
|
||||
ignoreTrekRange: boolean
|
||||
}) {
|
||||
if (!items) return <div className="py-14 text-center text-sm text-content-faint">{t('common.loading')}</div>
|
||||
if (items.length === 0) return (
|
||||
@@ -1425,9 +1583,13 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5 sm:gap-4 px-4 sm:px-6 pb-5 pt-1">
|
||||
{items.map(item => {
|
||||
const installed = installedIds.has(item.id)
|
||||
const offer = installOffer(item, t)
|
||||
const offer = installOffer(item, t, ignoreTrekRange)
|
||||
return (
|
||||
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
|
||||
// No press-scale on the card: shrinking it mid-click slides the Install
|
||||
// button out from under the pointer, so the click retargets onto the card
|
||||
// and opens the detail modal instead of installing (#2158).
|
||||
data-no-press
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }}
|
||||
className="group border border-edge rounded-2xl bg-surface-card overflow-hidden flex flex-col cursor-pointer hover:-translate-y-0.5 hover:shadow-elevated hover:border-edge-faint transition-all duration-150">
|
||||
<div className="relative">
|
||||
@@ -1456,7 +1618,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<Download size={11} /> {formatCompactCount(item.downloadCount)}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version, offer.warn) }}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className="ml-auto text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors">
|
||||
@@ -1486,9 +1648,54 @@ function PermLabel({ perm, t }: { perm: string; t: T }) {
|
||||
: <code className="font-mono text-[11px] bg-surface-tertiary px-1.5 py-0.5 rounded">{perm}</code>
|
||||
}
|
||||
|
||||
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale }: {
|
||||
item: RegistryItem; installed: boolean; busy: string | null
|
||||
onInstall: (id: string, version?: string) => void; onClose: () => void; t: T; locale: string
|
||||
/**
|
||||
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
|
||||
* component rather than ConfirmDialog because the notice mode has no cancel button.
|
||||
*/
|
||||
function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: {
|
||||
copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T
|
||||
}) {
|
||||
return createPortal(
|
||||
<div role="presentation" className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="dialog" aria-modal="true" aria-label={title}
|
||||
className="bg-surface-card border border-edge rounded-2xl w-full max-w-md p-6 shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="shrink-0 w-10 h-10 rounded-full bg-warning-soft grid place-items-center">
|
||||
<AlertTriangle size={20} className="text-warning" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-content">{title}</h3>
|
||||
<p className="mt-1.5 text-sm text-content-secondary leading-relaxed">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
{confirm ? (
|
||||
<>
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg text-content-secondary border border-edge-secondary transition-colors">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={onConfirm}
|
||||
className="px-4 py-2 text-sm font-semibold rounded-lg bg-warning text-white hover:opacity-90 transition-opacity">
|
||||
{t('admin.plugins.installAnyway')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-semibold rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale, ignoreTrekRange }: {
|
||||
item: RegistryItem; installed: boolean; busy: string | null; ignoreTrekRange: boolean
|
||||
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
|
||||
}) {
|
||||
const [detail, setDetail] = useState<RegistryDetail | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
@@ -1511,7 +1718,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
|
||||
// The detail fetch carries the same compat verdict as the browse list; prefer it once
|
||||
// it lands (it is keyed to the same entry) and fall back to the grid item until then.
|
||||
const offer = installOffer(detail ?? item, t)
|
||||
const offer = installOffer(detail ?? item, t, ignoreTrekRange)
|
||||
|
||||
return (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
@@ -1533,7 +1740,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
</div>
|
||||
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => onInstall(item.id, offer.version)}
|
||||
<button type="button" onClick={() => onInstall(item.id, offer.version, offer.warn)}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className="self-end text-[13px] font-semibold px-3 sm:px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors shrink-0">
|
||||
@@ -1793,15 +2000,15 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
|
||||
|
||||
return (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="shrink-0 px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldAlert size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.sig.title', { name: data.subject.name })}</h3>
|
||||
<p className="text-xs text-content-muted mt-1">{t(bodyKey as never)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="p-5 space-y-4 min-h-0 flex-1 overflow-y-auto">
|
||||
{/* Fingerprints, not full keys: these exist to be COMPARED by a human — read the
|
||||
new one back to the author over the phone. The full key travels in the request. */}
|
||||
{canRetrust && (
|
||||
@@ -1815,7 +2022,7 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
|
||||
<p className="text-xs text-content-faint font-mono break-all bg-surface-tertiary rounded-lg px-3 py-2">{data.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
|
||||
<div className="shrink-0 px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
|
||||
<button type="button" onClick={onClose}
|
||||
className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
|
||||
{offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')}
|
||||
@@ -1857,15 +2064,15 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
|
||||
}) {
|
||||
return (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onLater}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="shrink-0 px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldCheck size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.updateConsentTitle')}</h3>
|
||||
<p className="text-xs text-content-muted mt-1">{t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="p-5 space-y-4 min-h-0 flex-1 overflow-y-auto">
|
||||
{/* The admin is about to widen what this code may do — so say, right here, that
|
||||
nothing ties this code to its author. One line, no checkbox, no extra click:
|
||||
this informs, it does not block. */}
|
||||
@@ -1894,7 +2101,7 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
|
||||
<div className="shrink-0 px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
|
||||
<button type="button" onClick={onLater} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('admin.plugins.updateLater')}</button>
|
||||
<button type="button" onClick={onApprove} className="text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">{t('admin.plugins.updateApprove')}</button>
|
||||
</div>
|
||||
@@ -1917,15 +2124,15 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
|
||||
]
|
||||
return (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="shrink-0 px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><Puzzle size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.dep.resolveTitle')}</h3>
|
||||
<p className="text-xs text-content-muted mt-1">{t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-2.5">
|
||||
<div className="p-5 space-y-2.5 min-h-0 flex-1 overflow-y-auto">
|
||||
{rows.map(r => (
|
||||
<div key={r.id} className="flex items-center gap-3 p-3 rounded-xl border border-edge-secondary bg-surface-tertiary">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -1946,7 +2153,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
|
||||
<p className="text-[11.5px] text-content-faint pt-1">{t('admin.plugins.dep.resolveHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end">
|
||||
<div className="shrink-0 px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end">
|
||||
<button type="button" onClick={onClose} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,7 @@ type Defaults = {
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
routing_base_url?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -119,6 +120,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [routingBase, setRoutingBase] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -128,6 +130,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setCartoKey(data.carto_api_key || '')
|
||||
setRoutingBase(data.routing_base_url || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
@@ -150,6 +153,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'carto_api_key') setCartoKey('')
|
||||
if (key === 'routing_base_url') setRoutingBase('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -365,6 +369,32 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Instance configuration, not a taste, which is why it sits here rather
|
||||
than in a user's own Map settings. The origin has to appear in the CSP
|
||||
`connect-src` the server emits at boot, and that list is built from
|
||||
this default — a value on a personal settings row is read by the route
|
||||
calculator and then refused by the browser, so the whole app stops
|
||||
routing with no error it could report. The write route refuses one
|
||||
from a non-admin for the same reason. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.routingBase')}
|
||||
<ResetButton field="routing_base_url" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={routingBase}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRoutingBase(e.target.value)}
|
||||
onBlur={() => save({ routing_base_url: routingBase.trim() })}
|
||||
placeholder="https://osrm.example.org"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// FE-COMP-PLUGINS-ACT-001 to -006 — the shared instance-settings hook, action side.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useInstanceSettings } from './useInstanceSettings'
|
||||
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => ({ success: vi.fn(), error: vi.fn() }) }))
|
||||
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }))
|
||||
|
||||
const FIELDS = [{ key: 'apiUrl', label: 'API URL', input_type: 'text', required: false, secret: false }]
|
||||
const PURGE = { key: 'purge', label: 'Purge', danger: true, scope: 'instance' as const }
|
||||
const PING = { key: 'ping', label: 'Ping', danger: false, scope: 'instance' as const }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(adminApi, 'pluginConfig').mockResolvedValue({ fields: FIELDS, config: { apiUrl: 'https://a.example' }, actions: [PING, PURGE] })
|
||||
vi.spyOn(adminApi, 'pluginSaveConfig').mockResolvedValue({ config: { apiUrl: 'https://b.example' }, restarted: false })
|
||||
vi.spyOn(adminApi, 'runPluginAction').mockResolvedValue({ ok: true, message: 'pong' })
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
async function opened(active = true) {
|
||||
const hook = renderHook(() => useInstanceSettings())
|
||||
act(() => hook.result.current.open('p', active))
|
||||
await waitFor(() => expect(hook.result.current.form).not.toBeNull())
|
||||
return hook
|
||||
}
|
||||
|
||||
/** A promise this test controls the settling of, to pin down state mid-flight. */
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void
|
||||
const promise = new Promise<T>(r => { resolve = r })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useInstanceSettings — actions', () => {
|
||||
it('FE-COMP-PLUGINS-ACT-001: open carries the instance actions, the active flag, and a clean form', async () => {
|
||||
const { result } = await opened()
|
||||
expect(result.current.form?.actions).toEqual([PING, PURGE])
|
||||
expect(result.current.form?.active).toBe(true)
|
||||
expect(result.current.form?.dirty).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-002: a clean form runs the action straight away and keeps the dialog open', async () => {
|
||||
const { result } = await opened()
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' }))
|
||||
expect(adminApi.pluginSaveConfig).not.toHaveBeenCalled()
|
||||
expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping')
|
||||
expect(result.current.form).not.toBeNull()
|
||||
expect(result.current.runningAction).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-003: a dirty form saves first, then runs, and is clean afterwards', async () => {
|
||||
const { result } = await opened()
|
||||
act(() => result.current.setValue('apiUrl', 'https://b.example'))
|
||||
expect(result.current.form?.dirty).toBe(true)
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(result.current.actionResult.ping).toBeDefined())
|
||||
expect(adminApi.pluginSaveConfig).toHaveBeenCalledWith('p', { apiUrl: 'https://b.example' })
|
||||
expect(vi.mocked(adminApi.pluginSaveConfig).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(adminApi.runPluginAction).mock.invocationCallOrder[0])
|
||||
expect(result.current.form?.dirty).toBe(false)
|
||||
expect(result.current.form).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-004: a save failure surfaces in error and the action never runs', async () => {
|
||||
vi.mocked(adminApi.pluginSaveConfig).mockRejectedValue({ response: { data: { error: 'nope' } } })
|
||||
const { result } = await opened()
|
||||
act(() => result.current.setValue('apiUrl', 'x'))
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(result.current.error).toBe('nope'))
|
||||
expect(adminApi.runPluginAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-005: a danger action waits for confirmation; cancel drops it, confirm runs it', async () => {
|
||||
const { result } = await opened()
|
||||
act(() => result.current.runAction(PURGE))
|
||||
expect(result.current.pendingAction).toEqual(PURGE)
|
||||
expect(adminApi.runPluginAction).not.toHaveBeenCalled()
|
||||
act(() => result.current.cancelPendingAction())
|
||||
expect(result.current.pendingAction).toBeNull()
|
||||
act(() => result.current.runAction(PURGE))
|
||||
act(() => result.current.confirmPendingAction())
|
||||
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'purge'))
|
||||
expect(result.current.pendingAction).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-006: an inactive plugin never posts, and a failed result is stored as such', async () => {
|
||||
const { result } = await opened(false)
|
||||
act(() => result.current.runAction(PING))
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(adminApi.runPluginAction).not.toHaveBeenCalled()
|
||||
|
||||
vi.mocked(adminApi.runPluginAction).mockResolvedValue({ ok: false, message: 'credentials rejected' })
|
||||
const active = await opened(true)
|
||||
act(() => active.result.current.runAction(PING))
|
||||
await waitFor(() => expect(active.result.current.actionResult.ping).toEqual({ ok: false, message: 'credentials rejected' }))
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-007: an edit made while the pre-action save is in flight is not clobbered when dirty clears', async () => {
|
||||
const savePromise = deferred<{ config: Record<string, unknown>; restarted: boolean }>()
|
||||
vi.mocked(adminApi.pluginSaveConfig).mockReturnValue(savePromise.promise)
|
||||
const { result } = await opened()
|
||||
act(() => result.current.setValue('apiUrl', 'https://a-edit.example'))
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(adminApi.pluginSaveConfig).toHaveBeenCalledWith('p', { apiUrl: 'https://a-edit.example' }))
|
||||
|
||||
// A concurrent edit lands while the save is still pending.
|
||||
act(() => result.current.setValue('apiUrl', 'https://b-edit.example'))
|
||||
|
||||
await act(async () => {
|
||||
savePromise.resolve({ config: { apiUrl: 'https://a-edit.example' }, restarted: false })
|
||||
await savePromise.promise
|
||||
})
|
||||
|
||||
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping'))
|
||||
expect(result.current.form?.dirty).toBe(true)
|
||||
expect(result.current.form?.values.apiUrl).toBe('https://b-edit.example')
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-008: an in-flight action from a closed dialog does not leak into the next one', async () => {
|
||||
const runPromise = deferred<{ ok: boolean; message?: string }>()
|
||||
vi.mocked(adminApi.runPluginAction).mockReturnValueOnce(runPromise.promise)
|
||||
const { result } = await opened()
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping'))
|
||||
|
||||
act(() => result.current.close())
|
||||
act(() => result.current.open('q', true))
|
||||
await waitFor(() => expect(result.current.form?.id).toBe('q'))
|
||||
expect(result.current.runningAction).toBeNull()
|
||||
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('q', 'ping'))
|
||||
await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' }))
|
||||
|
||||
// The stale request from the closed dialog settles after the fact — it must not overwrite the new dialog's result.
|
||||
await act(async () => {
|
||||
runPromise.resolve({ ok: true, message: 'stale pong' })
|
||||
await runPromise.promise
|
||||
})
|
||||
expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' })
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-009: a 404 "Plugin is not active" from the server (DB active, no live child) shows the inactive message and flips active off', async () => {
|
||||
vi.mocked(adminApi.runPluginAction).mockRejectedValue({ response: { status: 404, data: { error: 'Plugin is not active' } } })
|
||||
const { result } = await opened(true)
|
||||
expect(result.current.form?.active).toBe(true)
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: false, message: 'admin.plugins.actions.inactive' }))
|
||||
expect(result.current.form?.active).toBe(false)
|
||||
expect(result.current.runningAction).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { adminApi, type PluginAction, type PluginUserSettingField } from '../../api/client'
|
||||
import type { PluginActionResult } from '@trek/shared'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { seedSettingsValues, findMissingRequired, settingsPatch } from '../Plugins/settingsForm'
|
||||
|
||||
export interface InstanceSettingsForm {
|
||||
id: string
|
||||
fields: PluginUserSettingField[]
|
||||
values: Record<string, string | boolean>
|
||||
/** The plugin's `scope:'instance'` action buttons. */
|
||||
actions: PluginAction[]
|
||||
/** Whether the plugin is active: an action needs a running child to execute. */
|
||||
active: boolean
|
||||
/** An edit since open/last save. An action saves a dirty form before it runs. */
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin-owned `scope:'instance'` settings form — the ONE logic path behind
|
||||
* both admin shells (the desktop modal and the phone sheet render their own
|
||||
* markup over this state). Open fetches the declared fields plus the stored
|
||||
* (masked) values and the instance-scope actions; save skips an untouched secret
|
||||
* mask so it never overwrites the stored ciphertext, and reports a restart when
|
||||
* the server re-spawned a running plugin (the child reads its config once, at init).
|
||||
*
|
||||
* Actions run AS the clicking admin. A dirty form is saved first (so the action
|
||||
* sees the config the admin is looking at), the dialog stays open, and the result
|
||||
* shows beside the button. A `danger` action parks in `pendingAction` until the
|
||||
* shell's confirm UI calls `confirmPendingAction`.
|
||||
*/
|
||||
export function useInstanceSettings() {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [form, setForm] = useState<InstanceSettingsForm | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [actionResult, setActionResult] = useState<Record<string, PluginActionResult>>({})
|
||||
const [runningAction, setRunningAction] = useState<string | null>(null)
|
||||
const [pendingAction, setPendingAction] = useState<PluginAction | null>(null)
|
||||
// Bumped on every open()/close() so an in-flight action from a closed/replaced
|
||||
// dialog can tell it's stale and bail without touching the new dialog's state.
|
||||
const openSeq = useRef(0)
|
||||
|
||||
const open = (id: string, active: boolean) => {
|
||||
openSeq.current++
|
||||
setError('')
|
||||
setActionResult({})
|
||||
setPendingAction(null)
|
||||
setRunningAction(null)
|
||||
adminApi.pluginConfig(id)
|
||||
.then(d => {
|
||||
setForm({ id, fields: d.fields, values: seedSettingsValues(d.fields, d.config), actions: d.actions ?? [], active, dirty: false })
|
||||
})
|
||||
.catch(() => toast.error(t('common.error')))
|
||||
}
|
||||
|
||||
const setValue = (key: string, value: string | boolean) =>
|
||||
setForm(s => s && ({ ...s, values: { ...s.values, [key]: value }, dirty: true }))
|
||||
|
||||
const close = () => { openSeq.current++; setForm(null); setPendingAction(null); setRunningAction(null) }
|
||||
|
||||
/** Persist the form. Returns false when the save was refused (error already shown). */
|
||||
const persist = async (f: InstanceSettingsForm): Promise<boolean> => {
|
||||
const missing = findMissingRequired(f.fields, f.values)
|
||||
if (missing) {
|
||||
setError(t('admin.plugins.requiredMissing', { field: missing.label || missing.key }))
|
||||
return false
|
||||
}
|
||||
setSaving(true); setError('')
|
||||
try {
|
||||
const d = await adminApi.pluginSaveConfig(f.id, settingsPatch(f.fields, f.values))
|
||||
toast.success(t(d.restarted ? 'admin.plugins.settingsSavedRestarted' : 'admin.plugins.settingsSaved'))
|
||||
return true
|
||||
} catch (e) {
|
||||
const err = e as { response?: { data?: { error?: string } } }
|
||||
setError(err.response?.data?.error || t('common.error'))
|
||||
return false
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return
|
||||
if (await persist(form)) setForm(null)
|
||||
}
|
||||
|
||||
const perform = async (a: PluginAction) => {
|
||||
if (!form || !form.active || runningAction) return
|
||||
const seq = openSeq.current
|
||||
// Set BEFORE the dirty-save window, not just around the invoke call, so a second
|
||||
// click (or the shells' Save button, see M7) is blocked for the whole run — not just
|
||||
// once the pre-run save has already gone out.
|
||||
setRunningAction(a.key)
|
||||
try {
|
||||
if (form.dirty) {
|
||||
if (!(await persist(form))) return
|
||||
if (openSeq.current !== seq) return
|
||||
// Only clear dirty if the values are still the snapshot we just persisted —
|
||||
// a reference check, since setValue always creates a new values object. A
|
||||
// concurrent edit during the save produced a new object, so dirty stays true
|
||||
// and the edit isn't silently dropped from the next save/action.
|
||||
setForm(s => s && s.values === form.values ? { ...s, dirty: false } : s)
|
||||
}
|
||||
if (openSeq.current !== seq) return
|
||||
const res = await adminApi.runPluginAction(form.id, a.key)
|
||||
if (openSeq.current !== seq) return
|
||||
setActionResult(r => ({ ...r, [a.key]: res }))
|
||||
} catch (e) {
|
||||
if (openSeq.current !== seq) return
|
||||
const err = e as { response?: { status?: number; data?: { error?: string } } }
|
||||
if (err.response?.status === 404 && err.response?.data?.error === 'Plugin is not active') {
|
||||
// The DB `status` column said active when the dialog opened, but the server's
|
||||
// live-process check (the supervisor's actual child map) disagrees — a stale
|
||||
// row with no running child. Believe the server: show the real reason instead
|
||||
// of a bare "Error", and disable the buttons instead of leaving them clickable.
|
||||
setActionResult(r => ({ ...r, [a.key]: { ok: false, message: t('admin.plugins.actions.inactive') } }))
|
||||
setForm(s => s && { ...s, active: false })
|
||||
} else {
|
||||
setActionResult(r => ({ ...r, [a.key]: { ok: false, message: t('common.error') } }))
|
||||
}
|
||||
} finally {
|
||||
if (openSeq.current === seq) setRunningAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
const runAction = (a: PluginAction) => {
|
||||
if (!form || !form.active) return
|
||||
if (a.danger) { setPendingAction(a); return }
|
||||
void perform(a)
|
||||
}
|
||||
|
||||
const confirmPendingAction = () => {
|
||||
const a = pendingAction
|
||||
setPendingAction(null)
|
||||
if (a) void perform(a)
|
||||
}
|
||||
|
||||
const cancelPendingAction = () => setPendingAction(null)
|
||||
|
||||
return {
|
||||
form, saving, error, open, setValue, close, save,
|
||||
actionResult, runningAction, pendingAction, runAction, confirmPendingAction, cancelPendingAction,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { bypassChip, bypassOffer, rangeWarningCopy, useRangeBypass } from './useRangeBypass'
|
||||
|
||||
const t = (k: string, p?: Record<string, unknown>) => (p ? `${k}:${JSON.stringify(p)}` : k)
|
||||
|
||||
/**
|
||||
* The ONE logic path behind the TREK_PLUGINS_IGNORE_TREK_RANGE warning in both admin
|
||||
* shells — the desktop panel and the phone panel render their own markup over this.
|
||||
*/
|
||||
describe('rangeBypass helpers', () => {
|
||||
it('bypassChip: a warning chip (not a blocker) only for a bypassed row', () => {
|
||||
expect(bypassChip(null, t)).toBeNull()
|
||||
expect(bypassChip(undefined, t)).toBeNull()
|
||||
expect(bypassChip({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' }, t)).toMatchObject({
|
||||
blocked: false, warn: true, label: 'admin.plugins.dep.trekBypassed:{"range":">=3.2.0 <4.0.0","host":"4.0.0"}',
|
||||
})
|
||||
expect(bypassChip({ trekRange: null, hostVersion: '4.0.0' }, t)).toMatchObject({ label: 'admin.plugins.dep.trekBypassedUnknown' })
|
||||
})
|
||||
|
||||
it('bypassOffer: an enabled "Install anyway" carrying what the dialog must say', () => {
|
||||
const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why')
|
||||
expect(offer).toEqual({
|
||||
blocked: false, label: 'admin.plugins.installAnyway', title: 'why',
|
||||
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null },
|
||||
})
|
||||
expect(bypassOffer({ name: 'X' }, t, 'why').warn).toMatchObject({ trekRange: null, hostVersion: '?' })
|
||||
})
|
||||
|
||||
it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => {
|
||||
const ranged = { name: 'G', trekRange: '>=4.0.0', hostVersion: '3.3.0' }
|
||||
expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({
|
||||
confirm: true, title: 'admin.plugins.rangeBypass.title',
|
||||
body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}',
|
||||
})
|
||||
expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({
|
||||
confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle',
|
||||
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","host":"3.3.0"}',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRangeBypass', () => {
|
||||
it('guard runs straight through without a warning, and parks the run behind one otherwise', () => {
|
||||
const { result } = renderHook(() => useRangeBypass())
|
||||
let ran = 0
|
||||
act(() => result.current.guard(undefined, () => { ran++ }))
|
||||
expect(ran).toBe(1)
|
||||
expect(result.current.warning).toBeNull()
|
||||
|
||||
act(() => result.current.guard({ name: 'G', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null }, () => { ran++ }))
|
||||
expect(ran).toBe(1) // nothing sent until the admin accepts
|
||||
expect(result.current.copy?.confirm).toBe(true)
|
||||
|
||||
act(() => result.current.confirm())
|
||||
expect(ran).toBe(2)
|
||||
expect(result.current.warning).toBeNull()
|
||||
})
|
||||
|
||||
it('dismiss drops a parked run; notice opens a plain notice only for a real marker', () => {
|
||||
const { result } = renderHook(() => useRangeBypass())
|
||||
let ran = 0
|
||||
act(() => result.current.guard({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, () => { ran++ }))
|
||||
act(() => result.current.dismiss())
|
||||
expect(ran).toBe(0)
|
||||
expect(result.current.warning).toBeNull()
|
||||
|
||||
act(() => result.current.notice('trek-new', null))
|
||||
expect(result.current.warning).toBeNull()
|
||||
act(() => result.current.notice('trek-new', { trekRange: null, hostVersion: '3.3.0' }))
|
||||
expect(result.current.warning).toMatchObject({ name: 'trek-new', onConfirm: null })
|
||||
expect(result.current.copy?.confirm).toBe(false)
|
||||
act(() => result.current.confirm()) // a notice's confirm is just a close
|
||||
expect(result.current.warning).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
type T = (k: string, p?: Record<string, unknown>) => string
|
||||
|
||||
/** The server's "this only went through because TREK_PLUGINS_IGNORE_TREK_RANGE is set" marker. */
|
||||
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string }
|
||||
/** What the range-bypass warning needs to say, and what to do if the admin accepts (null = a notice). */
|
||||
export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: (() => void) | null }
|
||||
|
||||
/**
|
||||
* The chip a row carries while it runs outside its declared range on the operator's
|
||||
* say-so. Not a blocker — the toggle works — but a warning that must outlive the install
|
||||
* dialog for as long as the plugin runs here. Null when nothing was bypassed.
|
||||
*/
|
||||
export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) {
|
||||
if (!b) return null
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
label: b.trekRange
|
||||
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
|
||||
: t('admin.plugins.dep.trekBypassedUnknown'),
|
||||
blocked: false,
|
||||
warn: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Install button for a registry entry the server flagged incompatible, once the
|
||||
* operator set TREK_PLUGINS_IGNORE_TREK_RANGE: the server will take the newest version
|
||||
* regardless, so the button says exactly that — and carries what the warning has to say
|
||||
* first. `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
|
||||
*/
|
||||
export function bypassOffer(item: { name: string; trek?: string | null; hostVersion?: string }, t: T, title: string) {
|
||||
return {
|
||||
blocked: false,
|
||||
label: t('admin.plugins.installAnyway'),
|
||||
title,
|
||||
warn: { name: item.name, trekRange: item.trek ?? null, hostVersion: item.hostVersion ?? '?', onConfirm: null } as RangeWarning,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Title + body of the warning, for either mode: a CONFIRM step before a registry install
|
||||
* the server would otherwise refuse (`onConfirm` set), or a plain NOTICE after a path that
|
||||
* could not ask first — sideload, dev-link, update, dependency download. The copy is the
|
||||
* whole point: the admin is accepting that the author never vouched for this TREK and
|
||||
* that, in rare cases, that can cost them data.
|
||||
*/
|
||||
export function rangeWarningCopy(w: RangeWarning, t: T): { title: string; body: string; confirm: boolean } {
|
||||
const confirm = w.onConfirm !== null
|
||||
return {
|
||||
confirm,
|
||||
title: confirm ? t('admin.plugins.rangeBypass.title') : t('admin.plugins.rangeBypass.noticeTitle'),
|
||||
body: w.trekRange
|
||||
? t('admin.plugins.rangeBypass.body', { name: w.name, range: w.trekRange, host: w.hostVersion })
|
||||
: t('admin.plugins.rangeBypass.bodyUnknown', { name: w.name, host: w.hostVersion }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning state behind BOTH admin shells (the desktop
|
||||
* panel and the phone panel render their own dialog/sheet over it). `guard` parks a
|
||||
* registry install behind the confirm step when the entry was already flagged
|
||||
* incompatible; `notice` shows the after-the-fact warning for a response that carries the
|
||||
* server's marker. Whether the switch is ON stays a plain `useState` in each shell: its
|
||||
* setter is read inside the mount-time refresh, and only a useState setter is something
|
||||
* the exhaustive-deps rule knows to be stable.
|
||||
*/
|
||||
export function useRangeBypass() {
|
||||
const { t } = useTranslation()
|
||||
const [warning, setWarning] = useState<RangeWarning | null>(null)
|
||||
|
||||
const notice = (name: string, b: TrekRangeBypass | null | undefined) => {
|
||||
if (b) setWarning({ name, trekRange: b.trekRange, hostVersion: b.hostVersion, onConfirm: null })
|
||||
}
|
||||
const guard = (warn: RangeWarning | undefined, run: () => void) => {
|
||||
if (warn) setWarning({ ...warn, onConfirm: run })
|
||||
else run()
|
||||
}
|
||||
const confirm = () => { const w = warning; setWarning(null); w?.onConfirm?.() }
|
||||
const dismiss = () => setWarning(null)
|
||||
|
||||
return {
|
||||
warning,
|
||||
copy: warning ? rangeWarningCopy(warning, t) : null,
|
||||
notice, guard, confirm, dismiss,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
|
||||
describe('splitCents', () => {
|
||||
it('splits evenly when it divides cleanly', () => {
|
||||
@@ -16,8 +16,12 @@ describe('splitCents', () => {
|
||||
expect(splitCents(50, 0)).toEqual([])
|
||||
})
|
||||
|
||||
it('floors a negative amount at zero rather than inventing debt', () => {
|
||||
expect(splitCents(-10, 2)).toEqual([0, 0])
|
||||
it('splits a negative amount exactly instead of clamping it to zero (#2176)', () => {
|
||||
// A refund spread across payers has to sum back too — the old Math.max(0, …)
|
||||
// clamp silently swallowed the sign and broke rebalancePayers for refunds.
|
||||
expect(splitCents(-10, 2)).toEqual([-5, -5])
|
||||
const parts = splitCents(-100.01, 3)
|
||||
expect(parts.reduce((a, b) => a + Math.round(b * 100), 0)).toBe(-10001)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,6 +79,57 @@ describe('rebalancePayers', () => {
|
||||
const next = rebalancePayers({ 1: '33.33' }, new Set([1]), new Set([1, 2, 3]), 100)
|
||||
expect(payersBalanced(next, new Set([1, 2, 3]), 100)).toBe(true)
|
||||
})
|
||||
|
||||
// ── Refunds: negative totals rebalance like any other (#2176) ──────────────
|
||||
|
||||
it('spreads a negative total across payers so the parts stay typeable', () => {
|
||||
const next = rebalancePayers({}, new Set(), new Set([1, 2]), -90)
|
||||
expect(next).toEqual({ 1: '-45.00', 2: '-45.00' })
|
||||
expect(payersBalanced(next, new Set([1, 2]), -90)).toBe(true)
|
||||
})
|
||||
|
||||
it('lets a free payer absorb the rest of a negative total past a pinned amount', () => {
|
||||
const next = rebalancePayers({ 1: '-70' }, new Set([1]), new Set([1, 2]), -100)
|
||||
expect(next[1]).toBe('-70')
|
||||
expect(next[2]).toBe('-30.00')
|
||||
})
|
||||
|
||||
it('handles a pinned positive payer against a negative total', () => {
|
||||
// Alice fronted 50 but the entry nets to -100 — the free payer carries -150.
|
||||
const next = rebalancePayers({ 1: '50' }, new Set([1]), new Set([1, 2]), -100)
|
||||
expect(next[2]).toBe('-150.00')
|
||||
expect(payersBalanced(next, new Set([1, 2]), -100)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Client/server share parity (#2176) ───────────────────────────────────────
|
||||
//
|
||||
// splitEqualShares exists twice: here (previewing the split in euros) and on the
|
||||
// server (netting the settlement in cents — BudgetService.splitEqualShares in
|
||||
// server/src/nest/budget/budget.service.ts). The fixture below is duplicated
|
||||
// verbatim in server/tests/unit/nest/budget.service.calc.test.ts; if either
|
||||
// implementation drifts — sign handling included — its copy of this table fails.
|
||||
const SHARE_PARITY_FIXTURE: { totalCents: number; users: number[]; itemId: number; expected: Record<number, number> }[] = [
|
||||
{ totalCents: 10000, users: [1, 2, 3], itemId: 0, expected: { 1: 3334, 2: 3333, 3: 3333 } },
|
||||
{ totalCents: 10000, users: [1, 2, 3], itemId: 1, expected: { 1: 3333, 2: 3334, 3: 3333 } },
|
||||
{ totalCents: -10000, users: [1, 2, 3], itemId: 0, expected: { 1: -3333, 2: -3333, 3: -3334 } },
|
||||
{ totalCents: -10000, users: [1, 2, 3], itemId: 1, expected: { 1: -3334, 2: -3333, 3: -3333 } },
|
||||
{ totalCents: -101, users: [1, 2], itemId: 0, expected: { 1: -50, 2: -51 } },
|
||||
{ totalCents: -101, users: [1, 2], itemId: 1, expected: { 1: -51, 2: -50 } },
|
||||
{ totalCents: -1, users: [1, 2, 3], itemId: 0, expected: { 1: 0, 2: 0, 3: -1 } },
|
||||
]
|
||||
|
||||
describe('splitEqualShares — server parity (#2176)', () => {
|
||||
it.each(SHARE_PARITY_FIXTURE)(
|
||||
'splits $totalCents cents across $users.length members (item $itemId) exactly like the server',
|
||||
({ totalCents, users, itemId, expected }) => {
|
||||
const shares = splitEqualShares(totalCents / 100, users.map(user_id => ({ user_id })), itemId)
|
||||
const inCents = Object.fromEntries(Object.entries(shares).map(([id, v]) => [id, Math.round(v * 100)]))
|
||||
expect(inCents).toEqual(expected)
|
||||
// The invariant behind the fixture: the shares always sum back to the total.
|
||||
expect(Object.values(inCents).reduce((a, b) => a + b, 0)).toBe(totalCents)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// ── Notes vs. itemized receipts (#1658) ──────────────────────────────────────
|
||||
|
||||
@@ -11,10 +11,24 @@
|
||||
* Amounts are the raw input strings, parsed on use (same as customAmounts).
|
||||
*/
|
||||
|
||||
/** Spread `amount` across `n` payers in whole cents so the parts sum back exactly. */
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
|
||||
// The split and receipt fields guard their own precision on every keystroke, so the
|
||||
// guard has to follow the currency: a three-decimal one (KWD, BHD, …) seeds three
|
||||
// places, and the old fixed two rejected every keystroke after that, leaving the field
|
||||
// unusable until a digit was deleted (#2175). Never stricter than two places, so no
|
||||
// value a field accepts today can become uneditable.
|
||||
export const amountPattern = (currency: string, signed: boolean) =>
|
||||
new RegExp(`^${signed ? '-?' : ''}\\d*\\.?\\d{0,${Math.max(2, currencyDecimals(currency))}}$`)
|
||||
|
||||
/**
|
||||
* Spread `amount` across `n` payers in whole cents so the parts sum back exactly.
|
||||
* Floor-based, so a negative total (a refund, #2176) splits just as exactly:
|
||||
* the remainder is always in [0, n) and the parts still sum to the input.
|
||||
*/
|
||||
export function splitCents(amount: number, n: number): number[] {
|
||||
if (n <= 0) return []
|
||||
const cents = Math.max(0, Math.round(amount * 100))
|
||||
const cents = Math.round(amount * 100)
|
||||
const base = Math.floor(cents / n)
|
||||
const rem = cents - base * n
|
||||
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
|
||||
@@ -58,6 +72,13 @@ export function rebalancePayers(
|
||||
* The remainder cent rotates with the item id rather than always landing on the
|
||||
* first member, so across several expenses the rounding evens out instead of
|
||||
* always favouring the same person.
|
||||
*
|
||||
* Must stay share-for-share identical to the server's
|
||||
* BudgetService.splitEqualShares (budget.service.ts) — the settlement is netted
|
||||
* there, this copy only previews it. The remainder is `totalCents - baseCents*n`
|
||||
* rather than `%` so a negative total (a refund, #2176) still yields a remainder
|
||||
* in [0, n) and the shares sum back to the total exactly, like the server's do.
|
||||
* The parity fixture in CostsPanel.helpers.test.ts pins both sides.
|
||||
*/
|
||||
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
|
||||
const n = members.length
|
||||
@@ -65,7 +86,7 @@ export function splitEqualShares(total: number, members: { user_id: number }[],
|
||||
|
||||
const totalCents = Math.round(total * 100)
|
||||
const baseCents = Math.floor(totalCents / n)
|
||||
const remainder = totalCents % n
|
||||
const remainder = totalCents - baseCents * n
|
||||
|
||||
const shares: Record<number, number> = {}
|
||||
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id)
|
||||
|
||||
@@ -295,6 +295,29 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
expect(card).toHaveTextContent('120') // 120,00 € (locale separator), i.e. 90 + 30
|
||||
})
|
||||
|
||||
// #2225 follow-up — the ledger stopped charging payer-less expenses, but this
|
||||
// tile kept counting them, so it contradicted the balances right beside it.
|
||||
it('keeps a payer-less expense out of Your share, like the balances do', async () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
const paid = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 60, payers: [{ user_id: 1, amount: 60, username: 'alice' }], members: [{ user_id: 1, username: 'alice', paid: 1 }] }
|
||||
const noPayer = { ...buildBudgetItem({ trip_id: 1, category: 'transport', name: 'Taxi' }), total_price: 40, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [paid, noPayer] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
// "Your share" is the first of the two bold figures in the Total spend card's
|
||||
// footer; the second is "You paid". 60 from the paid expense only — the 40
|
||||
// nobody has paid for is not a share, though it still counts as spend.
|
||||
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]')!
|
||||
const figures = [...card.querySelectorAll('b')].map(b => b.textContent ?? '')
|
||||
expect(figures[0]).toContain('60')
|
||||
expect(figures[0]).not.toContain('100')
|
||||
})
|
||||
|
||||
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
let posted: Record<string, unknown> | null = null
|
||||
@@ -518,8 +541,9 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
|
||||
// Loading used to be payers.find(...), which silently dropped the second payer.
|
||||
// Seeded with two decimals since #2175, localized for EUR.
|
||||
const amounts = await screen.findAllByTestId('payer-amount')
|
||||
expect(amounts.map(i => (i as HTMLInputElement).value)).toEqual(['45', '45'])
|
||||
expect(amounts.map(i => (i as HTMLInputElement).value)).toEqual(['45,00', '45,00'])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(put).toBeTruthy())
|
||||
@@ -705,8 +729,9 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
await screen.findByText('Payment')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
|
||||
// The stored USD amount comes back as-is, not silently reread as euros.
|
||||
expect((await screen.findByPlaceholderText('0.00') as HTMLInputElement).value).toBe('30')
|
||||
// The stored USD amount comes back as-is (padded to two decimals, #2175),
|
||||
// not silently reread as euros.
|
||||
expect((await screen.findByPlaceholderText('0.00') as HTMLInputElement).value).toBe('30.00')
|
||||
expect(screen.getByText(/^USD/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1300,8 +1325,9 @@ describe('CostsPanel — expense modal', () => {
|
||||
await screen.findByText('Dinner')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
|
||||
expect((await screen.findByDisplayValue('70')).tagName).toBe('INPUT')
|
||||
expect(screen.getByDisplayValue('30')).toBeInTheDocument()
|
||||
// Custom shares reopen padded to two decimals (#2175), localized for EUR.
|
||||
expect((await screen.findByDisplayValue('70,00')).tagName).toBe('INPUT')
|
||||
expect(screen.getByDisplayValue('30,00')).toBeInTheDocument()
|
||||
|
||||
// Excluding Bob drops his amount; the split no longer matches the total.
|
||||
await user.click(screen.getByRole('button', { name: /bob/i }))
|
||||
@@ -1430,7 +1456,8 @@ describe('CostsPanel — expense modal', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('Hotel Astoria')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('240')).toBeInTheDocument()
|
||||
// The prefilled amount is padded to the base currency's decimals (#2175).
|
||||
expect(screen.getByDisplayValue('240,00')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Add expense' }))
|
||||
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
@@ -1640,7 +1667,8 @@ describe('CostsPanel — remaining paths', () => {
|
||||
await screen.findByText('Mining rig')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
|
||||
expect(await screen.findByDisplayValue('2')).toBeInTheDocument()
|
||||
// XBT is unknown to the locale map → dot separator, two decimals (#2175).
|
||||
expect(await screen.findByDisplayValue('2.00')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('XBT').length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
@@ -1862,6 +1890,173 @@ describe('CostsPanel — mobile extras', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ── #2175: edit inputs are padded to the currency's decimals ────────────────
|
||||
describe('CostsPanel — decimal padding on edit (#2175)', () => {
|
||||
beforeEach(() => {
|
||||
seedAlice()
|
||||
clearExchangeRateCache()
|
||||
})
|
||||
afterEach(clearExchangeRateCache)
|
||||
|
||||
it('FE-W5COSTS-066: reopens 4,90 as "4,90" and 5,00 as "5,00", not "4,9" and "5"', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([
|
||||
expense({ id: 301, name: 'Coffee', category: 'food', total_price: 4.9, payers: [{ user_id: 1, amount: 4.9 }], members: [{ user_id: 1, username: 'alice' }] }),
|
||||
expense({ id: 302, name: 'Toll', category: 'transport', total_price: 5, payers: [{ user_id: 1, amount: 5 }], members: [{ user_id: 1, username: 'alice' }] }),
|
||||
])
|
||||
|
||||
await screen.findByText('Coffee')
|
||||
await user.click(screen.getAllByTitle('Edit')[0])
|
||||
expect(await screen.findByDisplayValue('4,90')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
await user.click(screen.getAllByTitle('Edit')[1])
|
||||
expect(await screen.findByDisplayValue('5,00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-071: a three-decimal currency seeds three places and the split field still takes input', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExpenseModal tripId={1} base="KWD" people={tripMembers} me={1} onClose={() => {}} onSaved={vi.fn()}
|
||||
editing={expense({
|
||||
id: 340, name: 'Museum', category: 'activities', currency: 'KWD', total_price: 3,
|
||||
payers: [{ user_id: 1, amount: 3 }],
|
||||
members: [{ user_id: 1, username: 'alice', amount: 1.5 }, { user_id: 2, username: 'bob', amount: 1.5 }],
|
||||
})} />)
|
||||
|
||||
expect(await screen.findByDisplayValue('3.000')).toBeInTheDocument()
|
||||
const shares = screen.getAllByDisplayValue('1.500') as HTMLInputElement[]
|
||||
|
||||
// The seed is three decimals, so a two-decimal guard would reject every further
|
||||
// keystroke, including one typed in front of the decimal point.
|
||||
await user.type(shares[0], '2', { initialSelectionStart: 0, initialSelectionEnd: 0 })
|
||||
expect(shares[0].value).toBe('21.500')
|
||||
|
||||
// A fourth decimal is still not a fils.
|
||||
await user.clear(shares[1])
|
||||
await user.type(shares[1], '1.5005')
|
||||
expect(shares[1].value).toBe('1.500')
|
||||
})
|
||||
})
|
||||
|
||||
// ── #2176: negative amounts record partial reimbursements ───────────────────
|
||||
describe('CostsPanel — negative amounts (#2176)', () => {
|
||||
beforeEach(() => {
|
||||
seedAlice()
|
||||
clearExchangeRateCache()
|
||||
})
|
||||
afterEach(clearExchangeRateCache)
|
||||
|
||||
it('FE-W5COSTS-067: "-100" can be typed and saves a negative expense with its payer', async () => {
|
||||
const user = userEvent.setup()
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel refund' }), id: 12 } })
|
||||
}))
|
||||
mount([])
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel refund')
|
||||
// The reported symptom: the '-' was stripped on input, so this was untypeable.
|
||||
await user.type(screen.getAllByPlaceholderText('0,00')[0], '-100')
|
||||
|
||||
const submits = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
expect(submits[submits.length - 1]).not.toBeDisabled()
|
||||
await user.click(submits[submits.length - 1])
|
||||
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.total_price).toBe(-100)
|
||||
expect(posted!.payers).toEqual([{ user_id: 1, amount: -100 }])
|
||||
expect(posted!.member_ids).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-068: a refund reopens with its negative payer intact and saves it back', async () => {
|
||||
const user = userEvent.setup()
|
||||
let put: Record<string, unknown> | null = null
|
||||
server.use(http.put('/api/trips/1/budget/310', async ({ request }) => {
|
||||
put = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: dinner() })
|
||||
}))
|
||||
mount([expense({
|
||||
id: 310, name: 'Hotel refund', category: 'lodging', total_price: -100,
|
||||
payers: [{ user_id: 1, amount: -100 }],
|
||||
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
|
||||
})])
|
||||
|
||||
// The row shows the amount twice — total pill and the payer chip, which used
|
||||
// to be filtered to positive amounts and left the refund's recipient invisible.
|
||||
const row = (await screen.findByText('Hotel refund')).closest('.exp-row') as HTMLElement
|
||||
expect(within(row).getAllByText(/100,00/).length).toBeGreaterThanOrEqual(2)
|
||||
// Not "Unfinished": the refund has its recipient recorded.
|
||||
expect(within(row).queryByText('Unfinished')).toBeNull()
|
||||
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
// The negative payer reopens selected instead of resetting to "Nobody",
|
||||
// and the total is seeded signed and padded.
|
||||
expect(await screen.findByRole('button', { name: 'You' })).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('-100,00')).toBeInTheDocument()
|
||||
|
||||
// Saving untouched must not silently drop the negative payer.
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(put).toBeTruthy())
|
||||
expect(put!.total_price).toBe(-100)
|
||||
expect(put!.payers).toEqual([{ user_id: 1, amount: -100 }])
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-069: a payer-less refund is marked Unfinished like a payer-less bill', async () => {
|
||||
mount([expense({ id: 320, name: 'Pending refund', category: 'misc', total_price: -50, payers: [], members: [{ user_id: 1, username: 'alice' }] })])
|
||||
|
||||
await screen.findByText('Pending refund')
|
||||
expect(screen.getByText('Unfinished')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-070: the category breakdown nets refunds and keeps a net-negative category listed', async () => {
|
||||
mount([
|
||||
expense({ id: 330, name: 'Dinner', category: 'food', total_price: 90, payers: [{ user_id: 1, amount: 90 }], members: [{ user_id: 1, username: 'alice' }] }),
|
||||
expense({ id: 331, name: 'Meal refund', category: 'food', total_price: -30, payers: [{ user_id: 1, amount: -30 }], members: [{ user_id: 1, username: 'alice' }] }),
|
||||
expense({ id: 332, name: 'Cancelled tour', category: 'activities', total_price: -20, payers: [{ user_id: 1, amount: -20 }], members: [{ user_id: 1, username: 'alice' }] }),
|
||||
])
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
const breakdown = screen.getByText('By category').parentElement as HTMLElement
|
||||
// Food nets 90 − 30 = 60; the fully refunded tour keeps a row of its own.
|
||||
expect(within(breakdown).getByText('60 €')).toBeInTheDocument()
|
||||
expect(within(breakdown).getByText(/[-−]20\s*€/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-072: the custom-split hint reads under and over the right way round on a refund', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} onClose={() => {}} onSaved={vi.fn()}
|
||||
editing={expense({
|
||||
id: 341, name: 'Hotel refund', category: 'accommodation', total_price: -100,
|
||||
payers: [{ user_id: 1, amount: -100 }],
|
||||
members: [{ user_id: 1, username: 'alice', amount: -70 }, { user_id: 2, username: 'bob' }],
|
||||
})} />)
|
||||
|
||||
// -70 of -100: 30 still to hand out, not 30 too much.
|
||||
expect(await screen.findByText('Sum of splits: €-70.00 of €-100.00 (under by €30.00)')).toBeInTheDocument()
|
||||
|
||||
await user.type(screen.getByPlaceholderText('-30,00'), '-40')
|
||||
expect(screen.getByText('Sum of splits: €-110.00 of €-100.00 (over by €10.00)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-073: the total can be turned into a refund without a minus key', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
|
||||
onClose={() => {}} onSaved={vi.fn()} />)
|
||||
|
||||
const total = (await screen.findAllByPlaceholderText('0,00'))[0] as HTMLInputElement
|
||||
await user.type(total, '100')
|
||||
|
||||
// The iOS decimal pad has no minus key, so the sign has to be reachable without one.
|
||||
const toggle = screen.getByRole('button', { name: 'Switch between expense and refund' })
|
||||
await user.click(toggle)
|
||||
|
||||
expect(total.value).toBe('-100')
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CostsPanel — split maths', () => {
|
||||
it('FE-W5COSTS-039: an equal split with nobody to split with is empty', () => {
|
||||
expect(splitEqualShares(90, [], 1)).toEqual({})
|
||||
|
||||
@@ -10,14 +10,14 @@ import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, cleanAmount } from '../../utils/formatters'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
|
||||
import { downloadBlob } from '../../utils/fileDownload'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { localToday } from '../Planner/today'
|
||||
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
@@ -123,7 +123,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
// ── derived expense maths (everything converted to the base currency) ────
|
||||
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
|
||||
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in. A negative
|
||||
// total (a refund, #2176) is just as unfinished until its recipient is named.
|
||||
const isUnfinished = (e: BudgetItem) => baseTotal(e) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0
|
||||
const myShareOf = (e: BudgetItem) => {
|
||||
// Nobody paid, so nobody owes: the ledger skips these entirely (#2225), and
|
||||
// counting them here left the tile contradicting the balances right beside it.
|
||||
if (isUnfinished(e)) return 0
|
||||
const myMember = (e.members || []).find(m => m.user_id === me)
|
||||
if (!myMember) return 0
|
||||
if (myMember.amount !== null && myMember.amount !== undefined) {
|
||||
@@ -133,9 +140,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const myShare = shares[me] || 0
|
||||
return convert(myShare, curOf(e))
|
||||
}
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in.
|
||||
const isUnfinished = (e: BudgetItem) => baseTotal(e) > 0 && (e.payers || []).filter(p => p.amount > 0).length === 0
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
|
||||
@@ -717,7 +721,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const c = catMeta(e.category)
|
||||
const Icon = c.Icon
|
||||
const cur = curOf(e)
|
||||
const payers = (e.payers || []).filter(p => p.amount > 0)
|
||||
const payers = (e.payers || []).filter(p => p.amount !== 0)
|
||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||
const unfinished = isUnfinished(e)
|
||||
const note = readUserNote(e)
|
||||
@@ -878,9 +882,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
}
|
||||
|
||||
function CategoryBreakdown() {
|
||||
// Categories net refunds against spend (#2176): a negative entry lowers its
|
||||
// category's sum, and a category that nets negative keeps its own row —
|
||||
// just without a bar, since the bars rank positive spend.
|
||||
const tot: Record<string, number> = {}
|
||||
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) }
|
||||
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
||||
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) !== 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
||||
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('costs.noCategories')}</div>
|
||||
// Bars are scaled relative to the most expensive category (the top row fills the
|
||||
// bar), not to the trip grand total — makes the relative ranking readable.
|
||||
@@ -888,7 +895,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{rows.map(c => {
|
||||
const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0
|
||||
const v = tot[c.key]; const pct = maxCat > 0 && v > 0 ? v / maxCat * 100 : 0
|
||||
return (
|
||||
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
|
||||
@@ -966,7 +973,9 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
|
||||
const otherDefault = people.find(p => p.id !== me)?.id ?? me
|
||||
const [fromId, setFromId] = useState<string>(String(editing?.from_user_id ?? me))
|
||||
const [toId, setToId] = useState<string>(String(editing?.to_user_id ?? otherDefault))
|
||||
const [amount, setAmount] = useState<string>(editing ? String(editing.amount) : '')
|
||||
// Seeded with the transfer's own currency decimals, so a reopened 4,90 reads
|
||||
// "4.90" and not "4.9" (#2175) — and a JPY transfer gets no fake decimals.
|
||||
const [amount, setAmount] = useState<string>(editing ? amountToInputString(editing.amount, (editing.currency || currency).toUpperCase()) : '')
|
||||
const [cur, setCur] = useState<string>((editing?.currency || currency).toUpperCase())
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
@@ -1051,9 +1060,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [day, setDay] = useState(editing?.expense_date || localToday())
|
||||
const [note, setNote] = useState(() => readUserNote(editing))
|
||||
// Edit and prefill seeds are padded to the currency's decimals (#2175): the DB
|
||||
// returns numbers, so a saved 4,90 would otherwise reopen as "4,9" and a saved
|
||||
// 5,00 as "5". A prefill has no currency of its own — it is read as `base`,
|
||||
// which is also what the currency field starts on.
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? String(cleanAmount(editing.total_price)) : ''
|
||||
if (prefill?.amount != null) return String(prefill.amount)
|
||||
if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : ''
|
||||
if (prefill?.amount != null) return amountToInputString(prefill.amount, base)
|
||||
return ''
|
||||
})
|
||||
const [participants, setParticipants] = useState<Set<number>>(() =>
|
||||
@@ -1064,8 +1077,9 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
// next". The single-payer dropdown stays the default path; multiPayer swaps in a
|
||||
// per-person amount editor. 0 represents "Nobody (planning entry)"; on an
|
||||
// existing expense a missing payer is a deliberate choice, so only a brand-new
|
||||
// one defaults to me.
|
||||
const initialPayers = (editing?.payers || []).filter(p => p.amount > 0)
|
||||
// one defaults to me. A negative payer (the recipient of a refund, #2176) is a
|
||||
// real payer — filtering on > 0 here would silently drop them on save.
|
||||
const initialPayers = (editing?.payers || []).filter(p => p.amount !== 0)
|
||||
|
||||
const [payerId, setPayerId] = useState<number>(() => {
|
||||
const existingPayer = initialPayers[0]
|
||||
@@ -1076,7 +1090,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const [payerIds, setPayerIds] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
|
||||
const [payerAmounts, setPayerAmounts] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
for (const p of initialPayers) m[p.user_id] = String(p.amount)
|
||||
for (const p of initialPayers) m[p.user_id] = amountToInputString(p.amount, currency)
|
||||
return m
|
||||
})
|
||||
// Payers the user typed an amount for: rebalance leaves these alone and makes
|
||||
@@ -1101,7 +1115,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
if (editing && editing.members) {
|
||||
for (const member of editing.members) {
|
||||
if (member.amount !== null && member.amount !== undefined) {
|
||||
m[member.user_id] = String(member.amount)
|
||||
m[member.user_id] = amountToInputString(member.amount, currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1119,6 +1133,10 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0)
|
||||
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
|
||||
// How much is still to be handed out, read on the total's own side: on a refund
|
||||
// (#2176) the shares run negative, so a plain total minus sum flips under and
|
||||
// over around and sends the user the wrong way.
|
||||
const splitShortfall = totalNum < 0 ? splitSum - totalNum : totalNum - splitSum
|
||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||
const equalShares = useMemo(() => {
|
||||
return splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0)
|
||||
@@ -1131,17 +1149,22 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const enteredSum = [...participants]
|
||||
.filter(id => customAmounts[id])
|
||||
.reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
const remaining = Math.max(0, totalNum - enteredSum)
|
||||
// Clamped toward zero on the total's own side, so an over-entered positive
|
||||
// split never suggests negative leftovers — while a negative total (#2176)
|
||||
// still previews its negative equal shares.
|
||||
const rest = totalNum - enteredSum
|
||||
const remaining = totalNum >= 0 ? Math.max(0, rest) : Math.min(0, rest)
|
||||
|
||||
return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0)
|
||||
}, [totalNum, participants, customAmounts, editing])
|
||||
|
||||
|
||||
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
|
||||
const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum))
|
||||
// A negative total is a valid entry (a refund, #2176); only zero has nothing to say.
|
||||
const valid = name.trim().length > 0 && payersOk && (
|
||||
isTicketMode
|
||||
? ticketValid
|
||||
: totalNum > 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
|
||||
: totalNum !== 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
|
||||
)
|
||||
|
||||
const onTotalChange = (v: string) => {
|
||||
@@ -1196,7 +1219,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
|
||||
const handleCustomAmountChange = (id: number, val: string) => {
|
||||
val = val.replace(',', '.')
|
||||
if (/^\d*\.?\d{0,2}$/.test(val) || val === '') {
|
||||
if (val === '' || amountPattern(currency, true).test(val)) {
|
||||
setCustomAmounts(prev => ({ ...prev, [id]: val }))
|
||||
}
|
||||
}
|
||||
@@ -1219,7 +1242,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
|
||||
const handleUpdateItemPrice = (id: string, price: string) => {
|
||||
price = price.replace(',', '.')
|
||||
if (/^\d*\.?\d{0,2}$/.test(price) || price === '') {
|
||||
if (price === '' || amountPattern(currency, false).test(price)) {
|
||||
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item))
|
||||
}
|
||||
}
|
||||
@@ -1264,7 +1287,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const payerList = multiPayer
|
||||
? [...payerIds]
|
||||
.map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 }))
|
||||
.filter(p => p.amount > 0)
|
||||
.filter(p => p.amount !== 0)
|
||||
: payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : []
|
||||
// A receipt line can name somebody who is not ticked as a participant. Sending
|
||||
// only the ticked set would drop their share, leaving the member sum short of
|
||||
@@ -1334,8 +1357,9 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px', opacity: isTicketMode ? 0.6 : 1 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
|
||||
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
|
||||
<NumericInput mode="signed-decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
|
||||
onValueChange={onTotalChange}
|
||||
signToggleLabel={t('costs.toggleSign')}
|
||||
disabled={isTicketMode}
|
||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||
</div>
|
||||
@@ -1353,7 +1377,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currency !== base && totalNum > 0 && (
|
||||
{currency !== base && totalNum !== 0 && (
|
||||
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{formatMoney(totalNum, currency, locale)}</span>
|
||||
<span className="text-content-faint">≈</span>
|
||||
@@ -1422,7 +1446,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
{on ? (
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{sym(currency)}</span>
|
||||
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} data-testid="payer-amount"
|
||||
<NumericInput mode="signed-decimal" placeholder={localizeAmountInput('0.00', currency)} data-testid="payer-amount"
|
||||
value={localizeAmountInput(payerAmounts[p.id] || '', currency)}
|
||||
onValueChange={v => onPayerAmountChange(p.id, v)}
|
||||
className="text-content"
|
||||
@@ -1591,7 +1615,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
|
||||
{customBalanced
|
||||
? t('costs.splitBalanced')
|
||||
: t(totalNum - splitSum > 0 ? 'costs.splitSumUnder' : 'costs.splitSumOver', {
|
||||
: t(splitShortfall > 0 ? 'costs.splitSumUnder' : 'costs.splitSumOver', {
|
||||
sum: sym(currency) + splitSum.toFixed(2),
|
||||
total: sym(currency) + totalNum.toFixed(2),
|
||||
diff: sym(currency) + Math.abs(totalNum - splitSum).toFixed(2),
|
||||
|
||||
@@ -1275,7 +1275,7 @@ describe('CollabNotes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// FE-W5CNT-001 to FE-W5CNT-029
|
||||
// FE-W5CNT-001 to FE-W5CNT-036
|
||||
// Fills in the load/error/attachment/category branches of useCollabNotes and the
|
||||
// view modal that the smoke tests above do not reach.
|
||||
|
||||
@@ -1327,6 +1327,14 @@ function wsHandler(): (msg: Record<string, unknown>) => void {
|
||||
return (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
}
|
||||
|
||||
function openedNoteBody(): Promise<HTMLElement> {
|
||||
return waitFor(() => {
|
||||
const md = document.querySelector('.collab-note-md-full');
|
||||
if (!md) throw new Error('view modal not open yet');
|
||||
return md as HTMLElement;
|
||||
});
|
||||
}
|
||||
|
||||
describe('CollabNotes details', () => {
|
||||
let addToast: ReturnType<typeof vi.fn<AddToast>>;
|
||||
let filesChanged: number;
|
||||
@@ -1912,4 +1920,59 @@ describe('CollabNotes details', () => {
|
||||
// …and both rejected writes are one message, not one each.
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-W5CNT-033: a placeholder in angle brackets survives sanitizing', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 7, title: 'Hotel', content: 'Zimmernummer <TBD> bestaetigen' })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Hotel');
|
||||
await user.click(screen.getByTitle('collab.notes.expand'));
|
||||
const full = await openedNoteBody();
|
||||
// Notes written before markdown rendering existed keep reading the way they
|
||||
// were typed (#2177).
|
||||
expect(within(full).getByText('Zimmernummer <TBD> bestaetigen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-034: a footnote reference lands on the footnote instead of a new tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 8, title: 'Booking', content: 'Hotel gebucht[^1]\n\n[^1]: Bestaetigung ABC123' })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Booking');
|
||||
await user.click(screen.getByTitle('collab.notes.expand'));
|
||||
const full = await openedNoteBody();
|
||||
|
||||
const ref = within(full).getByRole('link', { name: '1' });
|
||||
const href = ref.getAttribute('href')!;
|
||||
expect(href.startsWith('#')).toBe(true);
|
||||
expect(full.querySelector(`[id="${href.slice(1)}"]`)).not.toBeNull();
|
||||
expect(ref).not.toHaveAttribute('target');
|
||||
});
|
||||
|
||||
it('FE-W5CNT-035: the expanded note links its website out to a new tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 9, title: 'Hotel', website: 'www.hotel.test' })] });
|
||||
server.use(http.get('/api/trips/1/collab/link-preview', () => HttpResponse.json({ title: 'Hotel', image: null })));
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Hotel');
|
||||
await user.click(screen.getByTitle('collab.notes.expand'));
|
||||
const full = await openedNoteBody();
|
||||
|
||||
// A member without collab_edit never opens the edit modal, so this is the
|
||||
// only place the link is readable for them (#2222).
|
||||
const link = within(full).getByRole('link');
|
||||
expect(link).toHaveAttribute('href', 'https://www.hotel.test');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(link).toHaveTextContent('hotel.test');
|
||||
});
|
||||
|
||||
it('FE-W5CNT-036: a javascript: website stays unlinked in the expanded note', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 10, title: 'Hotel', website: 'javascript:alert(1)' })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Hotel');
|
||||
await user.click(screen.getByTitle('collab.notes.expand'));
|
||||
const full = await openedNoteBody();
|
||||
|
||||
expect(within(full).queryAllByRole('link')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../shared/markdownSanitize'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus, Pencil, X, StickyNote, Settings } from 'lucide-react'
|
||||
import { Plus, Pencil, X, StickyNote, Settings, ExternalLink } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
@@ -20,6 +21,7 @@ import { CategorySettingsModal } from './CollabNotesCategorySettingsModal'
|
||||
import { NoteCard } from './CollabNotesCard'
|
||||
import { FilePreviewPortal } from './CollabNotesFilePreviewPortal'
|
||||
import { AuthedImg } from './CollabNotesAuthedImg'
|
||||
import { safeExternalHref } from '../../utils/safeUrl'
|
||||
|
||||
// ── Main Component ──────────────────────────────────────────────────────────
|
||||
interface CollabNotesProps {
|
||||
@@ -392,9 +394,18 @@ function CollabNotesGrid(S: NotesState) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Label for the link chip: the host is what people recognise, "www." is noise. */
|
||||
function linkHost(url: string): string {
|
||||
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
|
||||
}
|
||||
|
||||
function ViewNoteModal(S: NotesState) {
|
||||
const { viewingNote, setViewingNote, canEdit, setEditingNote, getCategoryColor, t, setPreviewFile } = S
|
||||
if (!viewingNote) return null
|
||||
// A member without collab_edit only ever gets this modal, so the link has to
|
||||
// be here too (#2222). Allow-listed like the card tile: the field takes any
|
||||
// string, and a javascript: one would run in this origin.
|
||||
const websiteHref = safeExternalHref(viewingNote.website)
|
||||
return createPortal(
|
||||
<div
|
||||
style={{
|
||||
@@ -445,7 +456,18 @@ function ViewNoteModal(S: NotesState) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', lineHeight: 1.7 }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{viewingNote.content || ''}</Markdown>
|
||||
{websiteHref && (
|
||||
<a href={websiteHref} target="_blank" rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 12,
|
||||
padding: '6px 12px', borderRadius: 999, border: '1px solid var(--border-primary)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
color: 'var(--text-primary)', textDecoration: 'none',
|
||||
}}>
|
||||
<ExternalLink size={13} color="var(--text-muted)" /> {linkHost(websiteHref)}
|
||||
</a>
|
||||
)}
|
||||
{(viewingNote.attachments || []).length > 0 && (
|
||||
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { sanitizedMarkdownPlugins, sanitizedMarkdownComponents } from '../shared/markdownSanitize'
|
||||
import { Trash2, Pin, PinOff, Pencil, Maximize2 } from 'lucide-react'
|
||||
import { FONT } from './CollabNotes.constants'
|
||||
import { AuthedImg } from './CollabNotesAuthedImg'
|
||||
@@ -142,7 +143,7 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
|
||||
maxHeight: '4.5em', overflow: 'hidden',
|
||||
wordBreak: 'break-word', fontFamily: FONT,
|
||||
}}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{note.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -732,3 +732,146 @@ describe('CollabPolls details', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// FE-COMP-POLLS-016 to FE-COMP-POLLS-022
|
||||
// Markdown questions, sanitization and multiline input (#2177).
|
||||
|
||||
describe('CollabPolls markdown & multiline', () => {
|
||||
it('FE-COMP-POLLS-016: a markdown question renders headings and bold text', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: '## Day trip\n\n**Vote** carefully' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
const heading = await screen.findByText('Day trip');
|
||||
expect(heading.closest('h2')).not.toBeNull();
|
||||
expect(screen.getByText('Vote').closest('strong')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-017: a script/onerror injection attempt produces no live DOM nodes', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({
|
||||
polls: [buildPoll({ question: 'Safe title <script>alert("xss")</script> <img src=x onerror=alert(1)>' })],
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/Safe title/);
|
||||
expect(document.querySelector('script')).toBeNull();
|
||||
expect(document.querySelector('img[src="x"]')).toBeNull();
|
||||
expect(document.querySelector('[onerror]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-018: blank lines split the question into separate paragraphs', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: 'First paragraph\n\nSecond paragraph' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
const first = await screen.findByText('First paragraph');
|
||||
const second = screen.getByText('Second paragraph');
|
||||
expect(first.tagName).toBe('P');
|
||||
expect(second.tagName).toBe('P');
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-019: a plain-text question renders identically, without formatting nodes', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: 'Best destination?' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
const question = await screen.findByText('Best destination?');
|
||||
expect(question.textContent).toBe('Best destination?');
|
||||
// The question sits in a paragraph now, but no formatting was invented
|
||||
expect(question.parentElement!.querySelector('strong, em, h1, h2, a')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-020: the question field is a textarea — Enter adds a line instead of submitting', async () => {
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/polls', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ poll: buildPoll({ id: 60, question: 'Line one\nLine two' }) });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||
|
||||
const questionField = screen.getByPlaceholderText(/what should we do/i);
|
||||
expect(questionField.tagName).toBe('TEXTAREA');
|
||||
await user.type(questionField, 'Line one{enter}Line two');
|
||||
// Enter stayed in the field — the form was not submitted without options
|
||||
expect(body).toBeNull();
|
||||
expect(questionField).toHaveValue('Line one\nLine two');
|
||||
|
||||
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
||||
expect(optionInputs[0].tagName).toBe('TEXTAREA');
|
||||
await user.type(optionInputs[0], 'A');
|
||||
await user.type(optionInputs[1], 'B');
|
||||
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
|
||||
await waitFor(() => expect(body).toMatchObject({ question: 'Line one\nLine two' }));
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-021: a link in the question opens in a new tab with rel protection', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: 'Where? [Our hotel](https://example.com)' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
const link = await screen.findByRole('link', { name: 'Our hotel' });
|
||||
expect(link).toHaveAttribute('href', 'https://example.com');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-022: a long multiline option wraps instead of being clipped', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({
|
||||
polls: [buildPoll({
|
||||
options: [
|
||||
{ id: 1, text: 'Stay at the beach house\nwith the long unpronounceable name', voters: [] },
|
||||
{ id: 2, text: 'Rome', voters: [] },
|
||||
],
|
||||
})],
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
const label = (await screen.findByText(/Stay at the beach house/)).closest('span')!;
|
||||
expect(label.style.whiteSpace).toBe('pre-wrap');
|
||||
expect(label.style.overflowWrap).toBe('anywhere');
|
||||
expect(label.style.minWidth).toBe('0px');
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-023: a placeholder in angle brackets survives sanitizing', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: 'Treffpunkt <noch offen> oder Hotel?' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
// Questions written before markdown rendering existed must keep reading the
|
||||
// way they were typed, and they cannot be edited afterwards (#2177).
|
||||
expect(await screen.findByText('Treffpunkt <noch offen> oder Hotel?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-024: a script tag is shown as text, never as an element', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: 'Plan <script>alert("xss")</script>' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
expect(await screen.findByText('Plan <script>alert("xss")</script>')).toBeInTheDocument();
|
||||
expect(document.querySelector('script')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../shared/markdownSanitize'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -35,6 +39,34 @@ interface Poll {
|
||||
|
||||
const FONT = "var(--font-system)"
|
||||
|
||||
// Block styling for the markdown question (#2177). Tailwind's preflight resets
|
||||
// headings/lists, so the sizes are set here — in em, on purpose: they scale with
|
||||
// the surrounding calc(13px * var(--fs-scale-body)) base, so the user's
|
||||
// text-size setting keeps working. Links and sanitizing come from
|
||||
// markdownSanitize (#1629): new tab plus rel protection, this is cross-user
|
||||
// content.
|
||||
const pollMarkdownComponents: Components = {
|
||||
...sanitizedMarkdownComponents,
|
||||
p: ({ children }) => (
|
||||
<p style={{ margin: '0 0 0.55em' }}>{children}</p>
|
||||
),
|
||||
h1: ({ children }) => (
|
||||
<h1 style={{ margin: '0 0 0.45em', fontSize: '1.4em', lineHeight: 1.2 }}>{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 style={{ margin: '0 0 0.45em', fontSize: '1.25em', lineHeight: 1.25 }}>{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 style={{ margin: '0 0 0.45em', fontSize: '1.1em', lineHeight: 1.3 }}>{children}</h3>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul style={{ margin: '0 0 0.55em', paddingLeft: 20, listStyle: 'disc' }}>{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol style={{ margin: '0 0 0.55em', paddingLeft: 20, listStyle: 'decimal' }}>{children}</ol>
|
||||
),
|
||||
}
|
||||
|
||||
function timeRemaining(deadline) {
|
||||
if (!deadline) return null
|
||||
const diff = new Date(deadline).getTime() - Date.now()
|
||||
@@ -98,7 +130,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
{/* Question */}
|
||||
<div>
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
|
||||
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
<textarea autoFocus rows={5} value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', minHeight: 110, resize: 'vertical', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.4, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 3 }}>{t('collab.polls.markdownHint')}</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
@@ -107,8 +140,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
|
||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
|
||||
<textarea rows={2} value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
|
||||
style={{ flex: 1, minWidth: 0, resize: 'vertical', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.35, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
|
||||
{options.length > 2 && (
|
||||
<button type="button" onClick={() => removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={14} /></button>
|
||||
)}
|
||||
@@ -223,7 +256,9 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
|
||||
{poll.question}
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={pollMarkdownComponents}>
|
||||
{poll.question}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{isClosed && (
|
||||
@@ -281,7 +316,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
<button type="button" key={idx} onClick={() => onVote(poll.id, idx)}
|
||||
disabled={isClosed}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
|
||||
position: 'relative', display: 'flex', alignItems: 'flex-start', gap: 8,
|
||||
padding: '10px 12px', borderRadius: 10, border: 'none', cursor: isClosed ? 'default' : 'pointer',
|
||||
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
|
||||
overflow: 'hidden', transition: 'transform 0.1s',
|
||||
@@ -308,9 +343,12 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
{myVote && <Check size={11} color="#fff" strokeWidth={3} />}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
{/* Label — options stay plain text, but long or multiline ones
|
||||
must wrap instead of being clipped by the button's
|
||||
overflow:hidden (#2177) */}
|
||||
<span style={{
|
||||
flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
|
||||
flex: 1, minWidth: 0, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', wordBreak: 'break-word',
|
||||
lineHeight: 1.35, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
|
||||
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
{typeof opt === 'string' ? opt : opt.text}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-COLLIST-001 to FE-COMP-COLLIST-010
|
||||
// FE-COMP-COLLIST-001 to FE-COMP-COLLIST-011
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { CollectionPlace } from '@trek/shared';
|
||||
@@ -157,4 +157,14 @@ describe('CollectionList', () => {
|
||||
expect(screen.getByText('Blue Bottle Coffee').closest('.col-lrow')).toBeInTheDocument();
|
||||
expect(screen.getByText('Golden Gate Bridge').closest('.col-lrow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-011: the row opts out of the global press-scale (#2158)', () => {
|
||||
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
|
||||
// the row-wide role="button" shifted the status badge out from under the pointer,
|
||||
// so the click retargeted onto the row and opened the place instead of cycling the
|
||||
// status. The data-no-press attribute is the pin.
|
||||
renderList();
|
||||
|
||||
expect(screen.getByText('Blue Bottle Coffee').closest('.col-lrow')).toHaveAttribute('data-no-press');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,10 @@ export default function CollectionList({
|
||||
key={place.id}
|
||||
ref={active ? selectedRef : undefined}
|
||||
role="button"
|
||||
// Opt out of the global :active press-scale: shrinking the row-wide button
|
||||
// slides the status badge out from under the pointer mid-click, so the click
|
||||
// retargets onto the row and opens the place instead of cycling (#2158).
|
||||
data-no-press
|
||||
tabIndex={0}
|
||||
onClick={() => (selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (selectMode) onToggleSelect(place.id); else onOpenPlace(place.id) } }}
|
||||
|
||||
@@ -189,6 +189,23 @@ describe('EntryCard', () => {
|
||||
|
||||
expect(await screen.findByRole('link', { name: 'https://book.example' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// #2064: a stop switched off the route says so on its card, so a journal
|
||||
// that reads fine still shows which of its days the printed map skips.
|
||||
it('FE-JRN-CARD-018: marks an entry left out of the route, on the header and on the hero', () => {
|
||||
const { unmount } = mountCard(buildEntry({ stats_excluded: true }))
|
||||
expect(screen.getByText('Off route')).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
mountCard(buildEntry({ stats_excluded: true, photos: [buildPhoto(100)] }))
|
||||
expect(screen.getByText('Off route')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-CARD-019: carries no such mark on an ordinary entry', () => {
|
||||
mountCard(buildEntry({ photos: [buildPhoto(100)] }))
|
||||
|
||||
expect(screen.queryByText('Off route')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonCard', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus } from 'lucide-react'
|
||||
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus, RouteOff } from 'lucide-react'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
@@ -68,6 +68,14 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
{entry.entry_time}
|
||||
</span>
|
||||
)}
|
||||
{/* Switched off the route (#2064): the day is still in the journal,
|
||||
the printed map and the distance skip it, and the card says so. */}
|
||||
{entry.stats_excluded && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide">
|
||||
<RouteOff size={10} />
|
||||
{t('journey.entry.offRoute')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Menu top-right */}
|
||||
@@ -110,6 +118,11 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
<Clock size={10} /> {entry.entry_time}
|
||||
</span>
|
||||
)}
|
||||
{entry.stats_excluded && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500">
|
||||
<RouteOff size={10} /> {t('journey.entry.offRoute')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="relative">
|
||||
|
||||
@@ -965,4 +965,67 @@ describe('EntryEditor', () => {
|
||||
expect(library[0]).toHaveAttribute('multiple')
|
||||
expect(camera[0]).not.toHaveAttribute('multiple')
|
||||
})
|
||||
|
||||
// #2064: a home airport written up as an entry is a stop on the printed
|
||||
// route. The editor offers the same switch the Studio panel has, but only
|
||||
// to an entry that is (or was) a stop: one without a point never counted,
|
||||
// and a new entry is not on the route yet.
|
||||
it('FE-JRN-EDITOR-051: offers to leave a located entry out of the route and saves it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = mountEditor(buildEntry({
|
||||
id: 10, location_name: 'Keflavík', location_lat: 63.98, location_lng: -22.6,
|
||||
}))
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Leave out of the route' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
await user.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({ stats_excluded: true })
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-052: keeps the switch away from a new entry, point or no point', () => {
|
||||
mountEditor(buildEntry({ location_lat: 63.98, location_lng: -22.6 }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Leave out of the route' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-053: keeps the switch away from an entry without a point, and sends nothing for it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = mountEditor(buildEntry({ id: 10 }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Leave out of the route' })).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||
expect(onSave.mock.calls[0][0].stats_excluded).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-054: still offers the switch to a left-out entry, so it can be put back', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = mountEditor(buildEntry({ id: 10, stats_excluded: true }))
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Leave out of the route' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
await user.click(toggle)
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({ stats_excluded: false })
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-055: flipping the switch is a change worth warning about', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const user = userEvent.setup()
|
||||
const { onClose } = mountEditor(buildEntry({ id: 10, location_lat: 63.98, location_lng: -22.6 }))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Leave out of the route' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('You have unsaved changes. Discard them?')
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import { photoUrl, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyD
|
||||
import MarkdownToolbar from './MarkdownToolbar'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import { ProviderPicker, type ProviderPhotoGroup } from './JourneyDetailPageProviderPicker'
|
||||
|
||||
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
|
||||
@@ -48,6 +49,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [mood, setMood] = useState(entry.mood || '')
|
||||
const [weather, setWeather] = useState(entry.weather || '')
|
||||
const [statsExcluded, setStatsExcluded] = useState(entry.stats_excluded ?? false)
|
||||
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
|
||||
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -87,6 +89,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
(locationLng ?? null) !== (entry.location_lng ?? null) ||
|
||||
mood !== (entry.mood || '') ||
|
||||
weather !== (entry.weather || '') ||
|
||||
statsExcluded !== (entry.stats_excluded ?? false) ||
|
||||
pros.filter(p => p.trim()).join('\n') !== originalPros ||
|
||||
cons.filter(c => c.trim()).join('\n') !== originalCons ||
|
||||
pendingFiles.length > 0 ||
|
||||
@@ -146,6 +149,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
location_name: locationName || null,
|
||||
location_lat: locationLat,
|
||||
location_lng: locationLng,
|
||||
stats_excluded: offersStatsToggle ? statsExcluded : undefined,
|
||||
mood: mood || null,
|
||||
weather: weather || null,
|
||||
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
|
||||
@@ -214,6 +218,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
? { lat: locationLat!, lng: locationLng!, name: locationName || undefined }
|
||||
: null
|
||||
|
||||
// The route switch belongs to an entry that is a stop, or was one: an entry
|
||||
// without a point was never on the route, and a new one is not on it yet.
|
||||
const offersStatsToggle = entry.id > 0 && (contextLocation != null || !!entry.stats_excluded)
|
||||
|
||||
const handleUseCurrentLocation = async () => {
|
||||
if (locating) return
|
||||
setLocating(true)
|
||||
@@ -695,6 +703,19 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Every located entry is a stop on the route Studio prints, the home
|
||||
airport included. This is the entry's own way off it, the same
|
||||
switch the Studio travel panel offers (#2064). */}
|
||||
{offersStatsToggle && (
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[12px] font-semibold text-zinc-900 dark:text-white">{t('journey.editor.statsExcluded')}</div>
|
||||
<div className="text-[11px] leading-snug text-zinc-500 mt-0.5">{t('journey.editor.statsExcludedHint')}</div>
|
||||
</div>
|
||||
<ToggleSwitch on={statsExcluded} onToggle={() => setStatsExcluded(v => !v)} label={t('journey.editor.statsExcluded')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.mood')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-016
|
||||
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-019
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { act } from 'react'
|
||||
import { act, useRef, useState } from 'react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
@@ -73,20 +73,41 @@ const entries: JourneyEntry[] = [
|
||||
},
|
||||
]
|
||||
|
||||
/** Mirrors the page wiring: the gallery reports its connected providers up,
|
||||
and the host renders one button per provider next to Upload. */
|
||||
function GalleryHarness({ gallery, onPhotoClick, onRefresh, onRegisterUpload }: {
|
||||
gallery: GalleryPhoto[]
|
||||
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
|
||||
onRefresh: () => void
|
||||
onRegisterUpload?: (fn: () => void) => void
|
||||
}) {
|
||||
const [providers, setProviders] = useState<{ id: string; name: string }[]>([])
|
||||
const browseRef = useRef<((provider: string) => void) | null>(null)
|
||||
return (
|
||||
<>
|
||||
{providers.map(p => (
|
||||
<button type="button" key={p.id} onClick={() => browseRef.current?.(p.id)}>{p.name}</button>
|
||||
))}
|
||||
<GalleryView
|
||||
entries={entries}
|
||||
gallery={gallery}
|
||||
journeyId={JOURNEY_ID}
|
||||
userId={1}
|
||||
trips={trips}
|
||||
onPhotoClick={onPhotoClick}
|
||||
onRefresh={onRefresh}
|
||||
onRegisterUpload={onRegisterUpload}
|
||||
onRegisterProviders={(p, browse) => { setProviders(p); browseRef.current = browse }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function mountGallery(gallery: GalleryPhoto[], onRegisterUpload?: (fn: () => void) => void) {
|
||||
const onPhotoClick = vi.fn()
|
||||
const onRefresh = vi.fn()
|
||||
const utils = render(
|
||||
<GalleryView
|
||||
entries={entries}
|
||||
gallery={gallery}
|
||||
journeyId={JOURNEY_ID}
|
||||
userId={1}
|
||||
trips={trips}
|
||||
onPhotoClick={onPhotoClick}
|
||||
onRefresh={onRefresh}
|
||||
onRegisterUpload={onRegisterUpload}
|
||||
/>,
|
||||
<GalleryHarness gallery={gallery} onPhotoClick={onPhotoClick} onRefresh={onRefresh} onRegisterUpload={onRegisterUpload} />,
|
||||
)
|
||||
return { ...utils, onPhotoClick, onRefresh }
|
||||
}
|
||||
@@ -352,4 +373,14 @@ describe('GalleryView', () => {
|
||||
expect(container.querySelectorAll('.aspect-square')).toHaveLength(2)
|
||||
expect(container.querySelector('.animate-spin')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-JRN-GALLERY-019: the tile opts out of the global press-scale (#2158)', () => {
|
||||
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
|
||||
// the tile shifted its corner delete button out from under the pointer, so the
|
||||
// click retargeted onto the tile and opened the lightbox instead of deleting.
|
||||
// The data-no-press attribute is the pin.
|
||||
const { container } = mountGallery([buildGalleryPhoto()])
|
||||
|
||||
expect(container.querySelector('.aspect-square')).toHaveAttribute('data-no-press')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { RefreshCw, Camera, Image, X, Play } from 'lucide-react'
|
||||
import { RefreshCw, Camera, X, Play } from 'lucide-react'
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { isVideoFile } from '../../utils/videoPoster'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
@@ -13,7 +13,7 @@ import { ProviderPicker } from './JourneyDetailPageProviderPicker'
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload }: {
|
||||
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload, onRegisterProviders }: {
|
||||
entries: JourneyEntry[]
|
||||
gallery: GalleryPhoto[]
|
||||
journeyId: number
|
||||
@@ -22,16 +22,17 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
|
||||
onRefresh: () => void
|
||||
onRegisterUpload?: (fn: () => void) => void
|
||||
onRegisterProviders?: (providers: { id: string; name: string }[], browse: (provider: string) => void) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
||||
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
||||
const [galleryProgress, setGalleryProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const galleryUploading = galleryProgress !== null
|
||||
const toast = useToast()
|
||||
|
||||
// check which providers are enabled AND connected for the current user
|
||||
// check which providers are enabled AND connected for the current user, then
|
||||
// hand them up: the page header renders the provider buttons next to Upload
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -46,7 +47,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
if (status.connected) connected.push({ id: p.id, name: p.name })
|
||||
} catch { }
|
||||
}
|
||||
setAvailableProviders(connected)
|
||||
onRegisterProviders?.(connected, browseProvider)
|
||||
} catch { }
|
||||
})()
|
||||
}, [])
|
||||
@@ -126,23 +127,11 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
<div>
|
||||
<input ref={galleryFileRef} type="file" accept="image/*,video/*" multiple onChange={handleGalleryUpload} className="hidden" />
|
||||
|
||||
{/* Header */}
|
||||
{/* Header — the provider buttons live in the page header next to Upload */}
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-[0.07em]" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}>
|
||||
<Camera size={11} /> {allPhotos.length} {t('journey.detail.photos')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{availableProviders.map(p => (
|
||||
<button type="button"
|
||||
key={p.id}
|
||||
onClick={() => browseProvider(p.id)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[11px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<Image size={12} />
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{allPhotos.length === 0 ? (
|
||||
@@ -153,6 +142,10 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
<div
|
||||
key={photo.id}
|
||||
role="button"
|
||||
// No press-scale on the tile: shrinking it mid-click slides the corner
|
||||
// delete button out from under the pointer, so the click retargets onto
|
||||
// the tile and opens the lightbox instead (#2158).
|
||||
data-no-press
|
||||
tabIndex={0}
|
||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
||||
onClick={() => onPhotoClick(allPhotos, i)}
|
||||
|
||||
@@ -373,4 +373,40 @@ describe('JourneySettingsDialog', () => {
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('heading', { name: 'Journey Settings' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-SETTINGS-023: the trip-GPX switch reads off by default and turns on in one click (#2194)', async () => {
|
||||
const { onRefresh, onSaved } = mountDialog()
|
||||
|
||||
const sw = screen.getByRole('switch', { name: /GPX/i })
|
||||
expect(sw).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await userEvent.click(sw)
|
||||
|
||||
await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(3, { show_trip_tracks: true }))
|
||||
// Refresh, never onSaved: onSaved closes the dialog, which would destroy the
|
||||
// switch as it is flipped and skip the unsaved-changes guard.
|
||||
await waitFor(() => expect(onRefresh).toHaveBeenCalled())
|
||||
expect(onSaved).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-JRN-SETTINGS-024: a journey that already draws its tracks switches them back off (#2194)', async () => {
|
||||
// The column is INTEGER, so what arrives over the wire is 1, not true.
|
||||
mountDialog(buildJourney({ show_trip_tracks: 1 }))
|
||||
|
||||
const sw = screen.getByRole('switch', { name: /GPX/i })
|
||||
expect(sw).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
await userEvent.click(sw)
|
||||
await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(3, { show_trip_tracks: false }))
|
||||
})
|
||||
|
||||
it('FE-JRN-SETTINGS-025: a failed switch write says so and leaves the dialog open (#2194)', async () => {
|
||||
updateJourney.mockRejectedValueOnce(new Error('nope'))
|
||||
const { onClose } = mountDialog()
|
||||
|
||||
await userEvent.click(screen.getByRole('switch', { name: /GPX/i }))
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalled())
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,6 +77,24 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
}
|
||||
}
|
||||
|
||||
// Saved on the spot rather than on Save, like the archive switch above it:
|
||||
// it is a view setting, and the point of it is seeing the map change (#2194).
|
||||
const [savingTracks, setSavingTracks] = useState(false)
|
||||
const handleTracksToggle = async () => {
|
||||
setSavingTracks(true)
|
||||
try {
|
||||
await updateJourney(journey.id, { show_trip_tracks: !journey.show_trip_tracks })
|
||||
// onRefresh, not onSaved: onSaved closes the dialog, which would destroy the
|
||||
// switch the moment it is flipped AND skip handleClose's unsaved-changes
|
||||
// guard, silently dropping a title the owner had typed but not saved yet.
|
||||
onRefresh()
|
||||
} catch {
|
||||
toast.error(t('journey.settings.saveFailed'))
|
||||
} finally {
|
||||
setSavingTracks(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteJourney(journey.id)
|
||||
@@ -141,6 +159,30 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Trip GPX tracks on the journey map (#2194) */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.tracks')}</label>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!journey.show_trip_tracks}
|
||||
disabled={savingTracks}
|
||||
onClick={handleTracksToggle}
|
||||
className="w-full flex items-center gap-3 px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl bg-white dark:bg-zinc-800 text-left disabled:opacity-60"
|
||||
>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[14px] text-zinc-900 dark:text-white">{t('journey.settings.showTripTracks')}</span>
|
||||
<span className="block text-[11px] text-zinc-500">{t('journey.settings.showTripTracksHint')}</span>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`w-9 h-5 rounded-full flex-shrink-0 p-0.5 transition-colors ${journey.show_trip_tracks ? 'bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-600'}`}
|
||||
>
|
||||
<span className={`block w-4 h-4 rounded-full bg-white transition-transform ${journey.show_trip_tracks ? 'translate-x-4' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-027
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-050
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
@@ -27,6 +27,10 @@ vi.mock('leaflet', () => {
|
||||
setView: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
getZoom: vi.fn(() => 10),
|
||||
// Leaflet throws "Set map center and zoom first." out of these until the map
|
||||
// has a view; the loaded map is the default here, FE-COMP-JOURNEYMAP-050
|
||||
// takes it away for the window that #2254 fell into.
|
||||
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
zoomIn: vi.fn(),
|
||||
zoomOut: vi.fn(),
|
||||
// The photo layer subscribes to zoom/pan and clusters in screen space (#1614).
|
||||
@@ -58,6 +62,7 @@ import { render, act, fireEvent, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { buildSettings } from '../../../tests/helpers/factories';
|
||||
import type { Mock } from 'vitest';
|
||||
import L from 'leaflet';
|
||||
import JourneyMap from './JourneyMap';
|
||||
import type { JourneyMapHandle } from './JourneyMap';
|
||||
@@ -604,6 +609,39 @@ describe('JourneyMap', () => {
|
||||
expect(colors).not.toContain('#ffffff');
|
||||
});
|
||||
|
||||
/** The opening viewport is the assertion here, so the frame gets read out of the bounds call. */
|
||||
const fitCoords = () => vi.mocked(L.latLngBounds).mock.calls[0][0] as [number, number][];
|
||||
|
||||
// #2194: a recorded drive across the country used to pull the opening view out to
|
||||
// the whole trip, leaving the journey's own entries as a speck.
|
||||
it('FE-COMP-JOURNEYMAP-048: frames the entries, not the tracks, when the journey has both', () => {
|
||||
const origRAF = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
|
||||
try {
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} tracks={[track({ points: [[10, 10], [11, 11]] })]} />
|
||||
);
|
||||
|
||||
expect(mockedMap().fitBounds).toHaveBeenCalled();
|
||||
expect(fitCoords()).toEqual([[48.8566, 2.3522], [52.52, 13.405]]);
|
||||
} finally {
|
||||
globalThis.requestAnimationFrame = origRAF;
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-049: with nothing else on the map the tracks are framed instead of the world', () => {
|
||||
const origRAF = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
|
||||
try {
|
||||
render(<JourneyMap checkins={[]} entries={entriesWithoutCoords} tracks={[track()]} />);
|
||||
|
||||
expect(fitCoords()).toEqual([[47.1, 11.2], [47.2, 11.3]]);
|
||||
expect(mockedMap().setView).not.toHaveBeenCalledWith([30, 0], 2);
|
||||
} finally {
|
||||
globalThis.requestAnimationFrame = origRAF;
|
||||
}
|
||||
});
|
||||
|
||||
// A string handed to bindTooltip becomes innerHTML. A track name is a place
|
||||
// name off a shared trip and an entry label is a journey title, so both are
|
||||
// written by someone who is not necessarily the person reading the map — and
|
||||
@@ -660,4 +698,47 @@ describe('JourneyMap', () => {
|
||||
expect(html).toHaveLength(1);
|
||||
expect(html[0]).not.toMatch(/>\d+</);
|
||||
});
|
||||
|
||||
// #2254 — the map effect defers its opening fitBounds/setView to a rAF, so this
|
||||
// effect can draw while the map still has no center or zoom. Clustering
|
||||
// projects to container pixels, which throws in that window and took the whole
|
||||
// public journey page down with it.
|
||||
it('FE-COMP-JOURNEYMAP-050: waits out a map that has no view yet, then draws on moveend', () => {
|
||||
const noView = () => { throw new Error('Set map center and zoom first.'); };
|
||||
let hasView = false;
|
||||
const photoHtml = () =>
|
||||
vi.mocked(L.divIcon).mock.calls.map(c => String(c[0]?.html)).filter(h => h.includes('/uploads/'));
|
||||
|
||||
// The mock hands out one shared map instance, so it can be reached — and
|
||||
// stripped of its view — before the component ever builds one. Typed down to
|
||||
// the three spies this needs: L.map's signature promises a real Leaflet map.
|
||||
type MapProbe = { getCenter: Mock; latLngToContainerPoint: Mock; on: Mock };
|
||||
L.map(document.createElement('div'));
|
||||
const map = mockedMap() as MapProbe;
|
||||
vi.mocked(L.map).mockClear();
|
||||
map.getCenter.mockImplementation(() => (hasView ? { lat: 0, lng: 0 } : noView()));
|
||||
map.latLngToContainerPoint.mockImplementation(() => (hasView ? { x: 0, y: 0 } : noView()));
|
||||
|
||||
try {
|
||||
// Rendering must survive the viewless window, and draw nothing in it.
|
||||
expect(() =>
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={[]} photos={[{ id: 'p1', lat: 1, lng: 2, thumbUrl: '/uploads/late.jpg' }]} />,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(photoHtml()).toHaveLength(0);
|
||||
|
||||
// The deferred fitBounds lands: the listener this effect registered redraws.
|
||||
hasView = true;
|
||||
const redraw = map.on.mock.calls.find(c => c[0] === 'moveend')?.[1] as () => void;
|
||||
expect(redraw).toBeTypeOf('function');
|
||||
act(() => { redraw(); });
|
||||
|
||||
expect(photoHtml()).toHaveLength(1);
|
||||
expect(photoHtml()[0]).toContain('/uploads/late.jpg');
|
||||
} finally {
|
||||
map.getCenter.mockImplementation(() => ({ lat: 0, lng: 0 }));
|
||||
map.latLngToContainerPoint.mockImplementation(() => ({ x: 0, y: 0 }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,6 +268,23 @@ function JourneyMap(
|
||||
itemsRef.current = items
|
||||
|
||||
const allCoords: L.LatLngTuple[] = []
|
||||
/**
|
||||
* Track geometry is kept OUT of the fit set (#2194) and used only when there
|
||||
* is nothing else to frame.
|
||||
*
|
||||
* A journey's opening viewport should show the journey — its entries and the
|
||||
* trail between them. Letting a recorded GPX into the bounds meant one drive
|
||||
* across a country zoomed the map out until the entries were specks, which
|
||||
* is what the reporter's screenshot shows. JourneyMapGL already fits on
|
||||
* entries + trail alone, so for every journey that has entries the two
|
||||
* renderers now agree where they used to differ.
|
||||
*
|
||||
* They still differ for a journey with tracks and nothing else: this one
|
||||
* frames the tracks, JourneyMapGL falls back to the world view. Framing the
|
||||
* only thing on the map is the better of the two, and converging the GL side
|
||||
* is a change to a renderer this issue is not about.
|
||||
*/
|
||||
const trackCoords: L.LatLngTuple[] = []
|
||||
|
||||
if (stableTrail.length > 1) {
|
||||
const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple)
|
||||
@@ -293,7 +310,7 @@ function JourneyMap(
|
||||
// becomes innerHTML, and a track name is a place name off a shared trip.
|
||||
if (track.name) line.bindTooltip(escapeHtml(track.name), { sticky: true, direction: 'top', className: 'map-tooltip' })
|
||||
line.addTo(map)
|
||||
coords.forEach(c => allCoords.push(c))
|
||||
coords.forEach(c => trackCoords.push(c))
|
||||
}
|
||||
|
||||
// route polyline — only in non-fullscreen (sidebar map) mode
|
||||
@@ -341,9 +358,12 @@ function JourneyMap(
|
||||
if (!mapRef.current) return
|
||||
try {
|
||||
map.invalidateSize()
|
||||
if (allCoords.length > 0) {
|
||||
// Tracks only get a say when the journey has nothing located of its own —
|
||||
// a world view would be worse than framing the one thing on the map.
|
||||
const fitCoords = allCoords.length > 0 ? allCoords : trackCoords
|
||||
if (fitCoords.length > 0) {
|
||||
const pb = paddingBottom || 50
|
||||
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 16 })
|
||||
map.fitBounds(L.latLngBounds(fitCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 16 })
|
||||
} else {
|
||||
map.setView([30, 0], 2)
|
||||
}
|
||||
@@ -385,6 +405,16 @@ function JourneyMap(
|
||||
photoLayerRef.current = null
|
||||
if (!photos?.length) return
|
||||
|
||||
// The initial view (setView/fitBounds) is set on a deferred rAF in the
|
||||
// map-build effect above, so this can run before the map has a center/zoom
|
||||
// — latLngToContainerPoint throws in that window. Skip; the moveend/zoomend
|
||||
// listener below redraws once the deferred view lands.
|
||||
try {
|
||||
map.getCenter()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const group = L.layerGroup()
|
||||
for (const cluster of clusterPhotos(map, photos)) {
|
||||
const marker = L.marker([cluster.lat, cluster.lng], {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// FE-MAP-LOCBTN-001 to FE-MAP-LOCBTN-005
|
||||
import { render } from '@testing-library/react';
|
||||
import LocationButton from './LocationButton';
|
||||
import type { GeoWatchErrorCode } from '../../hooks/useGeolocation';
|
||||
|
||||
// useToast talks to the mounted ToastContainer through window.__addToast,
|
||||
// so a spy there observes exactly what the user would get shown. Outside a
|
||||
// TranslationProvider t(key) echoes the key, so assertions target the keys.
|
||||
const addToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
addToast.mockClear();
|
||||
window.__addToast = addToast;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function button(errorCode: GeoWatchErrorCode | null, error: string | null = errorCode && 'raw webkit text') {
|
||||
return <LocationButton mode="off" error={error} errorCode={errorCode} onClick={noop} />;
|
||||
}
|
||||
|
||||
describe('LocationButton', () => {
|
||||
it('FE-MAP-LOCBTN-001: shows the mode title and stays quiet without an error', () => {
|
||||
const { getByRole } = render(button(null));
|
||||
|
||||
expect(getByRole('button').getAttribute('title')).toBe('Show my location');
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MAP-LOCBTN-002: titles with the localized error text, not the raw browser string', () => {
|
||||
const { getByRole } = render(button('timeout'));
|
||||
|
||||
expect(getByRole('button').getAttribute('title')).toBe('map.location.timeout');
|
||||
expect(getByRole('button').getAttribute('aria-label')).toBe('map.location.timeout');
|
||||
});
|
||||
|
||||
it('FE-MAP-LOCBTN-003: fires exactly one error toast when a code appears', () => {
|
||||
const { rerender } = render(button(null));
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
|
||||
rerender(button('permission-denied'));
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
expect(addToast).toHaveBeenCalledWith('map.location.denied', 'error', 6000);
|
||||
|
||||
// The same error sticking around must not re-announce itself.
|
||||
rerender(button('permission-denied'));
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MAP-LOCBTN-004: only the transition from no error to an error toasts', () => {
|
||||
const { rerender } = render(button(null));
|
||||
rerender(button('permission-denied'));
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Code to code without recovering in between stays silent.
|
||||
rerender(button('timeout'));
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
|
||||
// After a recovery a fresh failure announces itself again.
|
||||
rerender(button(null));
|
||||
rerender(button('timeout'));
|
||||
expect(addToast).toHaveBeenCalledTimes(2);
|
||||
expect(addToast).toHaveBeenLastCalledWith('map.location.timeout', 'error', 6000);
|
||||
});
|
||||
|
||||
it('FE-MAP-LOCBTN-005: an insecure origin gets the HTTPS hint, not the permission one', () => {
|
||||
const { getByRole } = render(button('insecure-context', 'Only secure origins are allowed'));
|
||||
|
||||
expect(addToast).toHaveBeenCalledWith('journey.editor.locationInsecureContext', 'error', 6000);
|
||||
expect(getByRole('button').getAttribute('title')).toBe('journey.editor.locationInsecureContext');
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,58 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Navigation, LocateFixed, Locate } from 'lucide-react'
|
||||
import type { TrackingMode } from '../../hooks/useGeolocation'
|
||||
import type { TrackingMode, GeoWatchErrorCode } from '../../hooks/useGeolocation'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
interface Props {
|
||||
mode: TrackingMode
|
||||
error: string | null
|
||||
errorCode: GeoWatchErrorCode | null
|
||||
onClick: () => void
|
||||
// Offset from the bottom edge — callers push this up above the mobile
|
||||
// bottom nav. Defaults to 20px for desktop.
|
||||
bottomOffset?: number
|
||||
}
|
||||
|
||||
// The raw error string from the browser is unlocalized WebKit English, so the
|
||||
// user-facing message is looked up from the typed code instead. 'unsupported'
|
||||
// shares the generic text: a browser without geolocation cannot locate you.
|
||||
// 'insecure-context' borrows the journal editor's wording, which already says
|
||||
// exactly this in all locales.
|
||||
const ERROR_KEYS: Record<GeoWatchErrorCode, string> = {
|
||||
'permission-denied': 'map.location.denied',
|
||||
'unavailable': 'map.location.unavailable',
|
||||
'timeout': 'map.location.timeout',
|
||||
'unsupported': 'map.location.unavailable',
|
||||
'insecure-context': 'journey.editor.locationInsecureContext',
|
||||
}
|
||||
|
||||
// Three-state FAB. Matches the Apple/Google Maps pattern:
|
||||
// off → outline locate icon
|
||||
// show → filled locate (blue dot is visible on the map)
|
||||
// follow → filled navigation arrow (map follows + rotates with heading)
|
||||
export default function LocationButton({ mode, error, onClick, bottomOffset = 20 }: Props) {
|
||||
export default function LocationButton({ mode, error, errorCode, onClick, bottomOffset = 20 }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
// A title tooltip is invisible on touch, which is exactly where geolocation
|
||||
// errors happen (iOS PWA, see discussion #2095), so surface new errors as a
|
||||
// toast. The ref guards against re-fires: only the transition from no error
|
||||
// to an error announces itself.
|
||||
const prevCodeRef = useRef<GeoWatchErrorCode | null>(null)
|
||||
useEffect(() => {
|
||||
const prev = prevCodeRef.current
|
||||
prevCodeRef.current = errorCode
|
||||
if (errorCode && prev === null) {
|
||||
// The denied message is long, so give the toast extra time to be read.
|
||||
toast.error(t(ERROR_KEYS[errorCode]), 6000)
|
||||
}
|
||||
}, [errorCode, t, toast])
|
||||
|
||||
const Icon = mode === 'follow' ? Navigation : mode === 'show' ? LocateFixed : Locate
|
||||
const isActive = mode !== 'off'
|
||||
const title = error
|
||||
? error
|
||||
const errorText = errorCode ? t(ERROR_KEYS[errorCode]) : error
|
||||
const title = errorText
|
||||
? errorText
|
||||
: mode === 'off'
|
||||
? 'Show my location'
|
||||
: mode === 'show'
|
||||
|
||||
@@ -36,6 +36,7 @@ const geoMock = vi.hoisted(() => ({
|
||||
position: null as { lat: number; lng: number; accuracy: number; heading: number | null } | null,
|
||||
mode: 'off' as 'off' | 'show' | 'follow',
|
||||
error: null as string | null,
|
||||
errorCode: null as string | null,
|
||||
cycleMode: vi.fn(),
|
||||
setMode: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -3,7 +3,34 @@ import DOM from 'react-dom'
|
||||
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 } from './markerDrag'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
|
||||
|
||||
/**
|
||||
* The drive-time pill for one offered route.
|
||||
*
|
||||
* A divIcon rather than a tooltip: it has to be clickable, it has to sit exactly on the
|
||||
* road, and it is the same shape the GL renderers build by hand — one look across all
|
||||
* three maps.
|
||||
*/
|
||||
function alternativeLabelIcon(label: string, note: string, background: string, active: boolean) {
|
||||
const outline = active ? 'outline:2px solid #fff;outline-offset:1px;' : ''
|
||||
const second = note
|
||||
? `<span style="font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2">${escapeHtml(note)}</span>`
|
||||
: ''
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
// `font-family` spelled out: a divIcon sits inside `.leaflet-container`, whose own
|
||||
// stylesheet sets Helvetica/Arial on everything in it. Without this the label is the
|
||||
// one piece of TREK chrome on the map that is not in the app's typeface.
|
||||
html: `<span style="display:inline-flex;flex-direction:column;align-items:flex-start;white-space:nowrap;background:${background};color:${ALT_LABEL_TEXT};border-radius:11px;padding:6px 11px;font-family:var(--font-system);font-size:13px;font-weight:600;line-height:1.25;box-shadow:0 2px 10px rgba(0,0,0,.35);cursor:pointer;transition:none;${outline}"><span>${escapeHtml(label)}</span>${second}</span>`,
|
||||
iconSize: [0, 0],
|
||||
iconAnchor: [0, 0],
|
||||
})
|
||||
}
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
@@ -76,9 +103,21 @@ function formatViaDwell(seconds: number): string {
|
||||
* Shows image_url if available, otherwise category icon in colored circle.
|
||||
*/
|
||||
function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
|
||||
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}`
|
||||
const cached = iconCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
// A stop that interrupts the drive is drawn as its own small disc, before the photo
|
||||
// branch below ever gets a look at it — the brand logo a fuel search comes back with
|
||||
// is exactly what this replaces. No number badge either, for the same reason the rail
|
||||
// gives it none: it is part of the drive, not one of the day's stops.
|
||||
const service = serviceMarkerHtml(place.stop_type, isSelected)
|
||||
if (service) {
|
||||
const outer = serviceMarkerOuter(isSelected)
|
||||
const icon = L.divIcon({ className: '', html: service, iconSize: [outer, outer], iconAnchor: [outer / 2, outer / 2] })
|
||||
iconCache.set(cacheKey, icon)
|
||||
return icon
|
||||
}
|
||||
const size = isSelected ? 44 : 36
|
||||
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
|
||||
// escaping stops the attribute breakout but still permits a CSS url().
|
||||
@@ -159,7 +198,13 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
|
||||
// markers of planned places; the colour matches its pill category.
|
||||
const poiIconCache = new Map<string, L.DivIcon>()
|
||||
function createPoiIcon(category: string) {
|
||||
function createPoiIcon(category: string, brandWikidata?: string | null) {
|
||||
// One flat disc in the category's colour with its icon, and never the chain's logo.
|
||||
// The brands turned a corridor full of petrol stations into a row of advertisements,
|
||||
// they were unreadable at pin size, and a brand with no logo on file fell back to a
|
||||
// different picture entirely — so no two pins looked alike. `brandWikidata` is kept in
|
||||
// the signature because the callers still have it; it simply no longer changes anything.
|
||||
void brandWikidata
|
||||
const cached = poiIconCache.get(category)
|
||||
if (cached) return cached
|
||||
const cat = POI_CATEGORY_BY_KEY[category]
|
||||
@@ -167,7 +212,7 @@ function createPoiIcon(category: string) {
|
||||
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;">${svg}</div>`,
|
||||
html: `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;">${svg}</div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 13],
|
||||
tooltipAnchor: [0, -14],
|
||||
@@ -193,6 +238,41 @@ function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: bo
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
|
||||
*
|
||||
* The container rather than the drawn route: a polyline is a real element here but not in
|
||||
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
|
||||
* once the caller projects it onto the routed geometry — one behaviour for all three.
|
||||
*/
|
||||
function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (!onPoiDropOnRoute) return
|
||||
const container = map.getContainer()
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!draggedPoiId(e)) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const osmId = draggedPoiId(e)
|
||||
if (!osmId) return
|
||||
e.preventDefault()
|
||||
const rect = container.getBoundingClientRect()
|
||||
const at = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top])
|
||||
onPoiDropOnRoute(osmId, at.lat, at.lng)
|
||||
}
|
||||
container.addEventListener('dragover', onDragOver)
|
||||
container.addEventListener('drop', onDrop)
|
||||
return () => {
|
||||
container.removeEventListener('dragover', onDragOver)
|
||||
container.removeEventListener('drop', onDrop)
|
||||
}
|
||||
}, [map, onPoiDropOnRoute])
|
||||
return null
|
||||
}
|
||||
|
||||
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
||||
// OSM places for the visible area.
|
||||
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
|
||||
@@ -277,9 +357,17 @@ interface BoundsControllerProps {
|
||||
paddingOpts: L.FitBoundsOptions
|
||||
/** The map was built already framed on these places, so the opening fit has nothing to do. */
|
||||
framedOnMount?: boolean
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
|
||||
* for itself that it means "the selected day". Weighing the ways of driving one leg
|
||||
* needs that leg on screen, which is neither the day nor the trip.
|
||||
*/
|
||||
focusPoints?: [number, number][]
|
||||
}
|
||||
|
||||
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false }: BoundsControllerProps) {
|
||||
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints }: BoundsControllerProps) {
|
||||
const map = useMap()
|
||||
const prevFitKey = useRef(-1)
|
||||
const awaitingRoute = useRef(false)
|
||||
@@ -336,6 +424,15 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords])
|
||||
}, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Frame whatever was handed over. Nothing happens when it empties, so closing the
|
||||
// picker leaves the map where the user left it rather than snapping back.
|
||||
useEffect(() => {
|
||||
if (!focusPoints?.length) return
|
||||
// A day fit that has not run yet must not overwrite this a moment later.
|
||||
awaitingRoute.current = false
|
||||
fitTo(focusPoints)
|
||||
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -538,6 +635,18 @@ export const MapView = memo(function MapView({
|
||||
onViewportChange,
|
||||
tripId,
|
||||
routeVias = [],
|
||||
accessLines = [],
|
||||
onPoiDropOnRoute,
|
||||
onRouteClick,
|
||||
roadtripVias,
|
||||
onMoveVia,
|
||||
onRemoveVia,
|
||||
alternativeRoutes,
|
||||
focusPoints,
|
||||
clusterLoosely = false,
|
||||
activeAlternative,
|
||||
onChooseAlternative,
|
||||
onHighlightAlternative,
|
||||
}: any) {
|
||||
// The caller hands over whatever the user configured; what kind of basemap
|
||||
// that is decides which layer draws it. A saved raster template still wins,
|
||||
@@ -547,13 +656,21 @@ export const MapView = memo(function MapView({
|
||||
<Marker
|
||||
key={`poi-${poi.osm_id}`}
|
||||
position={[poi.lat, poi.lng]}
|
||||
icon={createPoiIcon(poi.category)}
|
||||
icon={createPoiIcon(poi.category, poi.brand_wikidata)}
|
||||
zIndexOffset={500}
|
||||
eventHandlers={{ click: () => onPoiClick?.(poi) }}
|
||||
eventHandlers={{
|
||||
click: () => onPoiClick?.(poi),
|
||||
// Wired on add rather than in a layout effect: Leaflet builds the icon element
|
||||
// itself, and this is the first moment there is one to attach to.
|
||||
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
|
||||
const el = e.target.getElement()
|
||||
if (el && onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
|
||||
</Marker>
|
||||
)), [pois, onPoiClick])
|
||||
)), [pois, onPoiClick, onPoiDropOnRoute])
|
||||
const visibleReservations = useMemo(() => (
|
||||
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
|
||||
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
|
||||
@@ -802,7 +919,7 @@ export const MapView = memo(function MapView({
|
||||
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
|
||||
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
|
||||
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode } = useGeolocation()
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
|
||||
// Desktop browsers only get IP-based geolocation (city-level accuracy),
|
||||
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
@@ -872,43 +989,127 @@ export const MapView = memo(function MapView({
|
||||
)}
|
||||
|
||||
<MapController center={center} zoom={zoom} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} />
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
|
||||
<ViewportController onViewportChange={onViewportChange} />
|
||||
<PoiDropTarget onPoiDropOnRoute={onPoiDropOnRoute} />
|
||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
{/* Road trip mode does not cluster at all: the whole drive has to stay readable
|
||||
zoomed right out, and a day's stops merging into one dot is exactly the shape
|
||||
it is being looked at for. Rendered outside the group rather than with the
|
||||
clustering disabled by option, so nothing is left to a plugin's own idea of
|
||||
what "disabled" means. */}
|
||||
{clusterLoosely ? markers : (
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
)}
|
||||
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
||||
The casing carries the click when the route can be reshaped: it is the wider of
|
||||
the two, so it is the one a pointer actually lands on. */}
|
||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
|
||||
<Polyline
|
||||
key={`${i}-casing`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={!!onRouteClick}
|
||||
eventHandlers={onRouteClick ? {
|
||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
// Stops the map's own click, which would otherwise open the add-place menu
|
||||
// underneath the new via.
|
||||
e.originalEvent.stopPropagation()
|
||||
onRouteClick(e.latlng.lat, e.latlng.lng)
|
||||
},
|
||||
} : undefined}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`${i}-core`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
] : [])}
|
||||
|
||||
{/* The last bit to a place the road does not reach.
|
||||
Dashed and thin, over the route rather than under it, because it is the one
|
||||
piece of the line that is not driving: the router snapped the stop to the
|
||||
nearest road and the drive really ends there. Same blue as the route, so it
|
||||
reads as the end of that route and not as a second one. */}
|
||||
{(accessLines ?? []).map((spur, i) => (
|
||||
<Polyline
|
||||
key={`access-${i}`}
|
||||
positions={spur.line}
|
||||
pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.85, dashArray: '2 7', lineCap: 'round' }}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* The offered ways of driving one leg, over the route they replace. Clicking one
|
||||
takes it, which is the same choice the bar above the map offers. */}
|
||||
{/* Every option in blue over a white casing, after Apple Maps: they are all real
|
||||
roads, so the difference is emphasis, not category. Grey was the first attempt
|
||||
and it disappeared on Positron, which is almost entirely greys. */}
|
||||
{(alternativeRoutes ?? []).flatMap((alt: AlternativeOverlay) => {
|
||||
const active = activeAlternative === alt.index
|
||||
return [
|
||||
<Polyline
|
||||
key={`alt-${alt.index}-casing`}
|
||||
positions={alt.coordinates}
|
||||
pathOptions={{ color: ALT_CASING, weight: active ? 9 : 8, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`alt-${alt.index}`}
|
||||
positions={alt.coordinates}
|
||||
pathOptions={{
|
||||
color: alt.color,
|
||||
weight: active ? 6 : 4,
|
||||
opacity: active ? 1 : 0.9,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}}
|
||||
eventHandlers={onChooseAlternative ? {
|
||||
click: (e: { originalEvent: MouseEvent }) => { e.originalEvent.stopPropagation(); onChooseAlternative(alt.index) },
|
||||
mouseover: () => onHighlightAlternative?.(alt.index),
|
||||
mouseout: () => onHighlightAlternative?.(null),
|
||||
} : undefined}
|
||||
/>,
|
||||
// The drive time on the road itself, the way Apple labels them.
|
||||
<Marker
|
||||
key={`alt-${alt.index}-label`}
|
||||
position={[alt.at.lat, alt.at.lng]}
|
||||
zIndexOffset={600}
|
||||
icon={alternativeLabelIcon(alt.label, alt.note, alt.labelBg, active)}
|
||||
eventHandlers={onChooseAlternative ? {
|
||||
click: () => onChooseAlternative(alt.index),
|
||||
mouseover: () => onHighlightAlternative?.(alt.index),
|
||||
mouseout: () => onHighlightAlternative?.(null),
|
||||
} : undefined}
|
||||
/>,
|
||||
]
|
||||
})}
|
||||
|
||||
{/* The handles that shape the drive. After the route so they sit on top of it. */}
|
||||
{roadtripVias ? (
|
||||
<RoadtripViaMarkers viasByDay={roadtripVias} onMoveVia={onMoveVia} onRemoveVia={onRemoveVia} />
|
||||
) : null}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
<TrackCasingPane onReady={setHasCasingPane} />
|
||||
{gpxPolylines}
|
||||
@@ -941,6 +1142,7 @@ export const MapView = memo(function MapView({
|
||||
{isMobile && <LocationButton
|
||||
mode={trackingMode}
|
||||
error={trackingError}
|
||||
errorCode={trackingErrorCode}
|
||||
onClick={cycleTrackingMode}
|
||||
bottomOffset={locationButtonBottom as unknown as number}
|
||||
/>}
|
||||
|
||||
@@ -195,6 +195,7 @@ const geoStub = vi.hoisted(() => ({
|
||||
position: null as GeoPosition | null,
|
||||
mode: 'off' as TrackingMode,
|
||||
error: null as string | null,
|
||||
errorCode: null as string | null,
|
||||
cycleMode: vi.fn(async () => {}),
|
||||
setMode: vi.fn((_m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => {}),
|
||||
}))
|
||||
@@ -1854,4 +1855,104 @@ describe('MapViewGL', () => {
|
||||
expect(glMap.off).toHaveBeenCalledWith('moveend', expect.any(Function))
|
||||
expect(glMap.off).toHaveBeenCalledWith('zoomend', expect.any(Function))
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-071: POI suggestions ride the map render clock instead of the pointer', async () => {
|
||||
// Same drift the planned-place pins had: a library Marker repositions on every
|
||||
// `move` event — one per pointer sample — while the canvas draws once a frame, so
|
||||
// the suggestions swam over the map during a drag and snapped back on release.
|
||||
loadOnAttach()
|
||||
glCanvasContainer.replaceChildren()
|
||||
glMap.project.mockReturnValue({ x: 100, y: 80 })
|
||||
|
||||
render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
pois={[{ osm_id: 'node:1', name: 'Aral', lat: 48.1, lng: 2.1, category: 'fuel', source: 'openstreetmap' } as never]}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
const layer = glCanvasContainer.firstElementChild as HTMLElement
|
||||
expect(layer.children.length).toBe(1)
|
||||
const pin = layer.firstElementChild as HTMLElement
|
||||
expect(pin.style.transform).toContain('translate(100px, 80px)')
|
||||
|
||||
glMap.project.mockReturnValue({ x: 140, y: 60 })
|
||||
act(() => { mapHandler('render')() })
|
||||
|
||||
expect(pin.style.transform).toContain('translate(140px, 60px)')
|
||||
})
|
||||
|
||||
// A via handle is a DOM element, and a pointerdown belongs to the element it happened
|
||||
// on. Rebuilding the handles on an unrelated render pulled that element out from under
|
||||
// the pointer, so a freshly placed via lost its first drag — the re-route it triggered
|
||||
// landed about a second later, which is exactly when somebody reaches for it.
|
||||
it('FE-COMP-MAPVIEWGL-VIA-001: a via handle survives a render it has nothing to do with', async () => {
|
||||
loadOnAttach()
|
||||
glCanvasContainer.replaceChildren()
|
||||
glMap.project.mockReturnValue({ x: 100, y: 80 })
|
||||
|
||||
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
|
||||
const { rerender } = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
roadtripVias={{ 1: [stored] }}
|
||||
onMoveVia={() => {}}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
const layer = glCanvasContainer.firstElementChild as HTMLElement
|
||||
const handle = layer.firstElementChild
|
||||
expect(handle).toBeTruthy()
|
||||
|
||||
// The same via in a fresh object, with a fresh callback: what every render of the
|
||||
// planner hands down while the day re-routes.
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
roadtripVias={{ 1: [{ ...stored }] }}
|
||||
onMoveVia={() => {}}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
expect(layer.firstElementChild).toBe(handle)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-VIA-002: a via that actually moved is drawn again', async () => {
|
||||
loadOnAttach()
|
||||
glCanvasContainer.replaceChildren()
|
||||
glMap.project.mockReturnValue({ x: 100, y: 80 })
|
||||
|
||||
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
|
||||
const { rerender } = render(
|
||||
<MapViewGL places={[]} fitKey={1} glProvider="maplibre-gl" roadtripVias={{ 1: [stored] }} onMoveVia={() => {}} />,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
const layer = glCanvasContainer.firstElementChild as HTMLElement
|
||||
const handle = layer.firstElementChild
|
||||
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
roadtripVias={{ 1: [{ ...stored, lat: 48.9 }] }}
|
||||
onMoveVia={() => {}}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
// The other half of the contract: holding the element still must not mean holding it
|
||||
// in the wrong place.
|
||||
expect(layer.firstElementChild).not.toBe(handle)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -81,6 +86,8 @@ interface RouteSegment {
|
||||
// mouseleave never fires" case (#1404).
|
||||
const NO_PLACES: Place[] = []
|
||||
const NO_ROUTE_VIAS: RouteVia[] = []
|
||||
/** Stable empty default: a fresh array each render would refire the spur effect. */
|
||||
const NO_ACCESS_LINES: { line: [[number, number], [number, number]]; meters: number }[] = []
|
||||
const NO_ROUTE_SEGMENTS: RouteSegment[] = []
|
||||
const NO_DAY_ORDER: Record<number, number[] | null> = {}
|
||||
const NO_RESERVATIONS: Reservation[] = []
|
||||
@@ -97,6 +104,8 @@ interface Props {
|
||||
tripId?: number | string
|
||||
// Charging stops / rest areas a plugin route places on the drawn day route.
|
||||
routeVias?: RouteVia[]
|
||||
/** The dashed last bit to a place the road network does not reach. */
|
||||
accessLines?: { line: [[number, number], [number, number]]; meters: number }[]
|
||||
route?: [number, number][][] | null
|
||||
routeSegments?: RouteSegment[]
|
||||
selectedPlaceId?: number | null
|
||||
@@ -121,6 +130,39 @@ interface Props {
|
||||
onReservationClick?: (reservationId: number) => void
|
||||
pois?: Poi[]
|
||||
onPoiClick?: (poi: Poi) => void
|
||||
/**
|
||||
* A corridor hit dropped somewhere on the map, with the coordinate it landed on.
|
||||
* The caller decides whether that point is near enough to the drive to mean anything.
|
||||
*/
|
||||
onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void
|
||||
/** A click on the drawn route, for putting a via point there (#1797). */
|
||||
onRouteClick?: (lat: number, lng: number) => void
|
||||
/** The ways of driving one leg, drawn while the picker is open. */
|
||||
alternativeRoutes?: AlternativeOverlay[]
|
||||
/** Which option is being considered, so it can be lit up in its own colour. */
|
||||
activeAlternative?: number | null
|
||||
onChooseAlternative?: (index: number) => void
|
||||
/** Reports which option the pointer is over, so the list and the map agree. */
|
||||
onHighlightAlternative?: (index: number | null) => void
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
|
||||
* for itself that it means "the selected day". Weighing the ways of driving one leg
|
||||
* needs that leg on screen, which is neither the day nor the trip.
|
||||
*/
|
||||
focusPoints?: [number, number][]
|
||||
/**
|
||||
* Let markers stay apart longer than usual.
|
||||
*
|
||||
* A road trip is read along a line: two stops fifty kilometres apart on the same
|
||||
* motorway are the shape of the day, and merging them into one dot hides it.
|
||||
*/
|
||||
clusterLoosely?: boolean
|
||||
/** Via points to draw as draggable handles, keyed by day (#1797). */
|
||||
roadtripVias?: Record<number, RoadtripVia[]>
|
||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
|
||||
onRemoveVia?: (dayId: number, id: number) => void
|
||||
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
|
||||
glProvider?: GlMapProvider
|
||||
/**
|
||||
@@ -134,7 +176,76 @@ interface Props {
|
||||
onMapReady?: (map: any | null) => void
|
||||
}
|
||||
|
||||
/** How eagerly place markers merge into a cluster, outside road trip mode. */
|
||||
const CLUSTER_RADIUS = 30
|
||||
const CLUSTER_MAX_ZOOM = 10
|
||||
|
||||
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
|
||||
* change and the initial build cannot drift apart. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function addPlaceClusterLayers(map: any): void {
|
||||
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
clusterRadius: CLUSTER_RADIUS,
|
||||
clusterMaxZoom: CLUSTER_MAX_ZOOM,
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': '#111827',
|
||||
'circle-opacity': 0.97,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
||||
'circle-stroke-width': 2.5,
|
||||
'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
||||
type: 'symbol',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-size': 12,
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(17,24,39,0.35)',
|
||||
'text-halo-width': 1,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_UNCLUSTERED_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 24,
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
// A stop that interrupts the drive gets its own small disc, decided before the photo
|
||||
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
|
||||
// No number badge either, for the same reason the rail gives it none.
|
||||
const service = serviceMarkerHtml(place.stop_type, selected)
|
||||
if (service) {
|
||||
const outer = serviceMarkerOuter(selected)
|
||||
const wrap = document.createElement('div')
|
||||
wrap.style.cssText = `width:${outer}px;height:${outer}px;display:flex;align-items:center;justify-content:center`
|
||||
wrap.innerHTML = service
|
||||
return wrap
|
||||
}
|
||||
|
||||
const size = selected ? 44 : 36
|
||||
// See MapView: allow-listed rather than escaped, because this is a CSS context.
|
||||
const borderColor = selected ? '#111827' : safeHexColor(place.category_color, 'white')
|
||||
@@ -356,6 +467,20 @@ function makePlacePin(map: any, layer: HTMLElement, el: HTMLElement, lng: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts an element on the map as a pin.
|
||||
*
|
||||
* `layer` is only ever set under MapLibre (that is where it gets created), so passing it
|
||||
* along is the whole provider decision: with a layer we position the element ourselves on
|
||||
* the map's render clock, without one the library's own Marker does it.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function attachPin(map: any, gl: any, layer: HTMLElement | null, el: HTMLElement, lng: number, lat: number): PlacePin {
|
||||
return layer
|
||||
? makePlacePin(map, layer, el, lng, lat)
|
||||
: libraryPin(new gl.Marker({ element: el, anchor: 'center' }).setLngLat([lng, lat]).addTo(map))
|
||||
}
|
||||
|
||||
// Tone dot for a plugin marker — visual twin of MapPluginMarkers' divIcon.
|
||||
function createPluginMarkerElement(tone: PluginMapMarker['tone']): HTMLDivElement {
|
||||
const color = PLUGIN_TONE_COLORS[tone] ?? PLUGIN_TONE_COLORS.default
|
||||
@@ -395,21 +520,37 @@ function buildPluginMarkerPopup(mk: PluginMapMarker): HTMLDivElement {
|
||||
}
|
||||
|
||||
// Small coloured pin for an OSM "explore" POI (matches the pill category colour).
|
||||
function createPoiMarkerElement(category: string): HTMLDivElement {
|
||||
// A chain shows its logo instead of the category icon: on a corridor full of petrol
|
||||
// stations the brand is what the eye is looking for, and the server proxies it so the
|
||||
// browser never asks Wikimedia which ones are on screen.
|
||||
function createPoiMarkerElement(category: string, brandWikidata?: string | null): HTMLDivElement {
|
||||
const cat = POI_CATEGORY_BY_KEY[category]
|
||||
const color = cat?.color || '#6b7280'
|
||||
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = 'width:26px;height:26px;cursor:pointer;'
|
||||
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;">${svg}</div>`
|
||||
el.style.cssText = 'width:26px;height:26px;cursor:pointer;will-change:transform;'
|
||||
el.innerHTML = `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;overflow:hidden;">${svg}${brandLogoMarkup(brandWikidata)}</div>`
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* Kept as the one place that decides a POI pin carries no brand mark: nothing.
|
||||
*
|
||||
* The logos turned a corridor full of petrol stations into a row of advertisements, were
|
||||
* unreadable at pin size, and a brand with no logo on file fell back to a different
|
||||
* picture entirely — so no two pins looked alike. A flat disc in the category's colour
|
||||
* with its icon is what the rail, the corridor list and the map all show now.
|
||||
*/
|
||||
export function brandLogoMarkup(_brandWikidata?: string | null): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
export function MapViewGL({
|
||||
places = NO_PLACES,
|
||||
dayPlaces = NO_PLACES,
|
||||
tripId,
|
||||
routeVias = NO_ROUTE_VIAS,
|
||||
accessLines = NO_ACCESS_LINES,
|
||||
route = null,
|
||||
routeSegments = NO_ROUTE_SEGMENTS,
|
||||
selectedPlaceId = null,
|
||||
@@ -420,6 +561,8 @@ export function MapViewGL({
|
||||
center = DEFAULT_MAP_CENTER,
|
||||
zoom = DEFAULT_MAP_ZOOM,
|
||||
fitKey = 0,
|
||||
focusPoints,
|
||||
clusterLoosely = false,
|
||||
dayOrderMap = NO_DAY_ORDER,
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
@@ -434,6 +577,15 @@ export function MapViewGL({
|
||||
onReservationClick,
|
||||
pois = NO_POIS,
|
||||
onPoiClick,
|
||||
onPoiDropOnRoute,
|
||||
onRouteClick,
|
||||
alternativeRoutes,
|
||||
activeAlternative,
|
||||
onChooseAlternative,
|
||||
onHighlightAlternative,
|
||||
roadtripVias,
|
||||
onMoveVia,
|
||||
onRemoveVia,
|
||||
onViewportChange,
|
||||
glProvider = 'mapbox-gl',
|
||||
gl,
|
||||
@@ -480,42 +632,354 @@ export function MapViewGL({
|
||||
const markersRef = useRef<Map<number, PlacePin>>(new Map())
|
||||
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
|
||||
const pinLayerRef = useRef<HTMLDivElement | null>(null)
|
||||
const repositionPins = useCallback(() => {
|
||||
markersRef.current.forEach(pin => pin.reposition())
|
||||
}, [])
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
|
||||
// Refs so the reservation overlay always sees the latest callback /
|
||||
// options without forcing a full overlay rebuild on every prop change.
|
||||
const onReservationClickRef = useRef(onReservationClick)
|
||||
onReservationClickRef.current = onReservationClick
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const poiMarkersRef = useRef<any[]>([])
|
||||
const poiMarkersRef = useRef<PlacePin[]>([])
|
||||
/** Undoes the drag wiring on each POI pin; the pins themselves are rebuilt wholesale. */
|
||||
const poiCleanupRef = useRef<(() => void)[]>([])
|
||||
const onPoiDropRef = useRef(onPoiDropOnRoute)
|
||||
onPoiDropRef.current = onPoiDropOnRoute
|
||||
// Plugin map contributions — data fetched per trip, elements owned imperatively
|
||||
// like the POI markers so they survive the React render cycle.
|
||||
const [pluginMarkers, setPluginMarkers] = useState<PluginMapMarker[]>([])
|
||||
const [pluginLayers, setPluginLayers] = useState<PluginMapLayer[]>([])
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pluginMarkersRef = useRef<any[]>([])
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const routeViaMarkersRef = useRef<any[]>([])
|
||||
const pluginMarkersRef = useRef<PlacePin[]>([])
|
||||
const routeViaMarkersRef = useRef<PlacePin[]>([])
|
||||
/** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */
|
||||
const viaPinsRef = useRef<PlacePin[]>([])
|
||||
/** The drive-time pills on the offered routes; same treatment. */
|
||||
const altLabelsRef = useRef<PlacePin[]>([])
|
||||
// Every hand-positioned pin, whichever set it belongs to. They all have to be written
|
||||
// in the same frame the canvas draws, or the ones left out swim against the ones in.
|
||||
const repositionPins = useCallback(() => {
|
||||
markersRef.current.forEach(pin => pin.reposition())
|
||||
poiMarkersRef.current.forEach(pin => pin.reposition())
|
||||
pluginMarkersRef.current.forEach(pin => pin.reposition())
|
||||
routeViaMarkersRef.current.forEach(pin => pin.reposition())
|
||||
viaPinsRef.current.forEach(pin => pin.reposition())
|
||||
altLabelsRef.current.forEach(pin => pin.reposition())
|
||||
}, [])
|
||||
// Single reusable hover popup for POI markers. Planned places use the
|
||||
// cursor-following React tooltip below so they match the Leaflet map.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const popupRef = useRef<any | null>(null)
|
||||
const onPoiClickRef = useRef(onPoiClick)
|
||||
onPoiClickRef.current = onPoiClick
|
||||
|
||||
/**
|
||||
* The via handles.
|
||||
*
|
||||
* Built as plain elements and attached with the same `attachPin` the place pins use,
|
||||
* rather than the library's own marker: under MapLibre those float above the map during
|
||||
* a drag instead of sticking to the ground (the reason `attachPin` exists at all). The
|
||||
* drag is therefore hand-rolled — pointer events on the element, unproject on move.
|
||||
*/
|
||||
const viaCleanupRef = useRef<(() => void)[]>([])
|
||||
/**
|
||||
* The list only changes when a via does, and the callbacks are read through a ref.
|
||||
*
|
||||
* Both matter for the same reason: this effect destroys every handle and builds new DOM
|
||||
* elements. It used to re-run on every render of the planner — the vias arrived as a
|
||||
* fresh object each time and `onMoveVia` as a fresh closure — so the render that landed
|
||||
* when a freshly placed via finished re-routing pulled the element out from under the
|
||||
* pointer, and the first drag went nowhere.
|
||||
*/
|
||||
const vias = useStableVias(roadtripVias)
|
||||
const viaHandlersRef = useRef({ onMoveVia, onRemoveVia })
|
||||
viaHandlersRef.current = { onMoveVia, onRemoveVia }
|
||||
const viasDraggable = !!onMoveVia
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
viaCleanupRef.current.forEach(off => off())
|
||||
viaCleanupRef.current = []
|
||||
viaPinsRef.current.forEach(p => p.remove())
|
||||
viaPinsRef.current = []
|
||||
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'
|
||||
const pin = attachPin(map, gl, pinLayerRef.current, el, via.lng, via.lat)
|
||||
viaPinsRef.current.push(pin)
|
||||
if (!viasDraggable) continue
|
||||
|
||||
let dragging = false
|
||||
// The map starts panning on mousedown/touchstart anywhere on its surface, and a pin
|
||||
// sits on that surface. Stopping only pointerdown left both running at once: the
|
||||
// handle moved AND the map slid out from under it.
|
||||
const swallow = (e: Event) => e.stopPropagation()
|
||||
// Where the gesture began, so a click can be told from a drag. Without it
|
||||
// every pointerup committed a move to wherever inside the 17px handle the
|
||||
// pointer happened to be: at a low zoom that is kilometres, and the write
|
||||
// re-routes the whole trip. Leaflet gets this for free from `dragend`,
|
||||
// which is why the two renderers behaved differently for one gesture.
|
||||
let startedAt: { x: number; y: number } | null = null
|
||||
const MOVED_ENOUGH = 4
|
||||
const onDown = (e: PointerEvent) => {
|
||||
// Primary button only. Right-clicking ran onDown, then contextmenu ->
|
||||
// remove AND pointerup -> move, so one gesture sent two writes; on the
|
||||
// platforms where contextmenu comes first, the move landed on an id that
|
||||
// no longer existed and reported a failure for a deletion that worked.
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
dragging = true
|
||||
startedAt = { x: e.clientX, y: e.clientY }
|
||||
el.setPointerCapture(e.pointerId)
|
||||
el.style.cursor = 'grabbing'
|
||||
}
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!dragging) return
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
|
||||
pin.setLngLat([at.lng, at.lat])
|
||||
}
|
||||
const onUp = (e: PointerEvent) => {
|
||||
if (!dragging) return
|
||||
dragging = false
|
||||
el.style.cursor = 'grab'
|
||||
const from = startedAt
|
||||
startedAt = null
|
||||
const travelled = from ? Math.hypot(e.clientX - from.x, e.clientY - from.y) : 0
|
||||
if (travelled < MOVED_ENOUGH) {
|
||||
// A click, not a drag. Put the pin back where the via actually is: the
|
||||
// move handler has been following the pointer across the handle.
|
||||
pin.setLngLat([via.lng, via.lat])
|
||||
return
|
||||
}
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
|
||||
viaHandlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
|
||||
}
|
||||
// The browser fires this when the captured element is torn out of the DOM,
|
||||
// which is exactly what a delete does. Committing a position there wrote a
|
||||
// move for a via that was being removed.
|
||||
const onCancel = () => {
|
||||
if (!dragging) return
|
||||
dragging = false
|
||||
startedAt = null
|
||||
el.style.cursor = 'grab'
|
||||
pin.setLngLat([via.lng, via.lat])
|
||||
}
|
||||
const onContext = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
viaHandlersRef.current.onRemoveVia?.(via.day_id, via.id)
|
||||
}
|
||||
el.addEventListener('pointerdown', onDown)
|
||||
el.addEventListener('pointermove', onMove)
|
||||
el.addEventListener('pointerup', onUp)
|
||||
el.addEventListener('pointercancel', onCancel)
|
||||
el.addEventListener('contextmenu', onContext)
|
||||
el.addEventListener('mousedown', swallow)
|
||||
el.addEventListener('touchstart', swallow, { passive: true })
|
||||
el.addEventListener('dblclick', swallow)
|
||||
viaCleanupRef.current.push(() => {
|
||||
el.removeEventListener('pointerdown', onDown)
|
||||
el.removeEventListener('pointermove', onMove)
|
||||
el.removeEventListener('pointerup', onUp)
|
||||
el.removeEventListener('pointercancel', onCancel)
|
||||
el.removeEventListener('contextmenu', onContext)
|
||||
el.removeEventListener('mousedown', swallow)
|
||||
el.removeEventListener('touchstart', swallow)
|
||||
el.removeEventListener('dblclick', swallow)
|
||||
})
|
||||
}
|
||||
}, [vias, viasDraggable, mapReady, glProvider])
|
||||
|
||||
/**
|
||||
* The offered routes, drawn under the current one.
|
||||
*
|
||||
* Grey and dashed rather than a set of bright lines: at any moment one of them IS the
|
||||
* route, drawn in blue on top, and three more solid colours next to it would read as
|
||||
* four equal roads. Hovering an option in the bar lights that one up in its own colour,
|
||||
* which is the only moment a second colour helps.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
const source = map.getSource?.('route-alternatives')
|
||||
if (!source?.setData) return
|
||||
source.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: (alternativeRoutes ?? []).map((alt, i) => ({
|
||||
type: 'Feature',
|
||||
properties: { index: i, active: activeAlternative === i, color: alt.color },
|
||||
geometry: { type: 'LineString', coordinates: alt.coordinates.map(([lat, lng]) => [lng, lat]) },
|
||||
})),
|
||||
})
|
||||
}, [alternativeRoutes, activeAlternative, mapReady, glProvider])
|
||||
|
||||
/**
|
||||
* The drive time on each offered route, the way Apple Maps labels them.
|
||||
*
|
||||
* Hand-positioned pins rather than a symbol layer: a symbol would need the text in the
|
||||
* style's own font stack, which differs between the bundled MapLibre styles and Mapbox,
|
||||
* and the pill has a shape a text layer cannot draw. They join the render clock like
|
||||
* every other pin here, so they stay on their road while the map moves.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
altLabelsRef.current.forEach(p => p.remove())
|
||||
altLabelsRef.current = []
|
||||
for (const alt of alternativeRoutes ?? []) {
|
||||
const el = document.createElement('button')
|
||||
el.type = 'button'
|
||||
const active = activeAlternative === alt.index
|
||||
el.style.cssText = [
|
||||
// A rounded rectangle rather than a pill, and roomy enough for a second line —
|
||||
// the shape Apple gives these, and the reason "1 h 55 min / Fastest" reads as one
|
||||
// label instead of two stacked chips.
|
||||
'display:flex;flex-direction:column;align-items:flex-start;white-space:nowrap',
|
||||
`background:${alt.labelBg}`,
|
||||
`color:${ALT_LABEL_TEXT}`,
|
||||
'border:none;border-radius:11px',
|
||||
// A <button> does not inherit the page's font, so without this the label reads in
|
||||
// the browser's UI face while every other piece of TREK chrome reads in Poppins.
|
||||
'font-family:var(--font-system)',
|
||||
'padding:6px 11px;font-size:13px;font-weight:600;line-height:1.25',
|
||||
'box-shadow:0 2px 10px rgba(0,0,0,.35)',
|
||||
'cursor:pointer;pointer-events:auto',
|
||||
// The pin is positioned by writing `transform` every frame. Any inherited
|
||||
// transition on it makes the label chase the map instead of sticking to the road,
|
||||
// and `transform` is exactly what a button picks up from the app's own styles.
|
||||
'transition:none !important;animation:none !important',
|
||||
'will-change:transform;backface-visibility:hidden',
|
||||
active ? 'outline:2px solid #fff;outline-offset:1px' : '',
|
||||
].filter(Boolean).join(';')
|
||||
const time = document.createElement('span')
|
||||
time.textContent = alt.label
|
||||
el.appendChild(time)
|
||||
if (alt.note) {
|
||||
const note = document.createElement('span')
|
||||
note.textContent = alt.note
|
||||
note.style.cssText = 'font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2'
|
||||
el.appendChild(note)
|
||||
}
|
||||
// Same reason as the via handles: without this the map pans as the label is pressed.
|
||||
el.addEventListener('mousedown', e => e.stopPropagation())
|
||||
el.addEventListener('touchstart', e => e.stopPropagation(), { passive: true })
|
||||
el.addEventListener('click', e => { e.stopPropagation(); onChooseAlternativeRef.current?.(alt.index) })
|
||||
el.addEventListener('mouseenter', () => onHighlightAlternativeRef.current?.(alt.index))
|
||||
el.addEventListener('mouseleave', () => onHighlightAlternativeRef.current?.(null))
|
||||
altLabelsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, alt.at.lng, alt.at.lat))
|
||||
}
|
||||
return () => {
|
||||
altLabelsRef.current.forEach(p => p.remove())
|
||||
altLabelsRef.current = []
|
||||
}
|
||||
}, [alternativeRoutes, activeAlternative, mapReady, glProvider])
|
||||
|
||||
/** Clicking one of the offered routes takes it, the same as clicking its chip. */
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady || !onChooseAlternative) return
|
||||
const onClick = (e: { features?: { properties?: { index?: number } }[] }) => {
|
||||
const index = e.features?.[0]?.properties?.index
|
||||
if (typeof index === 'number') onChooseAlternative(index)
|
||||
}
|
||||
const enter = () => { map.getCanvas().style.cursor = 'pointer' }
|
||||
const leave = () => { map.getCanvas().style.cursor = '' }
|
||||
if (!map.getLayer?.('route-alt-hit')) return
|
||||
map.on('click', 'route-alt-hit', onClick)
|
||||
map.on('mouseenter', 'route-alt-hit', enter)
|
||||
map.on('mouseleave', 'route-alt-hit', leave)
|
||||
return () => {
|
||||
map.off('click', 'route-alt-hit', onClick)
|
||||
map.off('mouseenter', 'route-alt-hit', enter)
|
||||
map.off('mouseleave', 'route-alt-hit', leave)
|
||||
}
|
||||
}, [mapReady, onChooseAlternative])
|
||||
|
||||
/**
|
||||
* A click on the route line, which in a GL renderer is a layer rather than an element.
|
||||
* `map.on(type, layerId, …)` is the one way to hit it, and it fires before the map's own
|
||||
* click, so the handler can keep the add-place menu from opening underneath the via.
|
||||
*/
|
||||
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 }) => {
|
||||
e.preventDefault?.()
|
||||
// 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
|
||||
// at the same two stops, and when the leg already carries vias the current
|
||||
// road is itself one of the offers, so the overlap is the whole leg. One
|
||||
// click then dropped a via AND chose a different road, two writes and two
|
||||
// trip reloads for a gesture the user made once. The picker is a modal
|
||||
// choice about that leg; dropping a via mid-choice is not offered anywhere.
|
||||
if (e.point && map.queryRenderedFeatures?.(e.point, { layers: ['route-alt-hit'] })?.length) return
|
||||
onRouteClick(e.lngLat.lat, e.lngLat.lng)
|
||||
}
|
||||
const enter = () => { map.getCanvas().style.cursor = 'copy' }
|
||||
const leave = () => { map.getCanvas().style.cursor = '' }
|
||||
for (const layer of ['trip-route-hit']) {
|
||||
if (!map.getLayer?.(layer)) continue
|
||||
map.on('click', layer, onClick)
|
||||
map.on('mouseenter', layer, enter)
|
||||
map.on('mouseleave', layer, leave)
|
||||
}
|
||||
return () => {
|
||||
for (const layer of ['trip-route-hit']) {
|
||||
map.off('click', layer, onClick)
|
||||
map.off('mouseenter', layer, enter)
|
||||
map.off('mouseleave', layer, leave)
|
||||
}
|
||||
}
|
||||
}, [mapReady, onRouteClick])
|
||||
|
||||
/**
|
||||
* The map takes the drop, not the drawn route: in a GL renderer the line lives in the
|
||||
* canvas and has no element to aim at. The coordinate under the pointer says just as
|
||||
* much once it is projected onto the routed geometry, and it works the same in all
|
||||
* three renderers.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
const container = map?.getContainer?.()
|
||||
if (!container || !mapReady || !onPoiDropOnRoute) return
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!draggedPoiId(e)) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const osmId = draggedPoiId(e)
|
||||
if (!osmId) return
|
||||
e.preventDefault()
|
||||
const rect = container.getBoundingClientRect()
|
||||
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
|
||||
onPoiDropOnRoute(osmId, at.lat, at.lng)
|
||||
}
|
||||
container.addEventListener('dragover', onDragOver)
|
||||
container.addEventListener('drop', onDrop)
|
||||
return () => {
|
||||
container.removeEventListener('dragover', onDragOver)
|
||||
container.removeEventListener('drop', onDrop)
|
||||
}
|
||||
}, [mapReady, onPoiDropOnRoute])
|
||||
const onViewportChangeRef = useRef(onViewportChange)
|
||||
onViewportChangeRef.current = onViewportChange
|
||||
const onMapReadyRef = useRef(onMapReady)
|
||||
onMapReadyRef.current = onMapReady
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||
onClickRefs.current.marker = onMarkerClick
|
||||
onClickRefs.current.map = onMapClick
|
||||
onClickRefs.current.context = onMapContextMenu
|
||||
const hoverDisabledRef = useRef(hoverDisabled)
|
||||
hoverDisabledRef.current = hoverDisabled
|
||||
// Read inside the map's own click handler, which is registered once at setup.
|
||||
const onRouteClickRef = useRef(onRouteClick)
|
||||
onRouteClickRef.current = onRouteClick
|
||||
const onChooseAlternativeRef = useRef(onChooseAlternative)
|
||||
onChooseAlternativeRef.current = onChooseAlternative
|
||||
const onHighlightAlternativeRef = useRef(onHighlightAlternative)
|
||||
onHighlightAlternativeRef.current = onHighlightAlternative
|
||||
// Same gate as the Leaflet renderer: HTML5 drag is a pointer feature, and the
|
||||
// day plan the marker would be dropped on is not on screen on a phone anyway.
|
||||
const markersDraggableRef = useRef(typeof window !== 'undefined' && navigator.maxTouchPoints === 0)
|
||||
@@ -599,6 +1063,49 @@ export function MapViewGL({
|
||||
if (glStyle === MAPBOX_DEFAULT_STYLE) {
|
||||
try { map.setTerrain(null) } catch { /* noop */ }
|
||||
}
|
||||
// The ways of driving one leg, offered while the picker is open (#1797). Added
|
||||
// BEFORE the route source so its layers sit underneath: the current route stays the
|
||||
// brightest thing on the map, and the options read as what they are — suggestions.
|
||||
if (!map.getSource('route-alternatives')) {
|
||||
map.addSource('route-alternatives', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
// A wide invisible band per option, so a pointer can pick one without hitting
|
||||
// the 3px line exactly.
|
||||
map.addLayer({
|
||||
id: 'route-alt-hit',
|
||||
type: 'line',
|
||||
source: 'route-alternatives',
|
||||
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 22 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
// A white casing under the option lines, the same trick the main route uses.
|
||||
// Plain grey disappeared on a grey basemap — Positron is mostly greys, and a
|
||||
// grey dashed line on it is invisible. The casing gives every option an edge, so
|
||||
// it reads on a pale map, a dark one and a satellite tile alike.
|
||||
map.addLayer({
|
||||
id: 'route-alt-casing',
|
||||
type: 'line',
|
||||
source: 'route-alternatives',
|
||||
paint: { 'line-color': ALT_CASING, 'line-width': 8, 'line-opacity': 0.9 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'route-alt-line',
|
||||
type: 'line',
|
||||
source: 'route-alternatives',
|
||||
// Dark and dashed by default so it stays subordinate to the blue route it is an
|
||||
// alternative to; the one being considered takes its own colour and thickens.
|
||||
// Every option in blue, after Apple Maps: they are all real roads, so the
|
||||
// difference between them is emphasis rather than category. The one being
|
||||
// considered thickens; nothing turns grey, because grey vanishes on Positron.
|
||||
paint: {
|
||||
'line-color': ['coalesce', ['get', 'color'], '#7eb8f0'],
|
||||
'line-width': ['case', ['boolean', ['get', 'active'], false], 6, 4],
|
||||
'line-opacity': ['case', ['boolean', ['get', 'active'], false], 1, 0.9],
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
|
||||
// initial route source — kept around so updates can setData() cheaply
|
||||
if (!map.getSource('trip-route')) {
|
||||
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
@@ -611,6 +1118,16 @@ export function MapViewGL({
|
||||
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
// An invisible band over the route, purely to be clicked. The drawn line is 8px
|
||||
// and a pointer is not that accurate, so aiming at the road was most of why
|
||||
// putting a via there felt like it did not work.
|
||||
map.addLayer({
|
||||
id: 'trip-route-hit',
|
||||
type: 'line',
|
||||
source: 'trip-route',
|
||||
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 26 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'trip-route-line',
|
||||
type: 'line',
|
||||
@@ -619,6 +1136,21 @@ export function MapViewGL({
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
// The last bit to a place the road does not reach (#1797). Added after the route
|
||||
// so it draws over it: it is the one piece of the line that is not driving, and it
|
||||
// has to be readable exactly where it meets the road it leaves. Same blue, because
|
||||
// it is the end of that route rather than a second one; dashed short and thin,
|
||||
// which is how a map says "on foot from here" without a legend.
|
||||
if (!map.getSource('trip-access')) {
|
||||
map.addSource('trip-access', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
map.addLayer({
|
||||
id: 'trip-access-line',
|
||||
type: 'line',
|
||||
source: 'trip-access',
|
||||
paint: { 'line-color': '#0a84ff', 'line-width': 3, 'line-opacity': 0.85, 'line-dasharray': [1, 2.5] },
|
||||
layout: { 'line-cap': 'round' },
|
||||
})
|
||||
}
|
||||
// gpx geometries source (place.route_geometry)
|
||||
if (!map.getSource('trip-gpx')) {
|
||||
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
@@ -681,53 +1213,7 @@ export function MapViewGL({
|
||||
map.on('mouseleave', GPX_HIT_LAYER_ID, clearTrackCursor)
|
||||
}
|
||||
if (!map.getSource(PLACE_CLUSTER_SOURCE_ID)) {
|
||||
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
clusterRadius: 30,
|
||||
clusterMaxZoom: 10,
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': '#111827',
|
||||
'circle-opacity': 0.97,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
||||
'circle-stroke-width': 2.5,
|
||||
'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
||||
type: 'symbol',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-size': 12,
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(17,24,39,0.35)',
|
||||
'text-halo-width': 1,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_UNCLUSTERED_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 24,
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
},
|
||||
})
|
||||
addPlaceClusterLayers(map)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const zoomToCluster = (e: any) => {
|
||||
const features = typeof map.queryRenderedFeatures === 'function'
|
||||
@@ -845,6 +1331,15 @@ export function MapViewGL({
|
||||
&& typeof map.queryRenderedFeatures === 'function'
|
||||
&& map.queryRenderedFeatures(e.point, { layers: [GPX_HIT_LAYER_ID] }).length > 0
|
||||
) return
|
||||
// And for the drive itself while it can be reshaped: that click just put a via
|
||||
// there, and it must not also drop a place on top of it (#1797).
|
||||
if (
|
||||
onRouteClickRef.current
|
||||
&& typeof map.getLayer === 'function'
|
||||
&& map.getLayer('trip-route-hit')
|
||||
&& typeof map.queryRenderedFeatures === 'function'
|
||||
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
|
||||
) return
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
})
|
||||
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
|
||||
@@ -1168,12 +1663,7 @@ export function MapViewGL({
|
||||
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
|
||||
// but it rotates the element by the pitch angle and visually offsets
|
||||
// the anchor by ~100px at 45° tilt, which caused the observed drift.
|
||||
const pin = isMapLibre && pinLayerRef.current
|
||||
? makePlacePin(map, pinLayerRef.current, el, place.lng, place.lat)
|
||||
: libraryPin(new gl.Marker({ element: el, anchor: 'center' })
|
||||
.setLngLat([place.lng, place.lat])
|
||||
.addTo(map))
|
||||
markersRef.current.set(place.id, pin)
|
||||
markersRef.current.set(place.id, attachPin(map, gl, pinLayerRef.current, el, place.lng, place.lat))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1185,6 +1675,18 @@ export function MapViewGL({
|
||||
return
|
||||
}
|
||||
|
||||
// Road trip mode does not cluster at all: the whole drive has to stay readable
|
||||
// zoomed right out, and a day's stops merging into one dot is exactly the shape it
|
||||
// is being looked at for. The source is emptied rather than reconfigured — the
|
||||
// cluster options can only be set when a GeoJSON source is created, and dropping and
|
||||
// re-adding a source under three layers to change a radius is not worth it when the
|
||||
// markers are drawn from `places` anyway.
|
||||
if (clusterLoosely) {
|
||||
source.setData({ type: 'FeatureCollection', features: [] })
|
||||
reconcileMarkers(validPlaces)
|
||||
return
|
||||
}
|
||||
|
||||
source.setData(buildPlaceClusterData(places) as any)
|
||||
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
|
||||
let raf: number | null = null
|
||||
@@ -1223,7 +1725,7 @@ export function MapViewGL({
|
||||
map.off('zoomend', scheduleReconcile)
|
||||
map.off('idle', scheduleReconcile)
|
||||
}
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider])
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely])
|
||||
|
||||
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
|
||||
// planned-place markers so they don't cluster or get confused with them).
|
||||
@@ -1233,15 +1735,19 @@ export function MapViewGL({
|
||||
popupRef.current?.remove() // same orphan-popup guard as the place markers
|
||||
poiMarkersRef.current.forEach(m => m.remove())
|
||||
poiMarkersRef.current = []
|
||||
poiCleanupRef.current.forEach(off => off())
|
||||
poiCleanupRef.current = []
|
||||
for (const poi of (pois as Poi[])) {
|
||||
const el = createPoiMarkerElement(poi.category)
|
||||
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
|
||||
el.addEventListener('mouseenter', () => {
|
||||
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
|
||||
})
|
||||
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
||||
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
||||
poiMarkersRef.current.push(m)
|
||||
// Dragging a hit onto the drive puts it where it is passed, rather than where the
|
||||
// corridor happened to project it. Only wired when someone is listening for it.
|
||||
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
|
||||
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
|
||||
}
|
||||
}, [pois, mapReady, glProvider])
|
||||
|
||||
@@ -1287,8 +1793,7 @@ export function MapViewGL({
|
||||
popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map)
|
||||
})
|
||||
}
|
||||
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([v.lng, v.lat]).addTo(map)
|
||||
routeViaMarkersRef.current.push(m)
|
||||
routeViaMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat))
|
||||
}
|
||||
}, [routeVias, mapReady, glProvider])
|
||||
|
||||
@@ -1306,7 +1811,7 @@ export function MapViewGL({
|
||||
popupRef.current?.setLngLat([mk.lng, mk.lat]).setDOMContent(buildPluginMarkerPopup(mk)).addTo(map)
|
||||
})
|
||||
}
|
||||
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([mk.lng, mk.lat]).addTo(map)
|
||||
const m = attachPin(map, gl, pinLayerRef.current, el, mk.lng, mk.lat)
|
||||
pluginMarkersRef.current.push(m)
|
||||
}
|
||||
}, [pluginMarkers, mapReady, glProvider])
|
||||
@@ -1325,6 +1830,22 @@ export function MapViewGL({
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [route, mapReady])
|
||||
|
||||
// Update access-spur geojson
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const src = map.getSource('trip-access') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
src.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: (accessLines || []).map(spur => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { meters: Math.round(spur.meters) },
|
||||
geometry: { type: 'LineString' as const, coordinates: spur.line.map(([lat, lng]) => [lng, lat]) },
|
||||
})),
|
||||
})
|
||||
}, [accessLines, mapReady])
|
||||
|
||||
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
|
||||
|
||||
// Update GPX geometries
|
||||
@@ -1452,6 +1973,25 @@ export function MapViewGL({
|
||||
if (routeArrivedForPendingFit) pendingRouteFitRef.current = null
|
||||
}, [fitKey, routeFitKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Frame whatever was handed over. Nothing happens when it empties, so closing the
|
||||
// picker leaves the map where the user left it rather than snapping back.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !focusPoints?.length) return
|
||||
const bounds = new gl.LngLatBounds()
|
||||
focusPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
|
||||
// A day fit still waiting on its route must not overwrite this a moment later.
|
||||
pendingRouteFitRef.current = null
|
||||
try {
|
||||
map.fitBounds(bounds, {
|
||||
padding: paddingOpts,
|
||||
maxZoom: 15,
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// flyTo selected place
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
@@ -1548,6 +2088,7 @@ export function MapViewGL({
|
||||
<LocationButton
|
||||
mode={trackingMode}
|
||||
error={trackingError}
|
||||
errorCode={trackingErrorCode}
|
||||
onClick={cycleTrackingMode}
|
||||
bottomOffset={buttonBottom as unknown as number}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useMemo, useRef } from 'react'
|
||||
import { Marker, Tooltip } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The handles that shape a drive.
|
||||
*
|
||||
* A via is deliberately small and unnumbered: it is not a stop, and anything that looks
|
||||
* like one would read as a place on the itinerary. Dragging it redraws the route through
|
||||
* the new spot, which is the whole interaction — the same one a paper map gets when you
|
||||
* put a finger on the road you actually want to take.
|
||||
*/
|
||||
const VIA_ICON = L.divIcon({
|
||||
className: '',
|
||||
html: `<span style="
|
||||
display:block;width:12px;height:12px;border-radius:9999px;
|
||||
background:#0a84ff;border:2.5px solid #ffffff;
|
||||
box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;
|
||||
"></span>`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6],
|
||||
})
|
||||
|
||||
interface RoadtripViaMarkersProps {
|
||||
viasByDay: Record<number, RoadtripVia[]>
|
||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
|
||||
onRemoveVia?: (dayId: number, id: number) => void
|
||||
}
|
||||
|
||||
/** Leaflet's half; the GL renderers draw their own inside `MapViewGL`. */
|
||||
export default function RoadtripViaMarkers({
|
||||
viasByDay, onMoveVia, onRemoveVia,
|
||||
}: RoadtripViaMarkersProps): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const vias = useStableVias(viasByDay)
|
||||
// Read through a ref so the handlers below can stay the same objects across renders.
|
||||
// Both callbacks are rebuilt on every render of the planner, and react-leaflet compares
|
||||
// its props by identity: a fresh `eventHandlers` unbinds and rebinds every listener,
|
||||
// and a fresh `position` array calls `setLatLng` on every marker. Doing either while a
|
||||
// drag is in flight is how a freshly placed via lost its first drag.
|
||||
const handlersRef = useRef({ onMoveVia, onRemoveVia })
|
||||
handlersRef.current = { onMoveVia, onRemoveVia }
|
||||
const draggable = !!onMoveVia
|
||||
|
||||
const markers = useMemo(() => vias.map(via => ({
|
||||
via,
|
||||
position: [via.lat, via.lng] as [number, number],
|
||||
handlers: {
|
||||
dragend: (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
|
||||
const at = e.target.getLatLng()
|
||||
handlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
|
||||
},
|
||||
// Right-click rather than a delete handle: a 12px dot has no room for one,
|
||||
// and the same gesture removes things elsewhere on the map.
|
||||
contextmenu: (e: { originalEvent: MouseEvent }) => {
|
||||
e.originalEvent.preventDefault()
|
||||
handlersRef.current.onRemoveVia?.(via.day_id, via.id)
|
||||
},
|
||||
},
|
||||
})), [vias])
|
||||
|
||||
if (!markers.length) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{markers.map(({ via, position, handlers }) => (
|
||||
<Marker
|
||||
key={`via-${via.id}`}
|
||||
position={position}
|
||||
icon={VIA_ICON}
|
||||
draggable={draggable}
|
||||
zIndexOffset={400}
|
||||
eventHandlers={handlers}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
{t('roadtrip.via.hint')}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
generateCoMapsUrl,
|
||||
parsePluginProfile,
|
||||
withHotelBookends,
|
||||
calculateAlternatives,
|
||||
} from './RouteCalculator'
|
||||
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
// calculateRouteWithLegs talks to the FOSSGIS per-profile hosts, not the car-only demo.
|
||||
// Every route now goes to the FOSSGIS per-profile hosts. The car-only project-osrm.org
|
||||
// demo ignored the profile in the URL, so walking routes followed the road network.
|
||||
const FOSSGIS = {
|
||||
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
|
||||
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
|
||||
@@ -46,7 +47,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-002: returns parsed coordinates on success', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse())
|
||||
)
|
||||
)
|
||||
@@ -56,7 +57,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-003: returns formatted distance text for >= 1000 m', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(1500, 360))
|
||||
)
|
||||
)
|
||||
@@ -66,7 +67,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-004: returns formatted distance in meters for short routes', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(800, 360))
|
||||
)
|
||||
)
|
||||
@@ -78,7 +79,7 @@ describe('calculateRoute', () => {
|
||||
const distance = 5000
|
||||
const osrmDuration = 999
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/walking/:coords`, () =>
|
||||
http.get(`${FOSSGIS.walking}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(distance, osrmDuration))
|
||||
)
|
||||
)
|
||||
@@ -90,7 +91,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-006: throws when OSRM returns non-ok HTTP status', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({}, { status: 500 })
|
||||
)
|
||||
)
|
||||
@@ -99,7 +100,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-007: throws when OSRM code is not Ok', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({ code: 'NoRoute', routes: [] })
|
||||
)
|
||||
)
|
||||
@@ -108,7 +109,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-008: respects AbortSignal', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse())
|
||||
)
|
||||
)
|
||||
@@ -128,7 +129,7 @@ describe('calculateSegments', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-010: returns segment midpoints and travel times', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({
|
||||
code: 'Ok',
|
||||
routes: [
|
||||
@@ -325,7 +326,7 @@ describe('parsePluginProfile', () => {
|
||||
describe('calculateRoute profiles', () => {
|
||||
it('FE-COMP-ROUTECALCULATOR-030: cycling overrides the OSRM duration with a 15 km/h estimate', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/cycling/:coords`, () =>
|
||||
http.get(`${FOSSGIS.cycling}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(9000, 4242))
|
||||
)
|
||||
)
|
||||
@@ -340,12 +341,12 @@ describe('calculateRoute profiles', () => {
|
||||
|
||||
describe('calculateSegments failures', () => {
|
||||
it('FE-COMP-ROUTECALCULATOR-031: throws when OSRM answers with an HTTP error', async () => {
|
||||
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({}, { status: 502 })))
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 502 })))
|
||||
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('Route could not be calculated')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-032: throws when OSRM reports no usable route', async () => {
|
||||
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
|
||||
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('No route found')
|
||||
})
|
||||
})
|
||||
@@ -613,3 +614,251 @@ describe('generateCoMapsUrl', () => {
|
||||
.toBe('https://comaps.at/map?v=1&ll=48.85,2.35&n=48.85%2C2.35')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a self-hosted routing engine', () => {
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
|
||||
})
|
||||
|
||||
const useOwnRouter = (base: string) =>
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: base } }))
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-021: a configured instance is asked instead of the public hosts', async () => {
|
||||
useOwnRouter('https://osrm.example.org')
|
||||
let asked = ''
|
||||
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
|
||||
|
||||
expect(asked).toContain('osrm.example.org')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-022: OSRM’s own profile names are used, not TREK’s', async () => {
|
||||
useOwnRouter('https://osrm.example.org')
|
||||
let asked = ''
|
||||
// TREK says "walking" and "cycling"; osrm-routed serves "foot" and "bike".
|
||||
server.use(http.get('https://osrm.example.org/route/v1/foot/:coords', ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'walking')
|
||||
|
||||
expect(asked).toContain('/route/v1/foot/')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-023: a trailing slash in the setting does not double up', async () => {
|
||||
useOwnRouter('https://osrm.example.org///')
|
||||
let asked = ''
|
||||
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
|
||||
|
||||
expect(asked).not.toContain('//route')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-024: blank or whitespace falls back to the public hosts', async () => {
|
||||
useOwnRouter(' ')
|
||||
let asked = ''
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
|
||||
|
||||
expect(asked).toContain('routing.openstreetmap.de')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateAlternatives', () => {
|
||||
const twoRoutes = {
|
||||
code: 'Ok',
|
||||
routes: [
|
||||
// The direct one, straight down the middle.
|
||||
{ geometry: { coordinates: [[10, 53], [10.5, 52.5], [11, 52]] }, distance: 100000, duration: 3600 },
|
||||
// A detour that swings well to the east before rejoining.
|
||||
{ geometry: { coordinates: [[10, 53], [12.5, 52.5], [11, 52]] }, distance: 130000, duration: 4500 },
|
||||
],
|
||||
}
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-025: asks for alternatives between exactly two points', async () => {
|
||||
let asked = ''
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(twoRoutes)
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(asked).toContain('alternatives=3')
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[0].distance).toBe(100000)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-026: each alternative carries the point that makes it different', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(twoRoutes)))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
// The first is what the router gives anyway, so it needs no pinning point.
|
||||
expect(routes[0].divergence).toBeNull()
|
||||
// The second diverges at its eastern swing — saving that as a via forces this road.
|
||||
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
|
||||
})
|
||||
|
||||
/** The router's own answer, and whatever it says when a road class is left out. */
|
||||
const withExclusions = (byExclude: Record<string, unknown>) =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
const answer = byExclude[exclude]
|
||||
return answer ? HttpResponse.json(answer) : HttpResponse.json({ code: 'NoRoute', routes: [] })
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-027: a road that is the same road either way is not offered twice', async () => {
|
||||
// Leaving the motorway out changed nothing — the leg does not touch one. Offering
|
||||
// the identical line a second time would be a choice that is not a choice.
|
||||
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[0]] }, toll: { code: 'Ok', routes: [twoRoutes.routes[0]] } }))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0].divergence).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-029: one answer from the router still offers the motorway-free way', async () => {
|
||||
// OSRM answers most long legs with exactly one route, which used to mean the picker
|
||||
// said there was no other way — while a perfectly good slower road existed.
|
||||
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[1]] } }))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[1].avoids).toBe('motorway')
|
||||
// Pinnable like any other offer: the point where it leaves the direct line.
|
||||
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-030: tolls are only asked about when the motorway gave nothing', async () => {
|
||||
const asked: string[] = []
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (exclude) asked.push(exclude)
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
if (exclude === 'motorway') return HttpResponse.json({ code: 'NoRoute', routes: [] })
|
||||
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] })
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(asked).toEqual(['motorway', 'toll'])
|
||||
expect(routes[1].avoids).toBe('toll')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-031: walking is never asked to leave out a road class', async () => {
|
||||
// The foot profile has no excludable classes; asking earns an InvalidOptions for
|
||||
// nothing, and a walk has no motorway to avoid in the first place.
|
||||
const asked: string[] = []
|
||||
server.use(http.get(`${FOSSGIS.walking}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (exclude) asked.push(exclude)
|
||||
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }, 'walking')
|
||||
|
||||
expect(asked).toEqual([])
|
||||
expect(routes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-032: a host that rejects exclude is only asked once', async () => {
|
||||
// Both public hosts TREK ships with answer 400 here, so without this every long leg
|
||||
// pays two extra requests against a one-per-second limit to be refused twice.
|
||||
const asked: string[] = []
|
||||
server.use(http.get('*/route/v1/driving/*', ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
asked.push(exclude)
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
}))
|
||||
|
||||
await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 13 }, 'driving')
|
||||
const afterFirst = asked.length
|
||||
await calculateAlternatives({ lat: 51, lng: 9 }, { lat: 50, lng: 12 }, 'driving')
|
||||
|
||||
expect(afterFirst).toBe(1)
|
||||
expect(asked).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-033: the snap positions come back with the route', async () => {
|
||||
// OSRM reports these in every answer and TREK threw them away since the first route
|
||||
// was drawn. Without them a place set back from the road looks like it is on the
|
||||
// route, while the drive really starts somewhere else.
|
||||
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
|
||||
code: 'Ok',
|
||||
waypoints: [
|
||||
{ location: [10.02, 53.51], distance: 812.5 },
|
||||
{ location: [13.0, 52.0], distance: 4 },
|
||||
],
|
||||
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
|
||||
})))
|
||||
|
||||
const r = await calculateRouteWithLegs([{ lat: 53.5, lng: 10.0 }, { lat: 52, lng: 13 }])
|
||||
|
||||
expect(r.snapped).toEqual([
|
||||
{ asked: [53.5, 10.0], at: [53.51, 10.02], meters: 812.5 },
|
||||
{ asked: [52, 13], at: [52.0, 13.0], meters: 4 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-034: a distance the router left out is worked out from the two points', async () => {
|
||||
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
|
||||
code: 'Ok',
|
||||
waypoints: [{ location: [10.0, 53.0] }, { location: [13.0, 52.0] }],
|
||||
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
|
||||
})))
|
||||
|
||||
const r = await calculateRouteWithLegs([{ lat: 53.01, lng: 10.0 }, { lat: 52, lng: 13 }])
|
||||
|
||||
// Roughly 1.1 km for a hundredth of a degree of latitude; the point is that it is a
|
||||
// real number rather than the whole answer being discarded.
|
||||
expect(r.snapped?.[0].meters).toBeGreaterThan(1000)
|
||||
expect(r.snapped?.[0].meters).toBeLessThan(1200)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-035: an answer that does not describe its waypoints changes nothing', async () => {
|
||||
// All-or-nothing: a partial list would have to be indexed by waypoint anyway, and one
|
||||
// bad entry would hang the spur off the wrong stop.
|
||||
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
|
||||
code: 'Ok',
|
||||
waypoints: [{ location: [10.0, 53.0] }],
|
||||
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
|
||||
})))
|
||||
|
||||
const r = await calculateRouteWithLegs([{ lat: 53, lng: 10 }, { lat: 52, lng: 13 }])
|
||||
|
||||
expect(r.snapped).toBeUndefined()
|
||||
expect(r.coordinates).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-028: no route at all is an empty list, not a throw', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({ code: 'NoRoute', routes: [] })))
|
||||
|
||||
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-029: a refused request is a RoutingRefusedError, not a generic one', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 429 })))
|
||||
|
||||
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }))
|
||||
.rejects.toMatchObject({ name: 'RoutingRefusedError', status: 429 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
|
||||
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
|
||||
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
|
||||
// the matching profile so walking routes follow footpaths, not the road network.
|
||||
@@ -14,6 +13,74 @@ const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
|
||||
}
|
||||
|
||||
/** OSRM's own profile names, which a self-hosted instance serves under its own paths. */
|
||||
const OSRM_PROFILE_PATH: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
driving: 'driving',
|
||||
walking: 'foot',
|
||||
cycling: 'bike',
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to ask for a route.
|
||||
*
|
||||
* The public FOSSGIS hosts allow roughly one request a second, which a road trip — every
|
||||
* leg of every day — runs into immediately. An instance that runs its own OSRM sets
|
||||
* `routing_base_url` and everything routes against that instead; the server has to name
|
||||
* the same origin in its connect-src, or the browser blocks the requests silently.
|
||||
*
|
||||
* A configured base is expected to serve the standard OSRM layout,
|
||||
* `<base>/route/v1/<profile>/…`, which is what `osrm-routed` does out of the box.
|
||||
*/
|
||||
/**
|
||||
* A configured base URL without its trailing slashes.
|
||||
*
|
||||
* Walked rather than matched with `/\/+$/`: that pattern backtracks over a run of
|
||||
* slashes, and the value comes from an instance setting somebody types. Linear either
|
||||
* way in practice, but the regex is the shape a scanner is right to flag.
|
||||
*/
|
||||
function withoutTrailingSlashes(value: string): string {
|
||||
let end = value.length
|
||||
while (end > 0 && value[end - 1] === '/') end -= 1
|
||||
return value.slice(0, end)
|
||||
}
|
||||
|
||||
function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
|
||||
const configured = useSettingsStore.getState().settings.routing_base_url?.trim()
|
||||
if (!configured) return OSRM_PROFILE_BASE[profile]
|
||||
return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A routing request the host refused, as opposed to one it answered with "no route".
|
||||
*
|
||||
* The public OSRM instances allow roughly one request a second and answer 429 above that.
|
||||
* Both used to arrive as the same generic Error, so a caller could not tell "back off and
|
||||
* try again" from "these two points are not connected by road" — and a road trip, which
|
||||
* asks for every leg of every day, hits the first case constantly while the second is
|
||||
* rare. `retryAfterMs` carries the host's own `Retry-After` when it sends one.
|
||||
*/
|
||||
export class RoutingRefusedError extends Error {
|
||||
constructor(readonly status: number, readonly retryAfterMs: number | null) {
|
||||
super(status === 429 ? 'Routing rate limit reached' : 'Route could not be calculated')
|
||||
this.name = 'RoutingRefusedError'
|
||||
}
|
||||
|
||||
/** True when waiting and asking again is the right response. */
|
||||
get isRateLimit(): boolean {
|
||||
return this.status === 429 || this.status === 503
|
||||
}
|
||||
}
|
||||
|
||||
/** `Retry-After` is either seconds or an HTTP date; anything else is no answer at all. */
|
||||
function retryAfterMs(response: Response): number | null {
|
||||
const raw = response.headers.get('Retry-After')
|
||||
if (!raw) return null
|
||||
const seconds = Number(raw)
|
||||
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000)
|
||||
const at = Date.parse(raw)
|
||||
return Number.isNaN(at) ? null : Math.max(0, at - Date.now())
|
||||
}
|
||||
|
||||
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
|
||||
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
|
||||
const routeCache = new Map<string, RouteWithLegs>()
|
||||
@@ -45,11 +112,11 @@ export async function calculateRoute(
|
||||
}
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) {
|
||||
throw new Error('Route could not be calculated')
|
||||
throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
@@ -252,10 +319,10 @@ export async function calculateSegments(
|
||||
if (!waypoints || waypoints.length < 2) return []
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${OSRM_BASE}/driving/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
|
||||
const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new Error('Route could not be calculated')
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||
@@ -344,10 +411,13 @@ export async function calculateRouteWithLegs(
|
||||
return result
|
||||
}
|
||||
|
||||
const osrmProfile = (profile === 'walking' || profile === 'cycling') ? profile : 'driving'
|
||||
const url = `${OSRM_PROFILE_BASE[osrmProfile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||
// Written as literals rather than narrowing `profile`: its type is an open string union
|
||||
// (plugins name their own modes), which no comparison narrows to the three OSRM knows.
|
||||
const osrmProfile: 'driving' | 'walking' | 'cycling' =
|
||||
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
|
||||
const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new Error('Route could not be calculated')
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||
@@ -374,7 +444,8 @@ export async function calculateRouteWithLegs(
|
||||
}
|
||||
)
|
||||
|
||||
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
|
||||
const snapped = readSnapped(data, waypoints)
|
||||
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
|
||||
routeCache.set(cacheKey, result)
|
||||
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||
const oldest = routeCache.keys().next().value
|
||||
@@ -383,6 +454,37 @@ export async function calculateRouteWithLegs(
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* The snap positions out of an OSRM answer, or undefined if it did not describe them.
|
||||
*
|
||||
* OSRM reports this in every response and TREK has thrown it away since the first route
|
||||
* was drawn. `distance` is the straight line from the coordinate we sent to the road it
|
||||
* used; it is recomputed from the two points when the field is missing or nonsense, so a
|
||||
* mirror that trims its answers still produces a usable gap rather than a wrong one.
|
||||
*
|
||||
* All-or-nothing on purpose: a partial list would have to be indexed by waypoint anyway,
|
||||
* and one bad entry would put a spur on the wrong stop.
|
||||
*/
|
||||
function readSnapped(data: unknown, waypoints: Waypoint[]): SnappedWaypoint[] | undefined {
|
||||
const raw = (data as { waypoints?: unknown })?.waypoints
|
||||
if (!Array.isArray(raw) || raw.length !== waypoints.length) return undefined
|
||||
const out: SnappedWaypoint[] = []
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const loc = (raw[i] as { location?: unknown })?.location
|
||||
if (!Array.isArray(loc) || loc.length < 2) return undefined
|
||||
const [lng, lat] = loc as [number, number]
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined
|
||||
const asked: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||
const at: [number, number] = [lat, lng]
|
||||
const reported = (raw[i] as { distance?: unknown })?.distance
|
||||
const meters = typeof reported === 'number' && Number.isFinite(reported) && reported >= 0
|
||||
? reported
|
||||
: haversineKm({ lat: asked[0], lng: asked[1] }, { lat, lng }) * 1000
|
||||
out.push({ asked, at, meters })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function getDistanceUnit(): DistanceUnit {
|
||||
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
|
||||
}
|
||||
@@ -403,3 +505,176 @@ function formatDuration(seconds: number): string {
|
||||
}
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
/** One way of driving a leg, as OSRM offers it. */
|
||||
export interface RouteAlternative {
|
||||
coordinates: [number, number][]
|
||||
distance: number
|
||||
duration: number
|
||||
/** Where this route differs most from the first one — the point that would pin it. */
|
||||
divergence: { lat: number; lng: number } | null
|
||||
/** Set when this way exists because a road class was left out of it. */
|
||||
avoids?: 'motorway' | 'toll'
|
||||
}
|
||||
|
||||
/**
|
||||
* Road classes worth asking the router to leave out, in the order they are tried.
|
||||
*
|
||||
* OSRM's own alternative search is conservative: it only offers a second road when the
|
||||
* detour is short enough and shares little enough with the first, so on a long motorway
|
||||
* run it usually answers with exactly one route — while a perfectly good, slower way over
|
||||
* the B-roads exists and is what somebody opening "other ways" is looking for. Excluding
|
||||
* a class asks a different question and reliably produces that road.
|
||||
*
|
||||
* Only for driving: the foot and bike profiles have no excludable classes, and asking
|
||||
* anyway earns an `InvalidOptions` for nothing.
|
||||
*/
|
||||
const EXCLUDABLE_CLASSES = ['motorway', 'toll'] as const
|
||||
|
||||
/** Two lines are the same road if their length and their duration agree closely enough. */
|
||||
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
|
||||
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts that have already answered "I do not do exclude", by base URL.
|
||||
*
|
||||
* Both public hosts TREK ships with reject the parameter outright with HTTP 400: the
|
||||
* upstream car profile is built without excludable classes, and only a self-hosted OSRM
|
||||
* built through the MLD pipeline supports it. Without this, every long leg on a default
|
||||
* install pays two extra requests to be told twice that it cannot have what it asked for,
|
||||
* against a host that allows one request per second.
|
||||
*
|
||||
* The first real attempt is the probe; there is no separate one. Only a 400 is recorded,
|
||||
* because only a 400 is about the host rather than about this particular question: a 429
|
||||
* is a rate limit, an abort is the user moving on, and a `NoRoute` means this leg has no
|
||||
* way round the motorway while the next one might.
|
||||
*
|
||||
* Per base URL rather than global, so pointing the instance at an own router that does
|
||||
* support it starts asking again without a reload.
|
||||
*/
|
||||
const excludeUnsupported = new Set<string>()
|
||||
|
||||
/**
|
||||
* One route with a road class left out, or null when the router will not or cannot.
|
||||
*
|
||||
* Every failure is a null: a rate limit answers 429, and a leg with no way round the
|
||||
* motorway answers `NoRoute`. Neither is worth an error on screen — they just mean this
|
||||
* particular question had no answer.
|
||||
*/
|
||||
async function routeExcluding(
|
||||
coords: string,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
const base = routeBaseFor(profile)
|
||||
if (excludeUnsupported.has(base)) return null
|
||||
try {
|
||||
const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson`
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) {
|
||||
// 400 is the router saying the parameter itself is not available here, which is
|
||||
// true of every leg from now on. Anything else is about this request alone.
|
||||
if (response.status === 400) excludeUnsupported.add(base)
|
||||
return null
|
||||
}
|
||||
const data = await response.json()
|
||||
const route = data?.code === 'Ok' && Array.isArray(data.routes) ? data.routes[0] : null
|
||||
if (!route?.geometry?.coordinates?.length) return null
|
||||
return {
|
||||
coordinates: route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng] as [number, number]),
|
||||
distance: route.distance,
|
||||
duration: route.duration,
|
||||
divergence: null,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ways of driving one leg, not one day.
|
||||
*
|
||||
* Deliberately per leg: OSRM only offers alternatives between exactly two coordinates, so
|
||||
* asking for a whole day would mean splitting it into one request per leg — which is the
|
||||
* bundling `splitIntoRuns` exists to avoid, and the reason a road trip appears at once
|
||||
* instead of trickling in. Asked for a single leg, on demand, it costs one extra request.
|
||||
*
|
||||
* `divergence` is the point on each alternative that lies furthest from the default route.
|
||||
* It is what makes a choice persistable: saving that point as a via forces the router back
|
||||
* onto this road on every future request, without storing a polyline that would go stale
|
||||
* with the next OSM update.
|
||||
*/
|
||||
export async function calculateAlternatives(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling' = 'driving',
|
||||
{ signal, limit = 3 }: { signal?: AbortSignal; limit?: number } = {},
|
||||
): Promise<RouteAlternative[]> {
|
||||
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
|
||||
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !Array.isArray(data.routes) || !data.routes.length) return []
|
||||
|
||||
const routes: RouteAlternative[] = data.routes.map((r: { geometry: { coordinates: [number, number][] }; distance: number; duration: number }) => ({
|
||||
coordinates: r.geometry.coordinates.map(([lng, lat]) => [lat, lng] as [number, number]),
|
||||
distance: r.distance,
|
||||
duration: r.duration,
|
||||
divergence: null,
|
||||
}))
|
||||
|
||||
// Nothing but the router's own preference came back, which is what it answers on most
|
||||
// long legs. Rather than reporting "only one sensible way", ask a different question:
|
||||
// the same drive without the motorway, and failing that without the tolls. Tried one at
|
||||
// a time and stopped as soon as one lands, so a leg that does have a second road costs
|
||||
// one extra request rather than three.
|
||||
//
|
||||
// Worth knowing before reading further: on the two public hosts TREK ships with, this
|
||||
// never produces anything. Their car profile is built without excludable classes and
|
||||
// the parameter comes back as HTTP 400, which is why long legs keep reporting a single
|
||||
// way to drive them. It works against a self-hosted OSRM built through the MLD
|
||||
// pipeline, and `excludeUnsupported` makes sure a host that refuses is only asked once.
|
||||
if (routes.length < 2 && profile === 'driving') {
|
||||
for (const exclude of EXCLUDABLE_CLASSES) {
|
||||
if (signal?.aborted) break
|
||||
const detour = await routeExcluding(coords, profile, exclude, signal)
|
||||
if (detour && !routes.some(r => sameRoad(r, detour))) {
|
||||
detour.avoids = exclude
|
||||
routes.push(detour)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The first route is what the router would have given anyway; the others are measured
|
||||
// against it so each one can be pinned by the point that makes it different.
|
||||
const [primary, ...rest] = routes
|
||||
for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates)
|
||||
return routes
|
||||
}
|
||||
|
||||
/** The point of `line` that lies furthest from `reference`, in plain degrees. */
|
||||
function furthestPointFrom(line: [number, number][], reference: [number, number][]): { lat: number; lng: number } | null {
|
||||
if (!line.length || !reference.length) return null
|
||||
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
|
||||
// full cross product of two thousand-point lines is not worth the milliseconds.
|
||||
const step = Math.max(1, Math.floor(reference.length / 200))
|
||||
let best: { lat: number; lng: number } | null = null
|
||||
let bestDist = -1
|
||||
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
|
||||
const [lat, lng] = line[i]
|
||||
let nearest = Infinity
|
||||
for (let j = 0; j < reference.length; j += step) {
|
||||
const dLat = lat - reference[j][0]
|
||||
const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180)
|
||||
const d = dLat * dLat + dLng * dLng
|
||||
if (d < nearest) nearest = d
|
||||
}
|
||||
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng } }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
|
||||
import { registerRtlTextPlugin } from '../rtlText'
|
||||
|
||||
/**
|
||||
* The only module in the client that pulls mapbox-gl in at runtime.
|
||||
@@ -8,4 +10,8 @@ import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
* a static import from anywhere in the tree drags 1.8 MB into the parent chunk and
|
||||
* quietly undoes the split. `npm run check:gl-split` fails on that.
|
||||
*/
|
||||
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
|
||||
// Mapbox's signature is (url, callback, deferred) — see rtlText.ts.
|
||||
registerRtlTextPlugin(() => mapboxgl.setRTLTextPlugin(rtlTextPluginUrl, null, true))
|
||||
|
||||
export default mapboxgl
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
|
||||
import { registerRtlTextPlugin } from '../rtlText'
|
||||
|
||||
/**
|
||||
* The only module in the client that pulls maplibre-gl in at runtime. Same rule as
|
||||
@@ -10,4 +12,8 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
* stay engine-agnostic. Where the two genuinely differ, the caller already guards
|
||||
* on the provider (projection, accessToken, aroundCenter).
|
||||
*/
|
||||
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
|
||||
// MapLibre's signature is (url, lazy) and it resolves a promise — see rtlText.ts.
|
||||
registerRtlTextPlugin(() => maplibregl.setRTLTextPlugin(rtlTextPluginUrl, true))
|
||||
|
||||
export default maplibregl
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* The two lines this fix actually adds. Everything else is a helper: the guard in
|
||||
* rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
|
||||
* working fix from the outside. Deleting either registration, swapping an
|
||||
* argument or dropping the deferred flag left the whole suite green.
|
||||
*
|
||||
* The engines are mocked because importing them for real builds a worker pool
|
||||
* under jsdom, and the plugin URL is mocked so the 133 KB payload never loads.
|
||||
*/
|
||||
const { mapboxSet, maplibreSet } = vi.hoisted(() => ({
|
||||
mapboxSet: vi.fn(),
|
||||
maplibreSet: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('mapbox-gl', () => ({ default: { setRTLTextPlugin: mapboxSet } }));
|
||||
vi.mock('maplibre-gl', () => ({ default: { setRTLTextPlugin: maplibreSet } }));
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
vi.mock('@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url', () => ({ default: '/assets/rtl-plugin.js' }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mapboxSet.mockReset();
|
||||
maplibreSet.mockReset();
|
||||
});
|
||||
|
||||
describe('the GL engines register the RTL text plugin (#2235)', () => {
|
||||
it('FE-MAP-RTL-001: importing the mapbox engine registers it deferred, with a null callback', async () => {
|
||||
await import('./mapbox');
|
||||
|
||||
expect(mapboxSet).toHaveBeenCalledTimes(1);
|
||||
// Mapbox GL 3 takes (url, callback, deferred). The third argument is what
|
||||
// keeps the payload off the wire until a map paints RTL text; dropping it
|
||||
// would fetch 133 KB for every user on every map.
|
||||
expect(mapboxSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), null, true);
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-002: importing the maplibre engine registers it lazily', async () => {
|
||||
await import('./maplibre');
|
||||
|
||||
expect(maplibreSet).toHaveBeenCalledTimes(1);
|
||||
// MapLibre 5 takes (url, lazy) and returns a promise, which is the reason
|
||||
// the call stays with each engine instead of going through one wrapper.
|
||||
expect(maplibreSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), true);
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-003: an engine still loads when its registration throws', async () => {
|
||||
mapboxSet.mockImplementation(() => { throw new Error('already registered'); });
|
||||
|
||||
const engine = await import('./mapbox');
|
||||
|
||||
expect(engine.default).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-004: a rejected registration promise does not surface as an unhandled rejection', async () => {
|
||||
maplibreSet.mockReturnValue(Promise.reject(new Error('already registered')));
|
||||
|
||||
const engine = await import('./maplibre');
|
||||
|
||||
expect(engine.default).toBeDefined();
|
||||
// Give the rejection a tick to go unhandled if nothing caught it.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
|
||||
/**
|
||||
* jsdom has no drag implementation, so these drive the events the browser would
|
||||
@@ -82,3 +82,93 @@ describe('makeMarkerDraggable', () => {
|
||||
expect(window.__dragData).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('makePoiDraggable', () => {
|
||||
afterEach(() => {
|
||||
window.__dragData = null
|
||||
document.body.classList.remove('dragging-poi')
|
||||
})
|
||||
|
||||
const dragEvent = (type: string) => {
|
||||
const e = new Event(type, { bubbles: true, cancelable: true }) as DragEvent & { dataTransfer: DataTransfer }
|
||||
const store: Record<string, string> = {}
|
||||
Object.defineProperty(e, 'dataTransfer', {
|
||||
value: {
|
||||
setData: (k: string, v: string) => { store[k] = v },
|
||||
getData: (k: string) => store[k] ?? '',
|
||||
effectAllowed: '',
|
||||
dropEffect: '',
|
||||
},
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
it('FE-MAP-POIDRAG-001: a hit carries its OSM id, not a place id — it is not a place yet', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
expect(el.getAttribute('draggable')).toBe('true')
|
||||
|
||||
const e = dragEvent('dragstart')
|
||||
el.dispatchEvent(e)
|
||||
|
||||
expect(e.dataTransfer.getData('poiOsmId')).toBe('node:9')
|
||||
// Mirrored on window because dataTransfer is unreadable during dragover, and the
|
||||
// map has to know what is coming while it is still being hovered.
|
||||
expect(window.__dragData).toEqual({ poiOsmId: 'node:9' })
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-002: the drag marks the body, so the route can light up while it lasts', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
|
||||
el.dispatchEvent(dragEvent('dragstart'))
|
||||
expect(document.body.classList.contains('dragging-poi')).toBe(true)
|
||||
|
||||
el.dispatchEvent(dragEvent('dragend'))
|
||||
expect(document.body.classList.contains('dragging-poi')).toBe(false)
|
||||
expect(window.__dragData).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-003: mousedown is swallowed, or the map pans out from under the drag', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
|
||||
const seen = vi.fn()
|
||||
document.body.appendChild(el)
|
||||
document.body.addEventListener('mousedown', seen)
|
||||
|
||||
el.dispatchEvent(down)
|
||||
|
||||
expect(seen).not.toHaveBeenCalled()
|
||||
document.body.removeEventListener('mousedown', seen)
|
||||
el.remove()
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-004: unwiring leaves the element as it was found', () => {
|
||||
const el = document.createElement('div')
|
||||
const off = makePoiDraggable(el, 'node:9')
|
||||
off()
|
||||
|
||||
expect(el.hasAttribute('draggable')).toBe(false)
|
||||
el.dispatchEvent(dragEvent('dragstart'))
|
||||
expect(window.__dragData).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-005: the id is readable from the event, and from the mirror during a hover', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
const start = dragEvent('dragstart')
|
||||
el.dispatchEvent(start)
|
||||
|
||||
expect(draggedPoiId(start)).toBe('node:9')
|
||||
|
||||
// A dragover event carries an unreadable dataTransfer; the mirror answers instead.
|
||||
const over = new Event('dragover') as DragEvent
|
||||
expect(draggedPoiId(over)).toBe('node:9')
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-006: an unrelated drag is not mistaken for a hit', () => {
|
||||
const over = new Event('dragover') as DragEvent
|
||||
expect(draggedPoiId(over)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,3 +48,51 @@ export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => voi
|
||||
el.removeEventListener('mousedown', onMouseDown)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a corridor hit up as a drag source, so it can be dropped onto the drive.
|
||||
*
|
||||
* The same HTML5 drag as `makeMarkerDraggable`, with the OSM id as payload instead of a
|
||||
* place id — a corridor hit is not a place yet. The drop target is the map itself rather
|
||||
* than the drawn line: a route polyline is a real element only in Leaflet (the GL
|
||||
* renderers draw it into the canvas), and the drop coordinate answers "where on the drive"
|
||||
* just as well once it is projected onto the routed geometry.
|
||||
*/
|
||||
export function makePoiDraggable(el: HTMLElement, osmId: string): () => void {
|
||||
el.setAttribute('draggable', 'true')
|
||||
|
||||
const onDragStart = (e: DragEvent) => {
|
||||
e.dataTransfer?.setData('poiOsmId', osmId)
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
|
||||
window.__dragData = { poiOsmId: osmId }
|
||||
el.classList.add('marker-dragging')
|
||||
document.body.classList.add('dragging-poi')
|
||||
}
|
||||
const onDragEnd = () => {
|
||||
window.__dragData = null
|
||||
el.classList.remove('marker-dragging')
|
||||
document.body.classList.remove('dragging-poi')
|
||||
}
|
||||
// Same reason as above: without this the map pans out from under the pointer as the
|
||||
// drag begins, and the route is impossible to aim at.
|
||||
const onMouseDown = (e: MouseEvent) => e.stopPropagation()
|
||||
|
||||
el.addEventListener('dragstart', onDragStart)
|
||||
el.addEventListener('dragend', onDragEnd)
|
||||
el.addEventListener('mousedown', onMouseDown)
|
||||
|
||||
return () => {
|
||||
el.removeAttribute('draggable')
|
||||
el.removeEventListener('dragstart', onDragStart)
|
||||
el.removeEventListener('dragend', onDragEnd)
|
||||
el.removeEventListener('mousedown', onMouseDown)
|
||||
}
|
||||
}
|
||||
|
||||
/** The OSM id being dragged, when a corridor hit is in flight. */
|
||||
export function draggedPoiId(e: DragEvent): string | null {
|
||||
const fromTransfer = e.dataTransfer?.getData('poiOsmId')
|
||||
if (fromTransfer) return fromTransfer
|
||||
// dataTransfer is unreadable during dragover, so the window mirror answers instead.
|
||||
return window.__dragData?.poiOsmId ?? null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, type LucideIcon } from 'lucide-react'
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, type LucideIcon } from 'lucide-react'
|
||||
// The road-trip kinds take their colour from the one place that defines it, so the
|
||||
// search result, the map pin and the road-trip rail cannot drift apart.
|
||||
import { SERVICE_COLORS } from '../Roadtrip/roadtripModel'
|
||||
import { CORRIDOR_CATEGORIES, HOTEL_COLOR } from '../Roadtrip/stopKinds'
|
||||
|
||||
// The POI categories shown in the map "explore" pill. The `key` is the contract
|
||||
// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping
|
||||
@@ -15,15 +19,27 @@ export const POI_CATEGORIES: PoiCategory[] = [
|
||||
{ key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' },
|
||||
{ key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' },
|
||||
{ key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' },
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' },
|
||||
// The colour lives with the road-trip categories, because the corridor search offers
|
||||
// the same one and two definitions of a blue is how they drift apart.
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR },
|
||||
{ key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' },
|
||||
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
|
||||
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
|
||||
{ key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' },
|
||||
]
|
||||
|
||||
/**
|
||||
* What a drive needs. Kept out of POI_CATEGORIES on purpose: these are not offered by
|
||||
* the explore pill, they are what the road trip corridor searches for. They are in the
|
||||
* lookup below all the same, so a hit drawn on the map gets its own icon and colour
|
||||
* instead of the nameless grey dot an unknown category falls back to.
|
||||
*/
|
||||
export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = CORRIDOR_CATEGORIES.map(
|
||||
({ key, labelKey, Icon, color }) => ({ key, labelKey, Icon, color }),
|
||||
)
|
||||
|
||||
export const POI_CATEGORY_BY_KEY: Record<string, PoiCategory> = Object.fromEntries(
|
||||
POI_CATEGORIES.map(c => [c.key, c]),
|
||||
[...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c]),
|
||||
)
|
||||
|
||||
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
|
||||
@@ -34,10 +50,25 @@ export interface Poi {
|
||||
lng: number
|
||||
category: string
|
||||
poi_type: string
|
||||
/** Brand name and its Wikidata id, when OSM carries them (road categories mostly do). */
|
||||
brand?: string | null
|
||||
brand_wikidata?: string | null
|
||||
address: string | null
|
||||
website: string | null
|
||||
phone: string | null
|
||||
opening_hours: string | null
|
||||
cuisine: string | null
|
||||
/**
|
||||
* What a charging station offers, when it is one and OSM says so.
|
||||
*
|
||||
* Optional twice over: only charging hits carry it at all, and OSM knows a socket type
|
||||
* for roughly a third of them, a capacity for about seven in ten. "Not stated" is its
|
||||
* own answer here and must not be read as a no.
|
||||
*/
|
||||
charging?: {
|
||||
sockets: { type: string; count: number | null; kw: number | null }[]
|
||||
capacity: number | null
|
||||
fee: boolean | null
|
||||
} | null
|
||||
source: 'openstreetmap'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { registerRtlTextPlugin } from './rtlText'
|
||||
|
||||
describe('registerRtlTextPlugin', () => {
|
||||
it('runs the engine-supplied registration', () => {
|
||||
const register = vi.fn()
|
||||
|
||||
expect(registerRtlTextPlugin(register)).toBe(true)
|
||||
expect(register).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Registering twice is how the engines report it: Mapbox throws.
|
||||
it('never lets a refused registration break map creation', () => {
|
||||
const register = vi.fn(() => {
|
||||
throw new Error('setRTLTextPlugin cannot be called multiple times.')
|
||||
})
|
||||
|
||||
expect(() => registerRtlTextPlugin(register)).not.toThrow()
|
||||
expect(registerRtlTextPlugin(register)).toBe(false)
|
||||
})
|
||||
|
||||
// MapLibre reports the same by rejecting, and an unhandled rejection would
|
||||
// reach the console for something the user cannot act on.
|
||||
it('swallows a rejected registration promise', async () => {
|
||||
const onUnhandled = vi.fn()
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
|
||||
expect(registerRtlTextPlugin(() => Promise.reject(new Error('404')))).toBe(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
|
||||
expect(onUnhandled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a resolved promise alone', async () => {
|
||||
expect(registerRtlTextPlugin(() => Promise.resolve())).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Arabic, Hebrew, Persian and Urdu labels need two things a GL engine does not do
|
||||
* on its own: the bidirectional algorithm, and Arabic contextual shaping (a letter
|
||||
* is drawn differently depending on its neighbours). Without them the engine lays
|
||||
* the codepoints out one by one, left to right, in isolated forms — so القاهرة
|
||||
* comes out as ة ر ه ا ق ل ا, unjoined and reversed. Raster basemaps never showed
|
||||
* this because their labels are drawn server-side, which is why it only surfaced
|
||||
* once the default basemap became a vector style.
|
||||
*
|
||||
* Both engines expose the fix as an optional plugin and neither loads it by
|
||||
* default. Registering is global to the engine, so it happens once per engine
|
||||
* module rather than at each map's construction site.
|
||||
*
|
||||
* The call itself stays with the caller: this is one of the places the two engines
|
||||
* genuinely differ. MapLibre 5 takes `(url, lazy)` and returns a promise, Mapbox
|
||||
* GL 3 takes `(url, callback, deferred)` and returns nothing — so each engine
|
||||
* module passes its own invocation rather than a shared wrapper guessing an arity.
|
||||
*
|
||||
* Deliberately no getRTLTextPluginStatus() pre-check: reading it makes MapLibre
|
||||
* build its worker pool, which is an eager side effect for a module that is
|
||||
* imported for its default export, and throws outright under jsdom. Registering
|
||||
* twice is reported — by a throw or a rejected promise depending on the engine —
|
||||
* and both are handled below, so the probe bought nothing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Runs a GL engine's RTL-plugin registration, swallowing the ways it can fail.
|
||||
*
|
||||
* Callers register lazily, so the payload is only fetched once a map actually
|
||||
* paints RTL text and a user who never leaves Latin script downloads nothing.
|
||||
*
|
||||
* @returns whether the registration call completed without throwing.
|
||||
*/
|
||||
export function registerRtlTextPlugin(register: () => void | Promise<unknown>): boolean {
|
||||
try {
|
||||
const pending = register()
|
||||
// MapLibre reports a failed or duplicate registration by rejecting; Mapbox
|
||||
// routes the same through its callback. Neither is worth surfacing: a map
|
||||
// with unshaped labels still renders, and an unhandled rejection would not
|
||||
// give the user anything to act on.
|
||||
if (pending instanceof Promise) pending.catch(() => {})
|
||||
return true
|
||||
} catch {
|
||||
// Already registered, or the engine refused outright. Registering the plugin
|
||||
// must never be able to break map creation.
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The list both map renderers rebuild their via handles from.
|
||||
*
|
||||
* Its identity is the whole contract: the GL renderer replaces the DOM element when this
|
||||
* changes, and a pointerdown belongs to the element it happened on. A list that changed
|
||||
* on every render meant a freshly placed via lost its first drag to the re-route landing
|
||||
* underneath it.
|
||||
*/
|
||||
const via = (over: Partial<RoadtripVia> & { id: number }): RoadtripVia => ({
|
||||
day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1, ...over,
|
||||
})
|
||||
|
||||
describe('useStableVias', () => {
|
||||
it('FE-VIASTATE-001: the same vias in a fresh object are the same list', () => {
|
||||
// Exactly what a reload hands down: identical rows, a new object every time.
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
|
||||
)
|
||||
const first = result.current
|
||||
|
||||
rerender({ byDay: { 1: [via({ id: 5 })] } })
|
||||
expect(result.current).toBe(first)
|
||||
})
|
||||
|
||||
it('FE-VIASTATE-002: a via that moved is a new list, so the handle is redrawn where it now is', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
|
||||
)
|
||||
const first = result.current
|
||||
|
||||
rerender({ byDay: { 1: [via({ id: 5, lat: 48.9 })] } })
|
||||
expect(result.current).not.toBe(first)
|
||||
expect(result.current[0].lat).toBe(48.9)
|
||||
})
|
||||
|
||||
it('FE-VIASTATE-003: adding or removing one is a new list too', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
|
||||
)
|
||||
const first = result.current
|
||||
|
||||
rerender({ byDay: { 1: [via({ id: 5 }), via({ id: 6, lat: 48.4 })] } })
|
||||
expect(result.current).not.toBe(first)
|
||||
expect(result.current).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-VIASTATE-004: every day of the trip is in one list, and no vias is a stable empty one', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })], 2: [via({ id: 6, day_id: 2 })] } as Record<number, RoadtripVia[]> | undefined },
|
||||
})
|
||||
expect(result.current.map(v => v.id)).toEqual([5, 6])
|
||||
|
||||
rerender({ byDay: undefined })
|
||||
const empty = result.current
|
||||
expect(empty).toEqual([])
|
||||
rerender({ byDay: {} })
|
||||
expect(result.current).toBe(empty)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useRef } from 'react'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The trip's via points as an array that only changes when a via does.
|
||||
*
|
||||
* Both map renderers rebuild their via handles whenever this list changes, and the GL one
|
||||
* replaces the DOM element outright. The list it was given changed on every render: the
|
||||
* hook that loads vias returns a fresh object each time, and a fresh object arrives again
|
||||
* on every reload even when the rows came back identical.
|
||||
*
|
||||
* That made dragging a freshly placed via fail on the first try. Placing one triggers a
|
||||
* re-route, the re-route lands about a second later, the render it causes replaced the
|
||||
* handle under the pointer — and a pointerdown belongs to the element it happened on, so
|
||||
* the drag went to an element that no longer existed. The second attempt worked because
|
||||
* by then nothing was pending. An older via never showed it: nothing was re-routing.
|
||||
*
|
||||
* Keyed by id and position because those are the only fields a handle is built from. A
|
||||
* via that genuinely moved gets a new list, which is what redraws it where it now is.
|
||||
*/
|
||||
export function useStableVias(viasByDay: Record<number, RoadtripVia[]> | undefined): RoadtripVia[] {
|
||||
const flat = Object.values(viasByDay ?? {}).flat()
|
||||
const key = flat.map(v => `${v.id}@${v.lat},${v.lng}`).join('|')
|
||||
// Read and written during render on purpose: this is a cache of the argument, not
|
||||
// state — it never triggers a render of its own, and it has to be settled before the
|
||||
// effects that consume the list run.
|
||||
const held = useRef<{ key: string; list: RoadtripVia[] }>({ key: '', list: [] })
|
||||
if (held.current.key !== key) held.current = { key, list: flat }
|
||||
return held.current.list
|
||||
}
|
||||
@@ -223,7 +223,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
const allCostEntries: MoneyEntry[] = Object.values(assignments)
|
||||
.flatMap(a => a)
|
||||
.map(a => ({ amount: Number(a.place?.price) || 0, currency: a.place?.currency || tripCur }))
|
||||
const needsFx = allCostEntries.some(e => e.amount > 0 && e.currency.toUpperCase() !== tripCur)
|
||||
const needsFx = allCostEntries.some(e => e.amount !== 0 && e.currency.toUpperCase() !== tripCur)
|
||||
const fxRates = needsFx ? await fetchExchangeRates(tripCur) : null
|
||||
const totalCostLabel = formatMoneySum(allCostEntries, tripCur, loc || 'en', fxRates)
|
||||
|
||||
@@ -353,6 +353,22 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'car') {
|
||||
// A rental with stops is a drive, and the printout is what people take
|
||||
// into the car (#1797). Without this the route reads as pick-up and return
|
||||
// with everything in between missing.
|
||||
//
|
||||
// Consecutive repeats collapse, and a chain that names one place only is
|
||||
// dropped: the ordinary rental is picked up and returned at the same desk,
|
||||
// which satisfies "two endpoints" and would otherwise print the counter's
|
||||
// name twice with an arrow between. A genuine round trip keeps both ends,
|
||||
// because Dresden sits between them.
|
||||
const stops = (r.endpoints || []).slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
.map(e => e.name)
|
||||
.filter((name, i, all) => name && name !== all[i - 1])
|
||||
subtitle = new Set(stops).size >= 2 ? stops.join(' → ') : ''
|
||||
}
|
||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
@@ -442,6 +458,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
|
||||
${chips ? `<div class="chips">${chips}</div>` : ''}
|
||||
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
||||
${item.data.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(item.data.notes)}</span></div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { X, Plus } from 'lucide-react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
import { bagFillPct, countsTowardsMyLoad, itemWeight } from './packingListPanel.helpers'
|
||||
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, unassignedTotalWeight } from './packingListPanel.helpers'
|
||||
import { BagCard } from './PackingListPanelBagCard'
|
||||
|
||||
export function BagModal(S: PackingState) {
|
||||
const {
|
||||
setShowBagModal, t, bags, items, tripId, tripMembers, canEdit, currentUserId, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag,
|
||||
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag, unassignedWeightGrams, serverWeightsFresh,
|
||||
} = S
|
||||
// These numbers describe what you are carrying. An item someone shared with you stays
|
||||
// in your list, but they are the one bringing it, so it is not your weight (#1767).
|
||||
// The ITEM LISTS still describe what you are carrying — an item someone shared
|
||||
// with you stays in your list, but they are the one bringing it (#1767).
|
||||
const myItems = items.filter(i => countsTowardsMyLoad(i, currentUserId))
|
||||
// The WEIGHTS no longer do. A bag's load is the bag's, whoever packed it and
|
||||
// whether or not you may see the items, so it comes from the server (#2191).
|
||||
const bagWeightOf = (bag: typeof bags[number]) =>
|
||||
bagTotalWeight(bag, myItems.filter(i => i.bag_id === bag.id), serverWeightsFresh)
|
||||
// Reference for bags without a limit of their own — computed once instead of per bag.
|
||||
const heaviestBagWeight = Math.max(...bags.map(b => myItems.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
|
||||
const heaviestBagWeight = Math.max(...bags.map(bagWeightOf), 1)
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
|
||||
role="button" tabIndex={0} aria-label={t('common.close')}
|
||||
@@ -30,7 +34,7 @@ export function BagModal(S: PackingState) {
|
||||
|
||||
{bags.map(bag => {
|
||||
const bagItems = myItems.filter(i => i.bag_id === bag.id)
|
||||
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||
const totalWeight = bagWeightOf(bag)
|
||||
const pct = bagFillPct(totalWeight, bag.weight_limit_grams, heaviestBagWeight)
|
||||
return (
|
||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
|
||||
@@ -40,8 +44,11 @@ export function BagModal(S: PackingState) {
|
||||
{/* Unassigned */}
|
||||
{(() => {
|
||||
const unassigned = myItems.filter(i => !i.bag_id)
|
||||
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
|
||||
if (unassigned.length === 0) return null
|
||||
const unassignedWeight = unassignedTotalWeight(unassignedWeightGrams, unassigned, serverWeightsFresh)
|
||||
// Shown whenever there is weight to account for, even with no visible
|
||||
// items: the grand total counts it, and a total nothing adds up to is
|
||||
// the confusion this issue was about (#2191).
|
||||
if (unassigned.length === 0 && unassignedWeight === 0) return null
|
||||
return (
|
||||
<div style={{ marginBottom: 16, opacity: 0.6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
@@ -60,7 +67,13 @@ export function BagModal(S: PackingState) {
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
<span>{t('packing.totalWeight')}</span>
|
||||
<span>{(() => { const w = myItems.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||
<span>{(() => {
|
||||
// Same rule as the rows above it: a grand total mixing true bag
|
||||
// weights with a per-viewer remainder would be worse than either.
|
||||
const w = bags.reduce((s, b) => s + bagWeightOf(b), 0)
|
||||
+ unassignedTotalWeight(unassignedWeightGrams, myItems.filter(i => !i.bag_id), serverWeightsFresh)
|
||||
return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g`
|
||||
})()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
import { bagFillPct, countsTowardsMyLoad, itemWeight } from './packingListPanel.helpers'
|
||||
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, unassignedTotalWeight } from './packingListPanel.helpers'
|
||||
import { BagCard } from './PackingListPanelBagCard'
|
||||
|
||||
export function BagSidebar(S: PackingState) {
|
||||
const {
|
||||
t, bags, items, tripId, tripMembers, canEdit, currentUserId, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag,
|
||||
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag, unassignedWeightGrams, serverWeightsFresh,
|
||||
} = S
|
||||
// These numbers describe what you are carrying. An item someone shared with you stays
|
||||
// in your list, but they are the one bringing it, so it is not your weight (#1767).
|
||||
// The ITEM LISTS still describe what you are carrying — an item someone shared
|
||||
// with you stays in your list, but they are the one bringing it (#1767).
|
||||
const myItems = items.filter(i => countsTowardsMyLoad(i, currentUserId))
|
||||
// The WEIGHTS no longer do: a bag's load is the bag's, whoever packed it (#2191).
|
||||
const bagWeightOf = (bag: typeof bags[number]) =>
|
||||
bagTotalWeight(bag, myItems.filter(i => i.bag_id === bag.id), serverWeightsFresh)
|
||||
// Reference for bags without a limit of their own — computed once instead of per bag.
|
||||
const heaviestBagWeight = Math.max(...bags.map(b => myItems.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
|
||||
const heaviestBagWeight = Math.max(...bags.map(bagWeightOf), 1)
|
||||
return (
|
||||
<div className="hidden xl:block" style={{ width: 260, marginLeft: 16, borderLeft: '1px solid var(--border-secondary)', overflowY: 'auto', padding: 16, flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-faint)', marginBottom: 12 }}>
|
||||
@@ -21,7 +24,7 @@ export function BagSidebar(S: PackingState) {
|
||||
|
||||
{bags.map(bag => {
|
||||
const bagItems = myItems.filter(i => i.bag_id === bag.id)
|
||||
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||
const totalWeight = bagWeightOf(bag)
|
||||
const pct = bagFillPct(totalWeight, bag.weight_limit_grams, heaviestBagWeight)
|
||||
return (
|
||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
|
||||
@@ -31,8 +34,10 @@ export function BagSidebar(S: PackingState) {
|
||||
{/* Unassigned */}
|
||||
{(() => {
|
||||
const unassigned = myItems.filter(i => !i.bag_id)
|
||||
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
|
||||
if (unassigned.length === 0) return null
|
||||
const unassignedWeight = unassignedTotalWeight(unassignedWeightGrams, unassigned, serverWeightsFresh)
|
||||
// Shown whenever there is weight to account for, even with no visible
|
||||
// items — the grand total counts it either way (#2191).
|
||||
if (unassigned.length === 0 && unassignedWeight === 0) return null
|
||||
return (
|
||||
<div style={{ marginBottom: 14, opacity: 0.6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
@@ -51,7 +56,12 @@ export function BagSidebar(S: PackingState) {
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
<span>{t('packing.totalWeight')}</span>
|
||||
<span>{(() => { const w = myItems.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||
<span>{(() => {
|
||||
// Same rule as the rows above it (#2191).
|
||||
const w = bags.reduce((s, b) => s + bagWeightOf(b), 0)
|
||||
+ unassignedTotalWeight(unassignedWeightGrams, myItems.filter(i => !i.bag_id), serverWeightsFresh)
|
||||
return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g`
|
||||
})()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// FE-COMP-BAGWEIGHT-001 to FE-COMP-BAGWEIGHT-006
|
||||
|
||||
/**
|
||||
* What the bag panels actually print (#2191).
|
||||
*
|
||||
* The numbers used to be summed in the browser over the privacy-filtered item
|
||||
* list, so a bag shared with someone whose personal items you cannot see read
|
||||
* lighter than it was — and that figure is what gets measured against an
|
||||
* airline's weight limit. The server now sends the real total; these tests pin
|
||||
* that the panels show it, that the "no bag" pile and the grand total follow
|
||||
* the same rule, and that offline they fall back to the honest partial sum
|
||||
* rather than a frozen absolute one.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, within } from '../../../tests/helpers/render'
|
||||
import { BagSidebar } from './PackingListPanelBagSidebar'
|
||||
import { BagModal } from './PackingListPanelBagModal'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
|
||||
/** My own 300 g item; the buddy's 700 g personal item never reaches this client. */
|
||||
const ITEMS = [
|
||||
{ id: 1, name: 'Stove', weight_grams: 300, quantity: 1, bag_id: 10, is_private: 0, owner_id: 1, checked: 0, category: 'Kitchen' },
|
||||
{ id: 2, name: 'Sandwich', weight_grams: 150, quantity: 1, bag_id: null, is_private: 0, owner_id: 1, checked: 0, category: 'Food' },
|
||||
]
|
||||
|
||||
function buildState(over: Partial<PackingState> = {}): PackingState {
|
||||
return {
|
||||
t: (k: string) => k,
|
||||
bags: [{ id: 10, trip_id: 1, name: 'Duffel', color: '#6366f1', sort_order: 0, weight_limit_grams: null, members: [], total_weight_grams: 1000 }],
|
||||
items: ITEMS,
|
||||
tripId: 1,
|
||||
tripMembers: [],
|
||||
canEdit: false,
|
||||
currentUserId: 1,
|
||||
unassignedWeightGrams: 500,
|
||||
serverWeightsFresh: true,
|
||||
handleDeleteBag: vi.fn(),
|
||||
handleUpdateBag: vi.fn(),
|
||||
handleSetBagMembers: vi.fn(),
|
||||
handleCreateBag: vi.fn(),
|
||||
setShowBagModal: vi.fn(),
|
||||
showAddBag: false,
|
||||
setShowAddBag: vi.fn(),
|
||||
newBagName: '',
|
||||
setNewBagName: vi.fn(),
|
||||
...over,
|
||||
} as unknown as PackingState
|
||||
}
|
||||
|
||||
describe('bag weights on the packing panels (#2191)', () => {
|
||||
it('FE-COMP-BAGWEIGHT-001: the sidebar shows the server total, not the sum of what you can see', () => {
|
||||
// Locally visible: 300 g. Actually in the bag: 1000 g.
|
||||
render(<BagSidebar {...buildState()} />)
|
||||
expect(screen.getByText('1.0 kg')).toBeInTheDocument()
|
||||
expect(screen.queryByText('300 g')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGWEIGHT-002: the grand total adds the bags and the unassigned pile', () => {
|
||||
// 1000 g in the bag + 500 g loose.
|
||||
const { container } = render(<BagSidebar {...buildState()} />)
|
||||
const total = within(container).getByText('packing.totalWeight').parentElement as HTMLElement
|
||||
expect(total).toHaveTextContent('1.5 kg')
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGWEIGHT-003: the modal follows the same rule as the sidebar', () => {
|
||||
render(<BagModal {...buildState()} />)
|
||||
expect(screen.getByText('1.0 kg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGWEIGHT-004: offline it falls back to summing what is visible', () => {
|
||||
// The server totals are frozen at the last online read and blind to the
|
||||
// mutation queue, so an honest partial number beats a stale absolute one.
|
||||
render(<BagSidebar {...buildState({ serverWeightsFresh: false })} />)
|
||||
expect(screen.getByText('300 g')).toBeInTheDocument()
|
||||
expect(screen.queryByText('1.0 kg')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGWEIGHT-005: a bag from before the field falls back to the local sum', () => {
|
||||
const bags = [{ id: 10, trip_id: 1, name: 'Duffel', color: '#6366f1', sort_order: 0, weight_limit_grams: null, members: [] }]
|
||||
render(<BagSidebar {...buildState({ bags: bags as never, unassignedWeightGrams: null })} />)
|
||||
expect(screen.getByText('300 g')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGWEIGHT-006: the unassigned row appears for weight with no visible items', () => {
|
||||
// Someone else's personal item is loose in the trip: the grand total counts
|
||||
// it, so a row has to account for it or the total adds up to nothing.
|
||||
render(<BagSidebar {...buildState({ items: [ITEMS[0]] as never, unassignedWeightGrams: 400 })} />)
|
||||
expect(screen.getByText('packing.noBag')).toBeInTheDocument()
|
||||
expect(screen.getByText('400 g')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { katColor, itemWeight, bagFillPct, countsTowardsMyLoad, parseCsvLine, parseImportLines } from './packingListPanel.helpers'
|
||||
import { katColor, itemWeight, bagFillPct, bagTotalWeight, countsTowardsMyLoad, parseCsvLine, parseImportLines, unassignedTotalWeight } from './packingListPanel.helpers'
|
||||
import { KAT_COLORS } from './packingListPanel.constants'
|
||||
|
||||
describe('packingListPanel.helpers', () => {
|
||||
@@ -128,4 +128,29 @@ describe('packingListPanel.helpers', () => {
|
||||
expect(rows[0].name).toBe('Passport')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bagTotalWeight / unassignedTotalWeight (#2191)', () => {
|
||||
it('prefers the server total over anything summable locally', () => {
|
||||
// The whole point: the local list is privacy-filtered, so it can only ever
|
||||
// be the part of the bag this viewer is allowed to see.
|
||||
expect(bagTotalWeight({ total_weight_grams: 1000 }, [{ weight_grams: 800, quantity: 1 }])).toBe(1000)
|
||||
})
|
||||
|
||||
it('keeps a server-reported zero instead of falling back to the local sum', () => {
|
||||
// An empty bag really weighs 0; only an ABSENT field means "not told".
|
||||
expect(bagTotalWeight({ total_weight_grams: 0 }, [{ weight_grams: 800, quantity: 1 }])).toBe(0)
|
||||
})
|
||||
|
||||
it('falls back to the local sum for a bag cached before the field existed', () => {
|
||||
expect(bagTotalWeight({}, [{ weight_grams: 250, quantity: 3 }, { weight_grams: 50 }])).toBe(800)
|
||||
expect(bagTotalWeight({ total_weight_grams: null }, [{ weight_grams: 120 }])).toBe(120)
|
||||
})
|
||||
|
||||
it('applies the same rule to the unassigned pile', () => {
|
||||
expect(unassignedTotalWeight(150, [{ weight_grams: 900 }])).toBe(150)
|
||||
expect(unassignedTotalWeight(0, [{ weight_grams: 900 }])).toBe(0)
|
||||
expect(unassignedTotalWeight(null, [{ weight_grams: 900 }])).toBe(900)
|
||||
expect(unassignedTotalWeight(undefined, [])).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,43 @@ export const countsTowardsMyLoad = (
|
||||
return i.owner_id == null || i.owner_id === currentUserId
|
||||
}
|
||||
|
||||
/**
|
||||
* What a bag weighs, as shown (#2191).
|
||||
*
|
||||
* The server sums every member's items — including the private ones this viewer
|
||||
* may not see — and sends the figure on the bag itself. Adding it up locally
|
||||
* could only ever produce the part of the bag the viewer is allowed to look at,
|
||||
* which is the wrong number to measure against an airline's weight limit.
|
||||
*
|
||||
* The local sum is the fallback in two cases: a bag that reached the client
|
||||
* without the field (a row cached before #2191), and `serverFresh === false`,
|
||||
* which the surfaces pass while offline — bags have no repo and no Dexie table,
|
||||
* so their totals are frozen at the last online read and blind to whatever the
|
||||
* mutation queue is holding. A stale absolute number measured against an
|
||||
* airline limit is worse than an honest partial one.
|
||||
*
|
||||
* Note the explicit null check rather than `??` on a falsy value: an empty bag
|
||||
* legitimately weighs 0 and must not fall through to the local sum.
|
||||
*/
|
||||
export const bagTotalWeight = (
|
||||
bag: { total_weight_grams?: number | null },
|
||||
visibleItems: { weight_grams?: number | null; quantity?: number | null }[],
|
||||
serverFresh = true,
|
||||
): number =>
|
||||
serverFresh && bag.total_weight_grams != null
|
||||
? bag.total_weight_grams
|
||||
: visibleItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||
|
||||
/** The same rule for the pile that is in no bag (#2191). */
|
||||
export const unassignedTotalWeight = (
|
||||
serverTotal: number | null | undefined,
|
||||
visibleItems: { weight_grams?: number | null; quantity?: number | null }[],
|
||||
serverFresh = true,
|
||||
): number =>
|
||||
serverFresh && serverTotal != null
|
||||
? serverTotal
|
||||
: visibleItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||
|
||||
/**
|
||||
* How full a bag's bar reads. A bag with a weight limit is measured against that limit —
|
||||
* that is the number an airline cares about. Without one there is nothing absolute to
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// FE-COMP-BAGPING-001 to FE-COMP-BAGPING-009
|
||||
|
||||
/**
|
||||
* The bag-totals ping listener (#2191).
|
||||
*
|
||||
* Bag weights are summed server-side across every member, so the event that
|
||||
* says they moved cannot carry the new numbers without leaking what produced
|
||||
* them. That makes every ping a refetch, and the server deliberately sends it
|
||||
* to the whole room including the writer — so the coalescing and the in-flight
|
||||
* guard here are what stop a busy trip turning one write into a storm of
|
||||
* listBags requests on every connected client.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useBagTotalsPing } from './useBagTotalsPing'
|
||||
import { setForcedOffline, _resetNetworkMode } from '../../sync/networkMode'
|
||||
|
||||
const listeners = new Set<(e: Record<string, unknown>) => void>()
|
||||
|
||||
vi.mock('../../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 })
|
||||
}
|
||||
|
||||
/** Past the 250ms coalescing window. */
|
||||
async function flushCoalesce(): Promise<void> {
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(300) })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
listeners.clear()
|
||||
_resetNetworkMode()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setForcedOffline(false)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useBagTotalsPing', () => {
|
||||
it('FE-COMP-BAGPING-001: reloads once a ping arrives', async () => {
|
||||
const reload = vi.fn(async () => {})
|
||||
renderHook(() => useBagTotalsPing(true, reload))
|
||||
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
act(() => emit('packing:bag-totals'))
|
||||
await flushCoalesce()
|
||||
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-002: folds a burst of pings into a single refetch', async () => {
|
||||
// Clearing twelve checked items issues twelve deletes; an import writes a
|
||||
// whole list. Each one pings the room.
|
||||
const reload = vi.fn(async () => {})
|
||||
renderHook(() => useBagTotalsPing(true, reload))
|
||||
|
||||
act(() => { for (let i = 0; i < 12; i++) emit('packing:bag-totals') })
|
||||
await flushCoalesce()
|
||||
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-003: never overlaps two refetches, and serves a ping that arrived mid-flight', async () => {
|
||||
let release: (() => void) | undefined
|
||||
const reload = vi.fn(() => new Promise<void>(resolve => { release = resolve }))
|
||||
renderHook(() => useBagTotalsPing(true, reload))
|
||||
|
||||
act(() => emit('packing:bag-totals'))
|
||||
await flushCoalesce()
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A second burst while the first request is still open must not start a
|
||||
// parallel one...
|
||||
act(() => emit('packing:bag-totals'))
|
||||
await flushCoalesce()
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
|
||||
// ...but it must not be dropped either: the totals moved again.
|
||||
await act(async () => { release?.(); await Promise.resolve() })
|
||||
expect(reload).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-004: ignores every other event type', async () => {
|
||||
const reload = vi.fn(async () => {})
|
||||
renderHook(() => useBagTotalsPing(true, reload))
|
||||
|
||||
act(() => { emit('packing:created'); emit('packing:updated'); emit('place:created') })
|
||||
await flushCoalesce()
|
||||
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-005: subscribes to nothing while bag tracking is off', async () => {
|
||||
const reload = vi.fn(async () => {})
|
||||
renderHook(() => useBagTotalsPing(false, reload))
|
||||
|
||||
expect(listeners.size).toBe(0)
|
||||
act(() => emit('packing:bag-totals'))
|
||||
await flushCoalesce()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-006: unsubscribes on unmount and does not reload afterwards', async () => {
|
||||
const reload = vi.fn(async () => {})
|
||||
const { unmount } = renderHook(() => useBagTotalsPing(true, reload))
|
||||
expect(listeners.size).toBe(1)
|
||||
|
||||
// A ping already collecting when the panel closes must not land on a
|
||||
// component that is gone.
|
||||
act(() => emit('packing:bag-totals'))
|
||||
unmount()
|
||||
await flushCoalesce()
|
||||
|
||||
expect(listeners.size).toBe(0)
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-007: reloads when the socket re-joins the room', async () => {
|
||||
// A ping sent while the socket was down is gone for good (a container
|
||||
// restart, a tunnel that dropped), and bags have no offline cache to catch
|
||||
// up from. The re-join is the only notice this client gets.
|
||||
const reload = vi.fn(async () => {})
|
||||
renderHook(() => useBagTotalsPing(true, reload))
|
||||
|
||||
act(() => emit('joined'))
|
||||
await flushCoalesce()
|
||||
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-008: reloads on the way back online, not on the way out', async () => {
|
||||
const reload = vi.fn(async () => {})
|
||||
renderHook(() => useBagTotalsPing(true, reload))
|
||||
|
||||
act(() => setForcedOffline(true))
|
||||
await flushCoalesce()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
|
||||
// Going online is where the surfaces stop summing locally and show the
|
||||
// server totals again, so those totals had better not be the pre-outage ones.
|
||||
act(() => setForcedOffline(false))
|
||||
await flushCoalesce()
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-009: stops watching the network mode after unmount', async () => {
|
||||
const reload = vi.fn(async () => {})
|
||||
const { unmount } = renderHook(() => useBagTotalsPing(true, reload))
|
||||
|
||||
act(() => setForcedOffline(true))
|
||||
unmount()
|
||||
act(() => setForcedOffline(false))
|
||||
await flushCoalesce()
|
||||
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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])
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useRef, useEffect } from 'react'
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
@@ -7,6 +7,8 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { packingApi, tripsApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { useBagTotalsPing } from './useBagTotalsPing'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
|
||||
import { parseImportLines } from './packingListPanel.helpers'
|
||||
@@ -210,6 +212,8 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
const addonsLoaded = useAddonStore(s => s.loaded)
|
||||
const loadAddons = useAddonStore(s => s.loadAddons)
|
||||
const [bags, setBags] = useState<PackingBag[]>([])
|
||||
/** Server-summed weight of everything in no bag (#2191); null until the first load. */
|
||||
const [unassignedWeightGrams, setUnassignedWeightGrams] = useState<number | null>(null)
|
||||
const [newBagName, setNewBagName] = useState('')
|
||||
const [showAddBag, setShowAddBag] = useState(false)
|
||||
const [showBagModal, setShowBagModal] = useState(false)
|
||||
@@ -218,10 +222,35 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
if (!addonsLoaded) loadAddons()
|
||||
}, [addonsLoaded, loadAddons])
|
||||
|
||||
useEffect(() => {
|
||||
if (bagTrackingEnabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
|
||||
const reloadBags = useCallback(async () => {
|
||||
if (!bagTrackingEnabled) return
|
||||
try {
|
||||
const r = await packingApi.listBags(tripId)
|
||||
setBags(r.bags || [])
|
||||
setUnassignedWeightGrams(r.unassigned_weight_grams ?? null)
|
||||
} catch {
|
||||
// Offline or a failed read: the surfaces fall back to the local sum
|
||||
// (see `serverWeightsFresh` below), so there is nothing to roll back.
|
||||
}
|
||||
}, [tripId, bagTrackingEnabled])
|
||||
|
||||
useEffect(() => { void reloadBags() }, [reloadBags])
|
||||
|
||||
// Bag weights are summed server-side across every member (#2191), so an item
|
||||
// this viewer may not even see still moves them. The item events cannot carry
|
||||
// that — a private item is delivered only to its owner, which is the very rule
|
||||
// that made the totals wrong — so the server pings the room content-free and
|
||||
// we re-read the numbers.
|
||||
useBagTotalsPing(bagTrackingEnabled, reloadBags)
|
||||
|
||||
// Bags are not part of the offline cache (no repo, no Dexie table), so while
|
||||
// offline the server totals are frozen at the last online read and cannot see
|
||||
// the optimistic item writes the mutation queue is holding. A stale absolute
|
||||
// number measured against an airline limit is worse than an honest partial
|
||||
// one, so offline the surfaces sum what they can see instead (#2191).
|
||||
const { offline } = useNetworkMode()
|
||||
const serverWeightsFresh = !offline
|
||||
|
||||
const handleCreateBag = async () => {
|
||||
if (!newBagName.trim()) return
|
||||
try {
|
||||
@@ -373,7 +402,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
||||
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
||||
bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
||||
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
|
||||
showSaveTemplate, setShowSaveTemplate, saveTemplateName, setSaveTemplateName,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
@@ -16,6 +17,8 @@ interface Props {
|
||||
// is never lost, and picking a suggestion just replaces the text (#1496).
|
||||
export default function AddressInput({ value, onChange, placeholder, className }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
||||
const { point: locationBias } = useLocationBias()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [results, setResults] = useState<any[]>([])
|
||||
const [highlight, setHighlight] = useState(-1)
|
||||
@@ -47,7 +50,7 @@ export default function AddressInput({ value, onChange, placeholder, className }
|
||||
const myReq = ++reqIdRef.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await mapsApi.search(trimmed, locale)
|
||||
const data = await mapsApi.search(trimmed, locale, locationBias)
|
||||
if (myReq !== reqIdRef.current) return
|
||||
setResults(data.places || [])
|
||||
setHighlight(-1)
|
||||
|
||||
@@ -56,9 +56,11 @@ interface DayDetailPanelProps {
|
||||
mobile?: boolean
|
||||
/** Rename the day from here — the sidebar pencil moved to the transit search (#1065). */
|
||||
onUpdateDayTitle?: (dayId: number, title: string) => void
|
||||
/** Name of the place the day's weather is anchored to — captioned above the forecast (#2167). */
|
||||
weatherPlaceName?: string | null
|
||||
}
|
||||
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false, onUpdateDayTitle }: DayDetailPanelProps) {
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false, onUpdateDayTitle, weatherPlaceName = null }: DayDetailPanelProps) {
|
||||
const { t, language, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
@@ -211,6 +213,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
</div>
|
||||
) : weather ? (
|
||||
<div>
|
||||
{/* Which place the forecast is for — on a roadtrip "the day's weather"
|
||||
is ambiguous without it (#2167). */}
|
||||
{weatherPlaceName && (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
|
||||
{t('day.weatherFor', { name: weatherPlaceName })}
|
||||
</div>
|
||||
)}
|
||||
{/* Summary row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 12, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
|
||||
@@ -87,7 +87,9 @@ vi.mock('../../hooks/useDayNotes', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../Weather/WeatherWidget', () => ({
|
||||
default: () => <span data-testid="weather-widget" />,
|
||||
default: (props: { locationName?: string | null }) => (
|
||||
<span data-testid="weather-widget" data-location={props.locationName ?? ''} />
|
||||
),
|
||||
}))
|
||||
|
||||
// A stable toast object so tests can assert on the messages the sidebar raises.
|
||||
@@ -405,6 +407,14 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.getByText(/10:00/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-012b: the day-specific assignment note shows as a caption line in the row (#2163)', () => {
|
||||
const place = buildPlace({ name: 'Louvre Museum' })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place, notes: 'Book the 10:00 timed entry' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [assignment] } })} />)
|
||||
expect(screen.getByText('Book the 10:00 timed entry')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-013: clicking a place calls onPlaceClick', async () => {
|
||||
const user = userEvent.setup()
|
||||
const place = buildPlace({ id: 42, name: 'Louvre Museum' })
|
||||
@@ -1378,39 +1388,90 @@ describe('DayPlanSidebar', () => {
|
||||
expect(onDeletePlace).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
// ── Note card edit/delete buttons ─────────────────────────────────────
|
||||
// ── Note card editing (#2249) ─────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064: note card edit button calls openEditNote', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-064: clicking a note row opens its edit modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
|
||||
mockDayNotesState.dayNotes = { '10': [note] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
// Find note edit button (Pencil in note-edit-buttons)
|
||||
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
|
||||
if (noteEditBtns.length > 0) {
|
||||
await user.click(noteEditBtns[0] as HTMLElement)
|
||||
expect(mockDayNotesState.openEditNote).toHaveBeenCalled()
|
||||
}
|
||||
await user.click(cardRow(screen.getByText('My note')))
|
||||
expect(mockDayNotesState.openEditNote).toHaveBeenCalledWith(10, expect.objectContaining({ id: 55 }))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-065: deleting a note asks for confirmation before calling deleteNote', async () => {
|
||||
// The bug: an absolutely-positioned pencil/trash pill sat on the note's own
|
||||
// reorder chevrons, and a coarse-pointer `opacity: 1 !important` in index.css
|
||||
// made it permanent on tablets. Nothing may float over that column again.
|
||||
it('FE-PLANNER-DAYPLAN-064b: the note row carries no floating action pill over its reorder buttons', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const row = cardRow(screen.getByText('My note'))
|
||||
expect(document.querySelector('.note-edit-buttons')).toBeNull()
|
||||
expect(row.querySelectorAll('[style*="position: absolute"]')).toHaveLength(0)
|
||||
expect(row.querySelectorAll('.reorder-buttons button')).toHaveLength(2)
|
||||
// Positional, not just by class: the reorder column owns the row's right edge.
|
||||
expect(row.lastElementChild).toHaveClass('reorder-buttons')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064e: the keyboard reaches the note and opens it with Enter', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const row = cardRow(screen.getByText('My note'))
|
||||
expect(row).toHaveAttribute('tabindex', '0')
|
||||
// A press-scale on a draggable row fights the drag, so the row opts out (#2158).
|
||||
expect(row).toHaveAttribute('data-no-press')
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
expect(mockDayNotesState.openEditNote).toHaveBeenCalledWith(10, expect.objectContaining({ id: 55 }))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064c: a link inside the note body opens the link, not the edit modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
|
||||
mockDayNotesState.dayNotes = { '10': [note] }
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note', time: '[docs](https://example.com)' })] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
// Find note delete button (Trash2 in note-edit-buttons)
|
||||
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
|
||||
if (noteEditBtns.length > 1) {
|
||||
await user.click(noteEditBtns[1] as HTMLElement)
|
||||
// Clicking delete opens a confirmation dialog rather than deleting immediately.
|
||||
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Delete note?')).toBeInTheDocument()
|
||||
// Confirming triggers the actual delete.
|
||||
await user.click(screen.getByRole('button', { name: /^delete$/i }))
|
||||
expect(mockDayNotesState.deleteNote).toHaveBeenCalled()
|
||||
}
|
||||
await user.click(screen.getByRole('link', { name: 'docs' }))
|
||||
expect(mockDayNotesState.openEditNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064d: the reorder chevrons still move the note instead of editing it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [
|
||||
buildDayNote({ id: 55, day_id: 10, text: 'First note', sort_order: 0 }),
|
||||
buildDayNote({ id: 56, day_id: 10, text: 'Second note', sort_order: 1 }),
|
||||
] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const row = cardRow(screen.getByText('Second note'))
|
||||
await user.click(row.querySelectorAll('.reorder-buttons button')[0] as HTMLElement)
|
||||
expect(mockDayNotesState.moveNote).toHaveBeenCalled()
|
||||
expect(mockDayNotesState.openEditNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-065: the note modal deletes only through the confirmation dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
|
||||
mockDayNotesState.noteUi = { '10': { mode: 'edit', noteId: 55, text: 'My note', time: '', icon: 'FileText', color: null } }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
await user.click(screen.getByRole('button', { name: /^delete$/i }))
|
||||
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Delete note?')).toBeInTheDocument()
|
||||
const confirm = within(screen.getByText('Delete note?').closest('[class*="z-[10000]"]') as HTMLElement)
|
||||
await user.click(confirm.getByRole('button', { name: /^delete$/i }))
|
||||
expect(mockDayNotesState.deleteNote).toHaveBeenCalledWith(10, 55)
|
||||
// Otherwise the edit modal is left open on a note that no longer exists.
|
||||
expect(mockDayNotesState.cancelNote).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-065b: a note being created offers no delete', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [] }
|
||||
mockDayNotesState.noteUi = { '10': { mode: 'add', text: '', time: '', icon: 'FileText', color: null } }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
expect(screen.queryByRole('button', { name: /^delete$/i })).toBeNull()
|
||||
})
|
||||
|
||||
// ── Drop on assignment: same-day reorder ─────────────────────────────
|
||||
@@ -2888,11 +2949,34 @@ describe('DayPlanSidebar', () => {
|
||||
expect(onReorder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-130: the weather badge falls back to any located trip place', () => {
|
||||
// #2167 — the badge used to borrow ANY located trip place, so a roadtrip day
|
||||
// without stops silently showed another city's weather. Day-local only now.
|
||||
it('FE-PLANNER-DAYPLAN-130: no weather badge for a day without located stops or a bookend hotel (#2167)', () => {
|
||||
const located = buildPlace({ id: 5, name: 'Somewhere', lat: 48.85, lng: 2.35 })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [located] })} />)
|
||||
expect(screen.getByTestId('weather-widget')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('weather-widget')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-130b: the weather badge anchors to the day bookend hotel, independent of the optimize setting (#2167)', () => {
|
||||
// The hotel fallback is unconditional (matching the mobile timeline) — turn the
|
||||
// route-optimization setting OFF to prove the weather anchor ignores it.
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius', optimize_from_accommodation: false } } as any)
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const day2 = buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' })
|
||||
const accommodations = [{ id: 1, start_day_id: 10, end_day_id: 11, place_name: 'Hotel Roma', place_lat: 41.9, place_lng: 12.5 }]
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day, day2], accommodations: accommodations as any })} />)
|
||||
const widgets = screen.getAllByTestId('weather-widget')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
expect(widgets[0]).toHaveAttribute('data-location', 'Hotel Roma')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-130c: the weather badge names the day-local stop it is anchored to (#2167)', () => {
|
||||
const place = buildPlace({ id: 5, name: 'Louvre', lat: 48.86, lng: 2.34 })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const a = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [a] } })} />)
|
||||
expect(screen.getByTestId('weather-widget')).toHaveAttribute('data-location', 'Louvre')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-131: a read-only trip drops the edit affordances from day and note rows', () => {
|
||||
@@ -2907,6 +2991,10 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.queryByLabelText('Add Note')).not.toBeInTheDocument()
|
||||
expect(dragRow(screen.getByText('Read only place'))).toBeNull()
|
||||
expect(screen.getByText('A note')).toBeInTheDocument()
|
||||
// A viewer must not reach the edit modal they could never save (#2249).
|
||||
const noteRow = cardRow(screen.getByText('A note'))
|
||||
expect(noteRow).not.toHaveAttribute('role', 'button')
|
||||
expect(noteRow.querySelectorAll('.reorder-buttons')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-132: the read-only chevron still collapses and expands the day', async () => {
|
||||
@@ -3496,6 +3584,34 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.getByText(/Reservation pending/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-163b: every booking on the stop gets its own chip line (#2201)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const place = buildPlace({ id: 1, name: 'Zoo' })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place })
|
||||
const parking = buildReservation({
|
||||
id: 530, type: 'parking', title: 'Parking pass', status: 'confirmed', assignment_id: 11,
|
||||
reservation_time: '2025-06-01T09:00:00', reservation_end_time: '2025-06-01T09:30:00',
|
||||
} as any)
|
||||
const tickets = buildReservation({
|
||||
id: 531, type: 'activity', title: 'Zoo tickets', status: 'pending', assignment_id: 11,
|
||||
reservation_time: '2025-06-01T10:15:00',
|
||||
} as any)
|
||||
const onEditReservation = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
// Newest first, the order the store hands them over after a local create.
|
||||
days: [day], places: [place], assignments: { '10': [a] }, reservations: [tickets, parking],
|
||||
onEditReservation,
|
||||
})} />)
|
||||
expect(screen.getByText('09:00 – 09:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('10:15')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Reservation confirmed/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Reservation pending/)).toBeInTheDocument()
|
||||
// Earliest first, so the first pencil belongs to the parking pass.
|
||||
await user.click(screen.getAllByTitle('Edit')[0])
|
||||
expect(onEditReservation).toHaveBeenCalledWith(parking)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-164: travellers on a place row are shown as avatars with an overflow count', () => {
|
||||
const place = buildPlace({ id: 1, name: 'Group visit' })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end'; /** A corridor hit on its way onto the drive (#1797) — not a place yet. */ poiOsmId?: string }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
|
||||
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, TramFront, Zap } from 'lucide-react'
|
||||
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 { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
|
||||
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, type NamedWaypoint } from '../Map/RouteCalculator'
|
||||
@@ -30,7 +30,8 @@ import { useTranslation } from '../../i18n'
|
||||
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||
import {
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
|
||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport,
|
||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay,
|
||||
getAssignmentReservations,
|
||||
type MergedItem,
|
||||
} from '../../utils/dayMerge'
|
||||
import { withinDriveRange } from '../../utils/geo'
|
||||
@@ -592,8 +593,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const reachable = (h: { place_lat?: number | null; place_lng?: number | null } | undefined, w: typeof firstWay) =>
|
||||
!h || !w || w.isPlace || h.place_lat == null || h.place_lng == null
|
||||
|| withinDriveRange({ lat: h.place_lat, lng: h.place_lng }, w)
|
||||
const wantTop = !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay)) && reachable(startHotel, firstWay)
|
||||
const wantBottom = !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay)) && reachable(endHotel, lastWay)
|
||||
// Same carrier evidence the map route uses (#2157): with a located carrier
|
||||
// endpoint on the day, the no-time default must not open a hotel leg.
|
||||
const dayHasCarrier = wayPts.some(w => w.carrierEdge != null)
|
||||
const wantTop = !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay, dayHasCarrier)) && reachable(startHotel, firstWay)
|
||||
const wantBottom = !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay, dayHasCarrier)) && reachable(endHotel, lastWay)
|
||||
return { runs, startHotel, endHotel, firstWay, lastWay, wantTop, wantBottom }
|
||||
}
|
||||
|
||||
@@ -692,19 +696,13 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const openEditNote = (dayId: number, note: DayNote, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
_openEditNote(dayId, note)
|
||||
}
|
||||
const openEditNote = (dayId: number, note: DayNote) => _openEditNote(dayId, note)
|
||||
|
||||
// Deleting a note asks for confirmation first — the edit/delete icons sit close together and are
|
||||
// easy to mis-tap on touch devices, where an accidental delete was previously unrecoverable.
|
||||
const [pendingDeleteNote, setPendingDeleteNote] = useState<{ dayId: number; noteId: number } | null>(null)
|
||||
|
||||
const deleteNote = async (dayId: number, noteId: number, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
await _deleteNote(dayId, noteId)
|
||||
}
|
||||
const deleteNote = (dayId: number, noteId: number) => _deleteNote(dayId, noteId)
|
||||
|
||||
// Unified reorder: assigns positions to ALL item types based on new visual order
|
||||
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
|
||||
@@ -975,7 +973,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
|
||||
const day = days.find(d => d.id === dayId)
|
||||
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
|
||||
? getAccommodationAnchors(day, days, accommodations)
|
||||
? getAccommodationAnchors(
|
||||
day, days, accommodations,
|
||||
unlockedWithCoords.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng! })),
|
||||
(mergedItemsMap[dayId] || []).some(i => i.type === 'transport' && hasCarrierEndpointOnDay(i.data, dayId)),
|
||||
)
|
||||
: {}
|
||||
const optimizedAssignments = unlockedWithCoords.length >= 2
|
||||
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id })), anchors).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
|
||||
@@ -1040,10 +1042,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
return formatMoneySum(entries, costBase, locale, fxRates)
|
||||
}, [days, assignments, currency, costBase, locale, fxRates])
|
||||
|
||||
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort
|
||||
const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng)
|
||||
const anyGeoPlace = anyGeoAssignment || (places || []).find(p => p.lat && p.lng)
|
||||
|
||||
return {
|
||||
tripId,
|
||||
trip,
|
||||
@@ -1179,8 +1177,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
handleOptimize,
|
||||
handleDropOnDay,
|
||||
totalCostLabel,
|
||||
anyGeoAssignment,
|
||||
anyGeoPlace,
|
||||
expandedRouteDayIds,
|
||||
setExpandedRouteDayIds,
|
||||
}
|
||||
@@ -1352,8 +1348,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
handleOptimize,
|
||||
handleDropOnDay,
|
||||
totalCostLabel,
|
||||
anyGeoAssignment,
|
||||
anyGeoPlace,
|
||||
expandedRouteDayIds,
|
||||
setExpandedRouteDayIds,
|
||||
} = S
|
||||
@@ -1551,7 +1545,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
// routable when accommodation optimization can bookend it with a hotel
|
||||
// (hotel → place → hotel, the same line the map draws) — otherwise the tools
|
||||
// vanish on such a day (#1330). Purely additive to the 2+ case.
|
||||
const routeBookends = optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : null
|
||||
// The weather anchor below must not depend on the optimize-from-accommodation
|
||||
// setting — where you wake up is a fact about the day, not a routing choice —
|
||||
// so the bookend lookup runs unconditionally (mirrors useMPlanTimeline) while
|
||||
// the route tools keep honoring the setting via routeBookends.
|
||||
const dayBookends = getDayBookendHotels(day, days, accommodations)
|
||||
const routeBookends = optimizeFromAccommodation !== false ? dayBookends : null
|
||||
const hasRouteBookend = !!(
|
||||
(routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null) ||
|
||||
(routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null)
|
||||
@@ -1582,11 +1581,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const dayExportStops = (): NamedWaypoint[] => {
|
||||
const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null)
|
||||
const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name }))
|
||||
const first = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null } : undefined
|
||||
const first = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null, lat: dayStops[0].place!.lat!, lng: dayStops[0].place!.lng! } : undefined
|
||||
const lastAssignment = dayStops[dayStops.length - 1]
|
||||
const last = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null } : undefined
|
||||
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first)
|
||||
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last)
|
||||
const last = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null, lat: lastAssignment.place!.lat!, lng: lastAssignment.place!.lng! } : undefined
|
||||
// Same carrier gate as the drawn route (#2157): the exported link must not
|
||||
// start at a hotel you only reach tonight or lead back to one you left.
|
||||
const dayHasCarrier = (mergedItemsMap[day.id] || []).some(i => i.type === 'transport' && hasCarrierEndpointOnDay(i.data, day.id))
|
||||
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first, dayHasCarrier)
|
||||
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last, dayHasCarrier)
|
||||
const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
|
||||
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng, name: routeBookends.morning.place_name } : null
|
||||
const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
|
||||
@@ -1627,6 +1629,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
// own, hence the key handler only answers for the row — and it
|
||||
// toggles exactly like the click, rather than only selecting.
|
||||
role="button"
|
||||
// No press-scale on the wide row — it would shift the nested
|
||||
// buttons out from under the pointer mid-click (#2158).
|
||||
data-no-press
|
||||
tabIndex={0}
|
||||
onClick={() => toggleDaySelection()}
|
||||
onKeyDown={e => {
|
||||
@@ -1659,12 +1664,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
>
|
||||
{/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */}
|
||||
{(() => {
|
||||
// anyGeoPlace is an assignment (has .place) or a bare place — read coords from either.
|
||||
const geoLat = anyGeoPlace ? ('place' in anyGeoPlace ? anyGeoPlace.place?.lat : anyGeoPlace.lat) : undefined
|
||||
const geoLng = anyGeoPlace ? ('place' in anyGeoPlace ? anyGeoPlace.place?.lng : anyGeoPlace.lng) : undefined
|
||||
const wLat = loc?.place?.lat ?? geoLat
|
||||
const wLng = loc?.place?.lng ?? geoLng
|
||||
const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null)
|
||||
// Day-local anchor only (#2167): the day's first located stop, else the
|
||||
// hotel you wake up in. No trip-wide fallback — on a roadtrip that
|
||||
// silently showed another city's weather with nothing naming the place.
|
||||
const weatherHotel = loc == null ? dayBookends.morning : undefined
|
||||
const wLat = loc?.place?.lat ?? weatherHotel?.place_lat
|
||||
const wLng = loc?.place?.lng ?? weatherHotel?.place_lng
|
||||
const weatherName = loc?.place?.name ?? weatherHotel?.place_name ?? null
|
||||
const hasWeather = !!(day.date && wLat != null && wLng != null)
|
||||
return (
|
||||
<div style={{
|
||||
// With weather the badge is a tall stack and has to start at
|
||||
@@ -1691,7 +1698,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<>
|
||||
<div style={{ width: '64%', height: 1, background: 'currentColor', opacity: 0.25 }} />
|
||||
<div style={{ padding: '3px 0 4px' }}>
|
||||
<WeatherWidget lat={wLat} lng={wLng} date={day.date} stacked />
|
||||
<WeatherWidget lat={wLat ?? null} lng={wLng ?? null} date={day.date} stacked locationName={weatherName} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1953,8 +1960,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
// Picking the place out of the day has no other trigger, so
|
||||
// the row is the control. Its grip, lock and arrows are
|
||||
// buttons in their own right, hence the key handler only
|
||||
// answers for the row itself.
|
||||
// answers for the row itself. No press-scale — see the
|
||||
// day header above (#2158).
|
||||
role="button"
|
||||
data-no-press
|
||||
tabIndex={0}
|
||||
draggable={canEditDays && !dragDisabled}
|
||||
onDragStart={e => {
|
||||
@@ -2121,87 +2130,107 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{assignment.notes && (
|
||||
// Day-specific note on this stop (#2163) — one muted
|
||||
// caption line so the timeline shows the note exists
|
||||
// without swallowing the row.
|
||||
<div title={t('places.assignmentNotes')} style={{ marginTop: 2, display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden' }}>
|
||||
<StickyNote size={9} strokeWidth={2} style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>{assignment.notes}</span>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const res = reservations.find(r => r.assignment_id === assignment.id)
|
||||
if (!res) return null
|
||||
const confirmed = res.status === 'confirmed'
|
||||
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
|
||||
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
|
||||
// The status, the time and the flight/train number used to sit in one
|
||||
// pill strung together on a middle dot, which read as one run-on
|
||||
// sentence. They are separate chips now: same tint so they still
|
||||
// belong together, own outline so the eye can take them one at a time.
|
||||
const RI = RES_ICONS[res.type] || Ticket
|
||||
const tint = confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'
|
||||
const chip: React.CSSProperties = {
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '1px 6px', borderRadius: 5,
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
const { time: st } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: et } = splitReservationDateTime(res.reservation_end_time)
|
||||
const timeLabel = st || et
|
||||
? `${st ? formatTime(st, locale, timeFormat) : ''}${et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}`
|
||||
: ''
|
||||
let meta: any = {}
|
||||
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
|
||||
const carrierLabel = meta
|
||||
? (meta.airline && meta.flight_number ? `${meta.airline} ${meta.flight_number}` : meta.flight_number || meta.train_number || '')
|
||||
: ''
|
||||
const linked = getAssignmentReservations(reservations, assignment.id)
|
||||
if (linked.length === 0) return null
|
||||
// A stop can carry more than one booking (a parking pass and the
|
||||
// tickets for the same zoo), and every one of them is kept out of
|
||||
// the timeline, so this row is where they have to appear (#2201).
|
||||
// Stacked, because the chip lines are inline-flex and would
|
||||
// otherwise run together on one.
|
||||
return (
|
||||
// No wrapping: the time belongs to the status it qualifies, so the
|
||||
// chips stay on one line even when the row gets narrow.
|
||||
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, flexWrap: 'nowrap' }}>
|
||||
<div className={tint} style={chip}>
|
||||
<RI size={8} />
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
</div>
|
||||
{timeLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{timeLabel}</span>}
|
||||
{carrierLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{carrierLabel}</span>}
|
||||
{hasEndpoints && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
|
||||
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
|
||||
className={active ? 'bg-[#3b82f6] text-[#fff]' : 'bg-transparent text-content-faint'}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<RouteIcon size={11} />
|
||||
</button>
|
||||
)}
|
||||
{canEditDays && (() => {
|
||||
const isTransport = TRANSPORT_TYPES.has(res.type)
|
||||
const handler = isTransport ? onEditTransport : onEditReservation
|
||||
if (!handler) return null
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
{linked.map(res => {
|
||||
const confirmed = res.status === 'confirmed'
|
||||
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
|
||||
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
|
||||
// The status, the time and the flight/train number used to sit in one
|
||||
// pill strung together on a middle dot, which read as one run-on
|
||||
// sentence. They are separate chips now: same tint so they still
|
||||
// belong together, own outline so the eye can take them one at a time.
|
||||
const RI = RES_ICONS[res.type] || Ticket
|
||||
const tint = confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'
|
||||
const chip: React.CSSProperties = {
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '1px 6px', borderRadius: 5,
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
const { time: st } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: et } = splitReservationDateTime(res.reservation_end_time)
|
||||
const timeLabel = st || et
|
||||
? `${st ? formatTime(st, locale, timeFormat) : ''}${et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}`
|
||||
: ''
|
||||
let meta: any = {}
|
||||
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
|
||||
const carrierLabel = meta
|
||||
? (meta.airline && meta.flight_number ? `${meta.airline} ${meta.flight_number}` : meta.flight_number || meta.train_number || '')
|
||||
: ''
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); handler(res) }}
|
||||
title={t('common.edit')}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
// No wrapping: the time belongs to the status it qualifies, so the
|
||||
// chips stay on one line even when the row gets narrow.
|
||||
<div key={res.id} style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, flexWrap: 'nowrap' }}>
|
||||
<div className={tint} style={chip}>
|
||||
<RI size={8} />
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
</div>
|
||||
{timeLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{timeLabel}</span>}
|
||||
{carrierLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{carrierLabel}</span>}
|
||||
{hasEndpoints && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
|
||||
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
|
||||
className={active ? 'bg-[#3b82f6] text-[#fff]' : 'bg-transparent text-content-faint'}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<RouteIcon size={11} />
|
||||
</button>
|
||||
)}
|
||||
{canEditDays && (() => {
|
||||
const isTransport = TRANSPORT_TYPES.has(res.type)
|
||||
const handler = isTransport ? onEditTransport : onEditReservation
|
||||
if (!handler) return null
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); handler(res) }}
|
||||
title={t('common.edit')}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
@@ -2336,7 +2365,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<div
|
||||
// Opening the booking has no other trigger, so the row is
|
||||
// the control; the buttons inside it answer for themselves.
|
||||
// No press-scale — see the day header above (#2158).
|
||||
role="button"
|
||||
data-no-press
|
||||
tabIndex={0}
|
||||
onClick={openTransportRow}
|
||||
onKeyDown={e => {
|
||||
@@ -2577,17 +2608,33 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
{ divider: true },
|
||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => setPendingDeleteNote({ dayId: day.id, noteId: note.id }) },
|
||||
]) : undefined}
|
||||
{...(canEditDays ? {
|
||||
role: 'button' as const,
|
||||
// No press-scale: index.css shrinks every [role=button] on
|
||||
// :active, which on a draggable row fights the drag (#2158).
|
||||
'data-no-press': '',
|
||||
tabIndex: 0,
|
||||
// The row is the note's edit affordance — the same gesture the
|
||||
// phone shell already uses (#2249). Links inside the rendered
|
||||
// body and the reorder buttons keep their own click.
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('a, button')) return
|
||||
openEditNote(day.id, note)
|
||||
},
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
openEditNote(day.id, note)
|
||||
},
|
||||
} : {})}
|
||||
onMouseEnter={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '1'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '1'
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '0.3'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '0'
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
@@ -2599,7 +2646,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
|
||||
background: noteSkin.background,
|
||||
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
|
||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||
transition: 'background 0.1s', cursor: canEditDays ? 'pointer' : 'default', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{canEditDays && !dragDisabled && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
@@ -2621,19 +2668,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Stacked in a capsule of their own, set off from the text:
|
||||
a note card is as tall as its body, so the height is free
|
||||
while the width next to the text is not, and a 10px icon
|
||||
in a 14px box was barely a target. */}
|
||||
{/* Floated over the text rather than sharing the row with
|
||||
it: a note is mostly text, and reserving a column for
|
||||
two buttons that are invisible until you hover costs
|
||||
every note a shorter line for the whole time you are
|
||||
not hovering. */}
|
||||
{canEditDays && <div className="note-edit-buttons bg-surface-card" style={{ position: 'absolute', right: 5, top: '50%', transform: 'translateY(-50%)', display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, padding: 4, borderRadius: 999, border: '1px solid var(--border-faint)', boxShadow: '0 4px 14px rgba(0,0,0,0.10)', opacity: 0, transition: 'opacity 0.15s' }}>
|
||||
<button type="button" onClick={e => openEditNote(day.id, note, e)} aria-label={t('dayplan.noteEdit')} title={t('dayplan.noteEdit')} className="text-content-faint note-edit-btn" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Pencil size={15} /></button>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} aria-label={t('dayplan.noteDelete')} title={t('dayplan.noteDelete')} className="text-content-faint note-edit-btn note-edit-btn-danger" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Trash2 size={15} /></button>
|
||||
</div>}
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} className={noteIdx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
@@ -2840,6 +2874,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
noteInputRef={noteInputRef}
|
||||
cancelNote={cancelNote}
|
||||
saveNote={saveNote}
|
||||
onRequestDelete={(dayId, noteId) => setPendingDeleteNote({ dayId, noteId })}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -2855,7 +2890,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingDeleteNote}
|
||||
onClose={() => setPendingDeleteNote(null)}
|
||||
onConfirm={() => { if (pendingDeleteNote) deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId) }}
|
||||
onConfirm={() => {
|
||||
if (!pendingDeleteNote) return
|
||||
// Close the edit modal behind the confirm; leaving it open would go on
|
||||
// editing a note that no longer exists.
|
||||
cancelNote(pendingDeleteNote.dayId)
|
||||
deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId)
|
||||
}}
|
||||
title={t('dayplan.confirmDeleteNoteTitle')}
|
||||
message={t('dayplan.confirmDeleteNoteBody')}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { markdownLinkComponents } from '../shared/markdownLink'
|
||||
|
||||
interface NoteModalUi {
|
||||
mode: 'add' | 'edit'
|
||||
noteId?: number
|
||||
icon: string
|
||||
text: string
|
||||
time: string
|
||||
@@ -25,6 +26,8 @@ interface DayPlanSidebarNoteModalProps {
|
||||
noteInputRef: React.RefObject<HTMLInputElement>
|
||||
cancelNote: (dayId: number) => void
|
||||
saveNote: (dayId: number) => void
|
||||
/** Asks the planner to confirm and then delete the note being edited (#2249). */
|
||||
onRequestDelete: (dayId: number, noteId: number) => void
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}
|
||||
|
||||
@@ -38,19 +41,19 @@ const BODY_MAX = 2000
|
||||
* right. The preview is the reason the colour picker is worth having — a swatch
|
||||
* row tells you nothing about what a tinted card looks like next to a place.
|
||||
*/
|
||||
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, t }: DayPlanSidebarNoteModalProps) {
|
||||
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: DayPlanSidebarNoteModalProps) {
|
||||
return (
|
||||
<>
|
||||
{Object.entries(noteUi).map(([dayId, ui]) => ui && createPortal(
|
||||
<NoteDialog key={dayId} dayId={dayId} ui={ui} setNoteUi={setNoteUi} noteInputRef={noteInputRef}
|
||||
cancelNote={cancelNote} saveNote={saveNote} t={t} />,
|
||||
cancelNote={cancelNote} saveNote={saveNote} onRequestDelete={onRequestDelete} t={t} />,
|
||||
document.body
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
|
||||
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
|
||||
dayId: string
|
||||
ui: NoteModalUi
|
||||
}) {
|
||||
@@ -174,7 +177,17 @@ function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{/* Delete lives here rather than on the row: the row's floating pencil/trash
|
||||
pill covered the reorder chevrons and, on a coarse pointer, never went
|
||||
away (#2249). The planner still confirms before it deletes. */}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||
{ui.mode === 'edit' && ui.noteId != null && (
|
||||
<button type="button" onClick={() => onRequestDelete(Number(dayId), ui.noteId!)}
|
||||
className="text-danger"
|
||||
style={{ marginRight: 'auto', fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button type="button" onClick={() => saveNote(Number(dayId))} disabled={!canSave}
|
||||
className={!canSave ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { MapPin, X } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
|
||||
export interface LocationPoint {
|
||||
name: string
|
||||
@@ -19,6 +20,8 @@ interface Props {
|
||||
|
||||
export default function LocationSelect({ value, onChange, placeholder, style }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
||||
const { point: locationBias } = useLocationBias()
|
||||
const [query, setQuery] = useState(value?.name || '')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [results, setResults] = useState<any[]>([])
|
||||
@@ -49,7 +52,7 @@ export default function LocationSelect({ value, onChange, placeholder, style }:
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await mapsApi.search(trimmed, locale)
|
||||
const data = await mapsApi.search(trimmed, locale, locationBias)
|
||||
setResults(data.places || [])
|
||||
setHighlight(-1)
|
||||
} catch {
|
||||
|
||||
@@ -169,6 +169,28 @@ describe('PlaceDetailsColumn', () => {
|
||||
expect(link.parentElement).toHaveTextContent('CC BY-SA 4.0')
|
||||
})
|
||||
|
||||
it('FE-PDC-010b: credits a website description by host', async () => {
|
||||
// The source labels used to be a ternary chain ending in 'Wikipedia', so a
|
||||
// restaurant's own marketing copy would have been credited to an
|
||||
// encyclopaedia. There is no fixed name for "the place's own website", and
|
||||
// the host is the honest one.
|
||||
placeEnrichment.mockResolvedValue({
|
||||
photos: [],
|
||||
facts: [],
|
||||
description: {
|
||||
text: 'Pizza in Rostock, seit 2015.',
|
||||
source: 'website',
|
||||
sourceUrl: 'https://www.losteria.net/rostock',
|
||||
license: null,
|
||||
},
|
||||
})
|
||||
renderColumn()
|
||||
|
||||
const link = await screen.findByRole('link', { name: /losteria\.net/ })
|
||||
expect(link).toHaveAttribute('href', 'https://www.losteria.net/rostock')
|
||||
expect(screen.queryByText(/Wikipedia/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-011: says so when the admin switched enrichment off', async () => {
|
||||
placeEnrichment.mockResolvedValue({ photos: [], description: null, facts: [], disabled: true })
|
||||
renderColumn()
|
||||
|
||||
@@ -379,6 +379,37 @@ function PhotoTile({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Who the description came from, as the reader would name them.
|
||||
*
|
||||
* `website` has no fixed name — it is the place's own site — so it is credited
|
||||
* by host: "losteria.net" says where the text is from far better than a generic
|
||||
* word would. Deliberately not a ternary chain: the previous one fell through
|
||||
* to 'Wikipedia', which would have credited a restaurant's marketing copy to an
|
||||
* encyclopaedia.
|
||||
*/
|
||||
function descriptionSourceLabel(
|
||||
source: NonNullable<MapsPlaceEnrichmentResult['description']>['source'],
|
||||
sourceUrl: string | null,
|
||||
): string {
|
||||
switch (source) {
|
||||
case 'google':
|
||||
return 'Google'
|
||||
case 'osm':
|
||||
return 'OpenStreetMap'
|
||||
case 'wikivoyage':
|
||||
return 'Wikivoyage'
|
||||
case 'wikipedia':
|
||||
return 'Wikipedia'
|
||||
case 'website':
|
||||
try {
|
||||
return new URL(sourceUrl || '').hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return 'Website'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
|
||||
if (source === 'google') return 'Google'
|
||||
if (source === 'wikipedia') return 'Wikipedia'
|
||||
@@ -659,14 +690,7 @@ function DescriptionBlock({
|
||||
}): React.ReactElement | null {
|
||||
if (!description) return null
|
||||
|
||||
const sourceLabel =
|
||||
description.source === 'google'
|
||||
? 'Google'
|
||||
: description.source === 'osm'
|
||||
? 'OpenStreetMap'
|
||||
: description.source === 'wikivoyage'
|
||||
? 'Wikivoyage'
|
||||
: 'Wikipedia'
|
||||
const sourceLabel = descriptionSourceLabel(description.source, description.sourceUrl)
|
||||
|
||||
// A description of the chain is not a description of this place, and the
|
||||
// heading is where that gets said. Without it the reader has no way to tell
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
|
||||
export interface PlaceFormData {
|
||||
name: string
|
||||
description: string
|
||||
@@ -19,6 +21,19 @@ export interface PlaceFormData {
|
||||
// DEFAULT_FORM on purpose: the mobile sheet shares this type and never sets
|
||||
// it, and places.service already writes image_url through on create/update.
|
||||
image_url?: string
|
||||
// Day-specific note on the in-context assignment (#2163). Only hydrated when
|
||||
// the form opened with an assignment in context; both forms drop it from the
|
||||
// submit payload when unchanged, and useTripPlanner strips it off the place
|
||||
// update and PUTs it per assignment instead.
|
||||
assignment_notes?: string
|
||||
// What kind of stop on a drive this is, if any (#1797). Optional and absent from
|
||||
// DEFAULT_FORM for the same reason as image_url: a missing key writes nothing, and a
|
||||
// place created outside a road trip has no opinion about fuel stops.
|
||||
stop_type?: RoadtripStopType | null
|
||||
// Offered only while creating, and only alongside a stop_type: the corridor popup
|
||||
// suggests how long that kind of pause usually takes, and the full form should not
|
||||
// throw the suggestion away on the way through. Editing a stay is the rail's job.
|
||||
duration_minutes?: number
|
||||
}
|
||||
|
||||
export function isGoogleMapsUrl(input: string): boolean {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
import { STOP_KINDS } from '../Roadtrip/stopKinds'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { recordPlacePick } from '../../api/placeShadow'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import CollectionPicker from '../Collections/CollectionPicker'
|
||||
import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -16,6 +19,7 @@ import { useTranslation } from '../../i18n'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { DEFAULT_FORM, isGoogleMapsUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import type { Place, Category, Assignment, BudgetItem } from '../../types'
|
||||
@@ -36,7 +40,7 @@ interface PlaceFormModalProps {
|
||||
onClose: () => void
|
||||
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
|
||||
place: Place | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number } | null
|
||||
tripId: number
|
||||
categories: Category[]
|
||||
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
|
||||
@@ -86,9 +90,20 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
onCategoryCreated, assignmentId, dayAssignments = [], isMobile = false,
|
||||
onOpenExpense,
|
||||
} = props
|
||||
// Hidden while the addon is off, because the kinds only mean anything to the road trip
|
||||
// rail: on an instance without it they would be six labels that change nothing.
|
||||
const roadtripEnabled = useAddonStore(s => s.isEnabled('roadtrip'))
|
||||
const [form, setForm] = useState(DEFAULT_FORM)
|
||||
const [mapsSearch, setMapsSearch] = useState('')
|
||||
const [mapsResults, setMapsResults] = useState([])
|
||||
/**
|
||||
* What produced the list currently on screen, kept for the shadow log: the
|
||||
* query as typed and the provider the envelope named. A ref rather than
|
||||
* state because nothing renders from it and a pick must read the value that
|
||||
* belonged to the list, not a value a re-render replaced.
|
||||
*/
|
||||
const searchMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
const [isSearchingMaps, setIsSearchingMaps] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||
@@ -143,6 +158,14 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
notes: place.notes || '',
|
||||
transport_mode: place.transport_mode || 'walking',
|
||||
website: place.website || '',
|
||||
// Carried through every edit. Without it, opening a fuel stop to fix a typo
|
||||
// submits an empty kind and turns it back into a numbered destination.
|
||||
// duration_minutes deliberately stays out: how long a stay takes belongs to the
|
||||
// rail's own dialog, and sending it from here would overwrite what was set there.
|
||||
stop_type: place.stop_type ?? null,
|
||||
// The day-specific note rides only with an assignment in context (#2163);
|
||||
// otherwise the key stays absent so submit never sends a notes write.
|
||||
...(assignment ? { assignment_notes: assignment.notes || '' } : {}),
|
||||
})
|
||||
} else if (prefillCoords) {
|
||||
setForm({
|
||||
@@ -154,6 +177,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
website: prefillCoords.website || '',
|
||||
phone: prefillCoords.phone || '',
|
||||
osm_id: prefillCoords.osm_id,
|
||||
stop_type: prefillCoords.stop_type ?? null,
|
||||
duration_minutes: prefillCoords.duration_minutes,
|
||||
})
|
||||
} else {
|
||||
setForm(DEFAULT_FORM)
|
||||
@@ -210,32 +235,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Derive location bias bounding box from the trip's existing places
|
||||
const places = useTripStore((s) => s.places)
|
||||
const locationBias = useMemo(() => {
|
||||
const withCoords = (places || []).filter((p) => p.lat != null && p.lng != null)
|
||||
if (withCoords.length === 0) return undefined
|
||||
|
||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
|
||||
for (const p of withCoords) {
|
||||
const lat = Number(p.lat), lng = Number(p.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
|
||||
if (lat < minLat) minLat = lat
|
||||
if (lat > maxLat) maxLat = lat
|
||||
if (lng < minLng) minLng = lng
|
||||
if (lng > maxLng) maxLng = lng
|
||||
}
|
||||
if (!Number.isFinite(minLat)) return undefined
|
||||
|
||||
// Skip bias if the bounding box is too large (~500 km diagonal)
|
||||
const dlat = maxLat - minLat
|
||||
const dlng = maxLng - minLng
|
||||
const avgLatRad = ((minLat + maxLat) / 2) * (Math.PI / 180)
|
||||
const diagKm = Math.sqrt((dlat * 111) ** 2 + (dlng * 111 * Math.cos(avgLatRad)) ** 2)
|
||||
if (diagKm > 500) return undefined
|
||||
|
||||
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
|
||||
}, [places])
|
||||
// Where the trip is happening — the hint that tells the search which of a
|
||||
// thousand identically named places is meant. Autocomplete wants a box, the
|
||||
// search wants a point; useLocationBias derives both from the same places.
|
||||
const { box: locationBias, point: locationBiasPoint } = useLocationBias()
|
||||
|
||||
// Autocomplete fetch — aborts any in-flight request before starting a new one
|
||||
const fetchSuggestions = useCallback(async (query: string) => {
|
||||
@@ -249,6 +253,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
acAbortRef.current = controller
|
||||
try {
|
||||
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
|
||||
acMetaRef.current = { query, source: result.source || 'unknown' }
|
||||
setAcSuggestions(result.suggestions || [])
|
||||
setAcHighlight(-1)
|
||||
} catch (err: unknown) {
|
||||
@@ -307,7 +312,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const result = await mapsApi.search(mapsSearch, language)
|
||||
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint)
|
||||
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
|
||||
setMapsResults(result.places || [])
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
@@ -316,7 +322,13 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectMapsResult = (result) => {
|
||||
/**
|
||||
* `pick` is present only when the click came from a ranked list. The
|
||||
* collection picker and the autocomplete detour reach this function with a
|
||||
* place that was never ranked against a query, and a made-up rank would be
|
||||
* worse than no row at all.
|
||||
*/
|
||||
const handleSelectMapsResult = (result, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => {
|
||||
setForm(prev => mergeResult(prev, result, autoFilledRef.current))
|
||||
// The one point every pick flows through, so the detail column hangs here.
|
||||
// A new pick drops whatever hero image belonged to the previous place.
|
||||
@@ -333,12 +345,36 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
details: result,
|
||||
})
|
||||
setForm(prev => ({ ...prev, image_url: undefined }))
|
||||
if (pick) {
|
||||
const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current
|
||||
if (meta) {
|
||||
recordPlacePick({
|
||||
query: meta.query,
|
||||
lang: language,
|
||||
// The bias the search actually ran under is a box around the trip's
|
||||
// existing places; the corpus stores its centre, which is what an
|
||||
// evaluation needs to bias its own index the same way.
|
||||
biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined,
|
||||
biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined,
|
||||
source: `${pick.mode}:${meta.source}`,
|
||||
liveRank: pick.rank,
|
||||
liveCount: pick.count,
|
||||
pickedName: result.name || '',
|
||||
pickedLat: lat,
|
||||
pickedLng: lng,
|
||||
pickedPlaceId: result.google_place_id || result.osm_id || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
setMapsResults([])
|
||||
setMapsSearch('')
|
||||
}
|
||||
|
||||
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
|
||||
// Read before the list is cleared: this is the rank the user saw.
|
||||
const acRank = acSuggestions.findIndex(s => s.placeId === suggestion.placeId)
|
||||
const acCount = acSuggestions.length
|
||||
setAcSuggestions([])
|
||||
setAcHighlight(-1)
|
||||
const previousSearch = mapsSearch
|
||||
@@ -365,11 +401,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
if (!place) {
|
||||
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
|
||||
const search = await mapsApi.search(query, language)
|
||||
const search = await mapsApi.search(query, language, locationBiasPoint)
|
||||
place = search.places?.[0] ?? null
|
||||
}
|
||||
if (place) {
|
||||
handleSelectMapsResult(place)
|
||||
handleSelectMapsResult(place, acRank >= 0 ? { mode: 'autocomplete', rank: acRank, count: acCount } : undefined)
|
||||
} else {
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
@@ -471,13 +507,31 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const saved = await onSave({
|
||||
const payload = {
|
||||
...form,
|
||||
lat: form.lat ? Number.parseFloat(form.lat) : null,
|
||||
lng: form.lng ? Number.parseFloat(form.lng) : null,
|
||||
category_id: form.category_id || null,
|
||||
// An explicit null is how a stop stops being a fuel stop; the service reads it
|
||||
// that way rather than as "leave alone", which is what a missing key means.
|
||||
stop_type: form.stop_type || null,
|
||||
// Only on the way in, and only with a kind: it is the popup's suggestion for how
|
||||
// long that kind of pause takes. On an edit it is left out entirely, because the
|
||||
// stay belongs to the rail's dialog and sending it here would overwrite it.
|
||||
...(!place && form.stop_type && form.duration_minutes
|
||||
? { duration_minutes: form.duration_minutes }
|
||||
: {}),
|
||||
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||
})
|
||||
}
|
||||
// #2163: the per-assignment note only travels when an assignment is in
|
||||
// context AND the value actually changed — an untouched note must not
|
||||
// produce a PUT (a legacy note longer than the textarea would otherwise
|
||||
// be re-sent on every unrelated save).
|
||||
const ctxAssignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
|
||||
if (!ctxAssignment || (form.assignment_notes ?? '') === (ctxAssignment.notes ?? '')) {
|
||||
delete payload.assignment_notes
|
||||
}
|
||||
const saved = await onSave(payload)
|
||||
// Open the Costs editor for the saved place when the user asked to
|
||||
// create/edit its linked expense — gated on an id, so a create that the
|
||||
// server refused never opens an editor pointing at nothing (#1298).
|
||||
@@ -515,6 +569,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
dayAssignments,
|
||||
isMobile,
|
||||
collectionsEnabled,
|
||||
roadtripEnabled,
|
||||
form,
|
||||
setForm,
|
||||
mapsSearch,
|
||||
@@ -587,6 +642,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
dayAssignments,
|
||||
isMobile,
|
||||
collectionsEnabled,
|
||||
roadtripEnabled,
|
||||
form,
|
||||
setForm,
|
||||
mapsSearch,
|
||||
@@ -756,7 +812,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => handleSelectMapsResult(result)}
|
||||
onClick={() => handleSelectMapsResult(result, { mode: 'search', rank: idx, count: mapsResults.length })}
|
||||
className="w-full text-left px-3 py-2 hover:bg-surface-hover border-b border-edge-faint last:border-0"
|
||||
>
|
||||
<div className="font-medium text-sm">{result.name}</div>
|
||||
@@ -858,6 +914,30 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What kind of stop this is on a drive.
|
||||
Under the category rather than beside it, because the two answer different
|
||||
questions: a category is the traveller's own label, editable and shared across
|
||||
the whole instance, while this is a fact about the place. Refuelling is not a
|
||||
taste. Shown always, not only in road trip mode: a fuel stop added on a Tuesday
|
||||
is still a fuel stop when the mode is switched on later, and this is the only
|
||||
place in the web UI where the kind can be taken off again. */}
|
||||
{roadtripEnabled ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">{t('roadtrip.stop.kind')}</label>
|
||||
<CustomSelect
|
||||
value={form.stop_type ?? ''}
|
||||
onChange={value => handleChange('stop_type', String(value) as RoadtripStopType | '')}
|
||||
placeholder={t('roadtrip.stop.none')}
|
||||
options={[
|
||||
{ value: '', label: t('roadtrip.stop.none') },
|
||||
...STOP_KINDS.map(k => ({ value: k.key, label: t(k.labelKey) })),
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('roadtrip.stop.kindHelp')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formCategory')}</label>
|
||||
@@ -924,6 +1004,21 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Day-specific note — like the times, it lives on the assignment, not
|
||||
the pool place (#2163): only editable with one in context. */}
|
||||
{!!(place && assignmentId) && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.assignmentNotes')}</label>
|
||||
<textarea
|
||||
value={form.assignment_notes ?? ''}
|
||||
onChange={e => handleChange('assignment_notes', e.target.value)}
|
||||
rows={2}
|
||||
placeholder={t('places.assignmentNotesPlaceholder')}
|
||||
className="form-input" style={{ resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Website */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formWebsite')}</label>
|
||||
|
||||
@@ -425,6 +425,33 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.getByText('Museum Ticket')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-030g: every booking on the stop gets its own strip (#2201)', () => {
|
||||
const onEditReservation = vi.fn();
|
||||
const parking = buildReservation({
|
||||
id: 530, title: 'Parking pass', status: 'confirmed', type: 'parking', assignment_id: 99,
|
||||
reservation_time: '2025-06-01T09:00:00',
|
||||
} as any);
|
||||
const tickets = buildReservation({
|
||||
id: 531, title: 'Zoo tickets', status: 'pending', type: 'activity', assignment_id: 99,
|
||||
reservation_time: '2025-06-01T10:15:00',
|
||||
} as any);
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': assignmentInDay }}
|
||||
reservations={[tickets, parking]}
|
||||
onEditReservation={onEditReservation}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Parking pass')).toBeTruthy();
|
||||
expect(screen.getByText('Zoo tickets')).toBeTruthy();
|
||||
fireEvent.click(screen.getByText('Zoo tickets').closest('[role="button"]') as HTMLElement);
|
||||
expect(onEditReservation).toHaveBeenCalledWith(tickets);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-030b: the linked reservation opens its editor (#2012)', () => {
|
||||
const onEditReservation = vi.fn();
|
||||
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
|
||||
@@ -1402,4 +1429,53 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.getByText('Eiffel Tower')).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Day-specific assignment note (#2163) ─────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-099: the assignment note renders as markdown under its own eyebrow', () => {
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: 'Book the **10:00** timed entry' }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': assignmentInDay }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Notes for this day')).toBeTruthy();
|
||||
const strong = Array.from(document.querySelectorAll('strong')).find(el => el.textContent === '10:00');
|
||||
expect(strong).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-100: without a note (or without a day in context) the block stays away', () => {
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
const { rerender } = render(
|
||||
<PlaceInspector {...defaultProps} selectedDayId={1} selectedAssignmentId={99} assignments={{ '1': assignmentInDay }} />
|
||||
);
|
||||
expect(screen.queryByText('Notes for this day')).toBeNull();
|
||||
// A note exists but no day is selected — nothing to attribute it to, so no block.
|
||||
rerender(
|
||||
<PlaceInspector {...defaultProps} selectedDayId={null} assignments={{ '1': [{ ...assignmentInDay[0], notes: 'hidden' }] }} />
|
||||
);
|
||||
expect(screen.queryByText('Notes for this day')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-101: the reservation strip opts out of the press-scale (#2158)', () => {
|
||||
// jsdom cannot replay the browser mechanics: the :active scale on the
|
||||
// composite strip slid the links inside out from under the pointer, so the
|
||||
// click landed elsewhere. The data-no-press attribute is the pin.
|
||||
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': assignmentInDay }}
|
||||
reservations={[reservation]}
|
||||
onEditReservation={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toHaveAttribute('data-no-press');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -6,12 +6,13 @@ import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { markdownLinkComponents } from '../shared/markdownLink'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route } from 'lucide-react'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route, StickyNote } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import PlaceAvatarUpload from '../shared/PlaceAvatarUpload'
|
||||
import PlaceRating from '../shared/StarRating'
|
||||
import TrackColorPicker from '../shared/TrackColorPicker'
|
||||
import { resolveTrackColor, inheritedTrackColor } from '../Map/trackColors'
|
||||
import { filesForPlace } from '../../utils/placeFiles'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
import StatusBadge from '../Collections/StatusBadge'
|
||||
import { mapsApi, pluginsApi } from '../../api/client'
|
||||
@@ -30,7 +31,7 @@ import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/f
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { formatDistance, formatElevation } from '../../utils/units'
|
||||
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
|
||||
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
|
||||
import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge'
|
||||
import { NavigationMenu } from '../shared/NavigationMenu'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
|
||||
import { convertHoursLine } from './placeHoursFormat'
|
||||
@@ -337,7 +338,8 @@ export default function PlaceInspector({
|
||||
const selectedDay = days?.find(d => d.id === selectedDayId)
|
||||
const weekdayIndex = getWeekdayIndex(selectedDay?.date, placeTimeZone)
|
||||
|
||||
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id) || (f.linked_place_ids || []).includes(place.id))
|
||||
// Its own files plus the ones on the bookings that hang on it (#2217).
|
||||
const placeFiles = filesForPlace(files, place.id, reservations, selectedAssignmentId != null ? [selectedAssignmentId] : [])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -428,6 +430,20 @@ export default function PlaceInspector({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Day-specific assignment note (#2163) — written via MCP/API or the
|
||||
edit form; distinct from the pool-wide place.notes above, so it
|
||||
carries an eyebrow saying which day-scope it belongs to. */}
|
||||
{assignmentInDay?.notes && (
|
||||
<div className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, padding: '8px 12px' }}>
|
||||
<div className="text-content-faint" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 3 }}>
|
||||
<StickyNote size={10} /> {t('places.assignmentNotes')}
|
||||
</div>
|
||||
<div className="collab-note-md text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: '1.5', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{assignmentInDay.notes}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reservation + Participants — trip-only (collections have no days) */}
|
||||
{mode === 'trip' && (
|
||||
<PlaceReservationParticipants selectedAssignmentId={selectedAssignmentId} reservations={reservations}
|
||||
@@ -825,92 +841,101 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
|
||||
return (
|
||||
<>
|
||||
{(() => {
|
||||
const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null
|
||||
const linked = getAssignmentReservations<Reservation>(reservations, selectedAssignmentId)
|
||||
const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null
|
||||
const currentParticipants = assignment?.participants || []
|
||||
const participantIds = currentParticipants.map(p => p.user_id)
|
||||
const allJoined = currentParticipants.length === 0
|
||||
const showParticipants = selectedAssignmentId && tripMembers.length > 1
|
||||
if (!res && !showParticipants) return null
|
||||
if (linked.length === 0 && !showParticipants) return null
|
||||
return (
|
||||
<div className={`grid ${res && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
|
||||
{/* Reservation */}
|
||||
{res && (() => {
|
||||
const confirmed = res.status === 'confirmed'
|
||||
// The strip summarised the booking but went nowhere, so its
|
||||
// attachments and fields had no route from the map (#2012).
|
||||
// A transport has its own form; picking by type is what the day
|
||||
// sidebar does, and an absent handler means this user may not
|
||||
// open this one — so the strip stays inert rather than lying.
|
||||
const editor = TRANSPORT_TYPES.has(res.type) ? onEditTransport : onEditReservation
|
||||
const open = editor ? () => editor(res) : undefined
|
||||
return (
|
||||
<div
|
||||
role={open ? 'button' : undefined}
|
||||
aria-label={open ? t('inspector.editRes') : undefined}
|
||||
tabIndex={open ? 0 : undefined}
|
||||
onClick={open}
|
||||
onKeyDown={open ? (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open() }
|
||||
} : undefined}
|
||||
title={open ? t('inspector.editRes') : undefined}
|
||||
style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}`, cursor: open ? 'pointer' : undefined, textAlign: 'left' }}
|
||||
>
|
||||
<div className={confirmed ? 'bg-[rgba(22,163,74,0.08)]' : 'bg-[rgba(217,119,6,0.08)]'} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px' }}>
|
||||
<div className={confirmed ? 'bg-[#16a34a]' : 'bg-[#d97706]'} style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0 }} />
|
||||
<span className={confirmed ? 'text-[#16a34a]' : 'text-[#d97706]'} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
|
||||
</div>
|
||||
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{(() => {
|
||||
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
return (
|
||||
<>
|
||||
{date && (
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
||||
</div>
|
||||
)}
|
||||
{(startTime || endTime) && (
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>
|
||||
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
|
||||
{endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
{res.confirmation_number && (
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{res.confirmation_number}</div>
|
||||
<div className={`grid ${linked.length > 0 && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
|
||||
{/* Bookings pinned to this stop; several can share one (#2201) */}
|
||||
{linked.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{linked.map(res => {
|
||||
const confirmed = res.status === 'confirmed'
|
||||
// The strip summarised the booking but went nowhere, so its
|
||||
// attachments and fields had no route from the map (#2012).
|
||||
// A transport has its own form; picking by type is what the day
|
||||
// sidebar does, and an absent handler means this user may not
|
||||
// open this one, so the strip stays inert rather than lying.
|
||||
const editor = TRANSPORT_TYPES.has(res.type) ? onEditTransport : onEditReservation
|
||||
const open = editor ? () => editor(res) : undefined
|
||||
return (
|
||||
<div
|
||||
key={res.id}
|
||||
role={open ? 'button' : undefined}
|
||||
// No press-scale on the composite strip: shrinking it
|
||||
// mid-click slides the links inside out from under the
|
||||
// pointer (#2158).
|
||||
data-no-press
|
||||
aria-label={open ? t('inspector.editRes') : undefined}
|
||||
tabIndex={open ? 0 : undefined}
|
||||
onClick={open}
|
||||
onKeyDown={open ? (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open() }
|
||||
} : undefined}
|
||||
title={open ? t('inspector.editRes') : undefined}
|
||||
style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}`, cursor: open ? 'pointer' : undefined, textAlign: 'left' }}
|
||||
>
|
||||
<div className={confirmed ? 'bg-[rgba(22,163,74,0.08)]' : 'bg-[rgba(217,119,6,0.08)]'} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px' }}>
|
||||
<div className={confirmed ? 'bg-[#16a34a]' : 'bg-[#d97706]'} style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0 }} />
|
||||
<span className={confirmed ? 'text-[#16a34a]' : 'text-[#d97706]'} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{res.notes && <div className="collab-note-md text-content-faint" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{res.notes}</Markdown></div>}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta || Object.keys(meta).length === 0) return null
|
||||
const parts: string[] = []
|
||||
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
|
||||
else if (meta.flight_number) parts.push(meta.flight_number)
|
||||
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport} → ${meta.arrival_airport}`)
|
||||
if (meta.train_number) parts.push(meta.train_number)
|
||||
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
|
||||
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
|
||||
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
|
||||
if (parts.length === 0) return null
|
||||
return <div className="text-content-muted" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500 }}>{parts.join(' · ')}</div>
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{(() => {
|
||||
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
return (
|
||||
<>
|
||||
{date && (
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
||||
</div>
|
||||
)}
|
||||
{(startTime || endTime) && (
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>
|
||||
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
|
||||
{endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
{res.confirmation_number && (
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{res.confirmation_number}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{res.notes && <div className="collab-note-md text-content-faint" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{res.notes}</Markdown></div>}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta || Object.keys(meta).length === 0) return null
|
||||
const parts: string[] = []
|
||||
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
|
||||
else if (meta.flight_number) parts.push(meta.flight_number)
|
||||
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport} → ${meta.arrival_airport}`)
|
||||
if (meta.train_number) parts.push(meta.train_number)
|
||||
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
|
||||
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
|
||||
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
|
||||
if (parts.length === 0) return null
|
||||
return <div className="text-content-muted" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500 }}>{parts.join(' · ')}</div>
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{showParticipants && (
|
||||
|
||||
@@ -43,6 +43,9 @@ export function ListImportModal(S: SidebarState) {
|
||||
)}
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginBottom: 16 }}>
|
||||
{t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
|
||||
{/* Same box, same Share button: which screen the link came from is the URL's
|
||||
business, and until it said so nobody knew a route could be pasted here. */}
|
||||
{listImportProvider === 'google' ? <div style={{ marginTop: 4 }}>{t('places.googleDirHint')}</div> : null}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { buildAssignmentOptions } from './assignmentOptions'
|
||||
import AddressInput from './AddressInput'
|
||||
import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2, ParkingSquare } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -32,31 +33,6 @@ const TYPE_OPTIONS = [
|
||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
|
||||
]
|
||||
|
||||
function buildAssignmentOptions(days, assignments, t, locale) {
|
||||
const options = []
|
||||
for (const day of (days || [])) {
|
||||
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
if (da.length === 0) continue
|
||||
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
|
||||
const groupLabel = `${dayLabel}${dateStr}`
|
||||
options.push({ value: `_header_${day.id}`, label: groupLabel, disabled: true, isHeader: true })
|
||||
for (let i = 0; i < da.length; i++) {
|
||||
const place = da[i].place
|
||||
if (!place) continue
|
||||
const timeStr = place.place_time ? ` · ${place.place_time}${place.end_time ? ' – ' + place.end_time : ''}` : ''
|
||||
options.push({
|
||||
value: da[i].id,
|
||||
label: ` ${i + 1}. ${place.name}${timeStr}`,
|
||||
searchLabel: place.name,
|
||||
groupLabel,
|
||||
dayDate: day.date || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
interface ReservationModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
@@ -278,8 +254,16 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
// Hotels link a place through the accommodation record; every other type links
|
||||
// the picked trip place/activity directly on the reservation (#1353).
|
||||
place_id: form.type === 'hotel' ? null : (form.place_id || null),
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
endpoints: [],
|
||||
// An empty object, not null: null clears the column outright, and that
|
||||
// took the mirrored booking price with it on every edit of a type that
|
||||
// fills no metadata of its own — restaurant, event, tour, parking, other,
|
||||
// a hotel without check-in times (#2233). An object still clears what the
|
||||
// form dropped, and lets the server carry the price across.
|
||||
metadata,
|
||||
// Omitted on an edit: the server replaces the endpoint set whenever the
|
||||
// key is present, and this form never edits endpoints, so sending an
|
||||
// empty list would drop a transit booking's stations (#2216).
|
||||
...(reservation?.id ? {} : { endpoints: [] }),
|
||||
needs_review: false,
|
||||
}
|
||||
if (form.type === 'hotel' && (form.hotel_start_day || form.hotel_end_day)) {
|
||||
|
||||
@@ -614,6 +614,9 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
|
||||
className="bg-surface-card"
|
||||
onClick={() => onOpen(r)}
|
||||
role="button"
|
||||
// No press-scale on the card — it would shift the nested controls out
|
||||
// from under the pointer mid-click (#2158).
|
||||
data-no-press
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onOpen(r) } }}
|
||||
style={{ borderRadius: 12, border: '1px solid rgba(124,58,237,0.22)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 9, cursor: 'pointer', transition: 'box-shadow 0.15s ease' }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-TRANSITJOURNEY-001 to 016 — the journey view for a saved transit entry.
|
||||
// FE-PLANNER-TRANSITJOURNEY-001 to 018 — the journey view for a saved transit entry.
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
@@ -113,6 +113,21 @@ describe('TransitJourneyModal', () => {
|
||||
expect(onChangeRoute).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITJOURNEY-017: edit details hands off to the full transport editor', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onEditDetails = vi.fn()
|
||||
render(<TransitJourneyModal {...makeProps({ onEditDetails })} />)
|
||||
await user.click(screen.getByRole('button', { name: /Edit details/ }))
|
||||
expect(onEditDetails).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITJOURNEY-018: edit details stays hidden without the callback or edit rights', () => {
|
||||
const { rerender } = render(<TransitJourneyModal {...makeProps()} />)
|
||||
expect(screen.queryByRole('button', { name: /Edit details/ })).not.toBeInTheDocument()
|
||||
rerender(<TransitJourneyModal {...makeProps({ onEditDetails: vi.fn(), canEdit: false })} />)
|
||||
expect(screen.queryByRole('button', { name: /Edit details/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITJOURNEY-004: delete asks for confirmation, then calls onDelete', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDelete = vi.fn().mockResolvedValue({})
|
||||
|
||||
@@ -15,7 +15,9 @@ import type { Reservation } from '../../types'
|
||||
* The journey view for an automated public-transit entry (#1065): a roomy modal
|
||||
* around the stop-by-stop itinerary. The title renames inline right in the
|
||||
* header, notes get the full width with markdown support, and "Change route"
|
||||
* re-enters the transit search pre-seeded with this journey's route.
|
||||
* re-enters the transit search pre-seeded with this journey's route. "Edit
|
||||
* details" hands off to the full transport editor for the booking fields
|
||||
* (travelers, costs, files, code, status — #2148).
|
||||
*/
|
||||
|
||||
interface TransitLegMeta {
|
||||
@@ -38,10 +40,12 @@ interface TransitJourneyModalProps {
|
||||
onSave: (fields: { title: string; notes: string | null }) => Promise<unknown>
|
||||
onDelete: () => Promise<unknown>
|
||||
onChangeRoute: () => void
|
||||
/** Switch to the full transport editor (travelers, costs, files, code, status). */
|
||||
onEditDetails?: () => void
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
export default function TransitJourneyModal({ reservation, onClose, onSave, onDelete, onChangeRoute, canEdit }: TransitJourneyModalProps) {
|
||||
export default function TransitJourneyModal({ reservation, onClose, onSave, onDelete, onChangeRoute, onEditDetails, canEdit }: TransitJourneyModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h'
|
||||
@@ -160,6 +164,15 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
|
||||
<RefreshCw size={13} /> {t('transit.changeRoute')}
|
||||
</button>
|
||||
)}
|
||||
{canEdit && onEditDetails && (
|
||||
<button type="button" onClick={onEditDetails} className="text-content-muted" style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 16px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Pencil size={13} /> {t('transit.editDetails')}
|
||||
</button>
|
||||
)}
|
||||
{canEdit ? (
|
||||
<button type="button" onClick={save} disabled={saving || !title.trim() || !dirty} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{
|
||||
padding: '8px 20px', borderRadius: 10, border: 'none',
|
||||
|
||||
@@ -175,6 +175,29 @@ describe('TransitSearchPanel', () => {
|
||||
await user.click(await screen.findByText(/London Victoria/))
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty result is ambiguous without the backend's name: the fallback to
|
||||
* Transitous is silent, so "no connections" could mean the provider has no
|
||||
* data here or that the provider you picked never ran (#1699).
|
||||
*/
|
||||
it('FE-PLANNER-TRANSIT-006b: the empty state names the backend that answered', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [], provider: 'transitous' })
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
await pickFromAndLondon(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
expect(await screen.findByText(/No connections found via Transitous/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-006c: falls back to the unnamed message when the server sends no provider', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
await pickFromAndLondon(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
expect(await screen.findByText(/^No connections found\./)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-007: depart-by anchors the entered time to the origin timezone', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Day, Place, Accommodation } from '../../types'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
|
||||
@@ -36,6 +37,14 @@ interface TransitPlaceResult { name: string; lat: number; lng: number; type: str
|
||||
|
||||
export interface PickedPlace { name: string; lat: number; lng: number }
|
||||
|
||||
// Backend names as they are written, not translated — the empty state says
|
||||
// which one answered so "nothing here" can be told apart from "not the one you
|
||||
// picked" (#1699).
|
||||
const PROVIDER_NAMES: Record<TransitProvider, string> = {
|
||||
transitous: 'Transitous',
|
||||
google: 'Google',
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const MODE_GROUPS: { key: string; labelKey: string; Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; modes: string }[] = [
|
||||
@@ -340,7 +349,7 @@ interface TransitSearchPanelProps {
|
||||
}
|
||||
|
||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t, language } = useTranslation()
|
||||
const toast = useToast()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
@@ -355,6 +364,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
|
||||
const [addingIdx, setAddingIdx] = useState<number | null>(null)
|
||||
const [provider, setProvider] = useState<TransitProvider | null>(null)
|
||||
|
||||
// Quick picks: the day's located places, plus the trip's located accommodations.
|
||||
const quickPicks = useMemo<PickedPlace[]>(() => {
|
||||
@@ -387,6 +397,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
setLoading(true)
|
||||
setItineraries(null)
|
||||
setExpandedIdx(null)
|
||||
setProvider(null)
|
||||
try {
|
||||
const tzFrom = tzAt(from.lat, from.lng)
|
||||
const tzTo = tzAt(to.lat, to.lng)
|
||||
@@ -395,7 +406,11 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
const timeIso = localToUtcIso(day.date, time, arriveBy ? tzTo : tzFrom)
|
||||
const allModes = activeModes.size === MODE_GROUPS.length
|
||||
const modes = allModes ? undefined : MODE_GROUPS.filter(m => activeModes.has(m.key)).map(m => m.modes).join(',')
|
||||
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes })
|
||||
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes, lang: language })
|
||||
// Which backend actually answered (#1699). Named in the empty state so
|
||||
// "nothing found" can be told apart from "the provider you picked never
|
||||
// ran" — the fallback to Transitous is silent by design.
|
||||
setProvider(d.provider ?? null)
|
||||
// MOTIS names the request coordinates START/END — swap in the places the
|
||||
// user actually picked so walks read "Walk to Zoologischer Garten".
|
||||
const cleanStop = (n: string) => (n === 'START' ? from.name : n === 'END' ? to.name : n)
|
||||
@@ -606,7 +621,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
)}
|
||||
{!loading && ranked && ranked.length === 0 && (
|
||||
<div className="text-content-faint" style={{ textAlign: 'center', padding: '24px 0', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{t('transit.noResults')}
|
||||
{provider
|
||||
? t('transit.noResultsVia', { provider: PROVIDER_NAMES[provider] })
|
||||
: t('transit.noResults')}
|
||||
</div>
|
||||
)}
|
||||
{!loading && ranked && ranked.length > 0 && (
|
||||
|
||||
@@ -1192,6 +1192,93 @@ describe('TransportModal', () => {
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-058: a car booking keeps its stops in order between pick-up and return (#1797)', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen Berlin → Prag');
|
||||
// Pick-up and return are the rental frame; the stops sit between them.
|
||||
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
// Pick-up and return come first in the form, the stops follow — so the saved
|
||||
// route is from · stop · stop · to even though the fields read in another order.
|
||||
const fields = screen.getAllByTestId('location-select');
|
||||
expect(fields).toHaveLength(4);
|
||||
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
||||
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
||||
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
|
||||
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'stop', 'to']);
|
||||
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
|
||||
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Dresden', 'Bastei', 'Prag']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-060: the stops of a car booking can be put in another order', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
|
||||
const fields = screen.getAllByTestId('location-select');
|
||||
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
||||
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
||||
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
|
||||
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
|
||||
|
||||
// The order of the stops is the order they are driven, and `sequence` is derived
|
||||
// from it on save — so without this the only way to swap two was to retype both.
|
||||
await userEvent.click(screen.getAllByRole('button', { name: /Move down/i })[0]);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Bastei', 'Dresden', 'Prag']);
|
||||
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-061: the ends of the stop list cannot be pushed past themselves', async () => {
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
// A single stop has nothing to swap with, so the buttons stay away entirely.
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
expect(screen.queryByRole('button', { name: /Move up/i })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
const up = screen.getAllByRole('button', { name: /Move up/i });
|
||||
const down = screen.getAllByRole('button', { name: /Move down/i });
|
||||
expect(up[0]).toBeDisabled();
|
||||
expect(down[down.length - 1]).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-059: a stop removed from a car booking is gone from the saved route', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
expect(screen.getAllByTestId('location-select')).toHaveLength(3);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Remove stop/i }));
|
||||
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
|
||||
|
||||
const fields = screen.getAllByTestId('location-select');
|
||||
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
||||
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-057: the open-file link on an attached document does not throw', async () => {
|
||||
const res = buildReservation({ id: 23, type: 'flight', title: 'LH 400' });
|
||||
const attached = buildTripFile({ id: 72, original_name: 'boarding.pdf' });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user