mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
129 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 515398f8ee | |||
| e4f2eaf336 | |||
| 16fee0800d | |||
| 888cd4ee08 | |||
| 8fe90d1783 | |||
| 4cda3c4b2e | |||
| bc85ec1eea | |||
| 04c22b34af | |||
| 56a23cb809 | |||
| 251378be1d | |||
| 973ce5f8ff | |||
| bbc32310e9 | |||
| ad6e51c574 | |||
| 6da11994d9 | |||
| eef1f16836 | |||
| 4a8d77c59c | |||
| a70c2eb9e0 | |||
| 99b040f684 | |||
| 944e0a0970 | |||
| b9f69b0c76 | |||
| fcefc7e771 | |||
| 6f0a171c74 | |||
| 3e1c06a0ee | |||
| 1aafab83a5 | |||
| c1d0435480 | |||
| 798859517f | |||
| d3fdf78db6 | |||
| 1b71b8633d | |||
| d77f1c1758 | |||
| e09c0688ae | |||
| 0e66ae72ca | |||
| 61bef334d5 | |||
| fa406b708f | |||
| 09ce5cb733 | |||
| 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 | |||
| 33a33e7b1d | |||
| 376332d989 | |||
| 43fbee94f9 | |||
| 0b2cf1c5ef | |||
| 241f152347 | |||
| 8c85323d6d | |||
| 8635bec507 | |||
| 57637cb447 | |||
| df9e2d542a | |||
| 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.1.0
|
||||
version: 4.2.1
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.1.0"
|
||||
appVersion: "4.2.1"
|
||||
|
||||
+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.0",
|
||||
"version": "4.2.1",
|
||||
"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
|
||||
|
||||
+47
-16
@@ -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'
|
||||
@@ -316,24 +316,46 @@ export default function App() {
|
||||
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_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 +402,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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+59
-25
@@ -30,7 +30,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 +53,12 @@ import {
|
||||
type StorageConfigPut,
|
||||
type StorageTestResponse,
|
||||
type StorageUsage,
|
||||
type PluginSettingsField,
|
||||
type PluginInstanceConfigResponse,
|
||||
type PluginInstanceConfigUpdated,
|
||||
type PluginActionDescriptor,
|
||||
type PluginActionResult,
|
||||
type PluginInstallRequest,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
@@ -122,7 +128,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 +214,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 +432,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),
|
||||
@@ -467,6 +484,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 +554,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 +575,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.
|
||||
@@ -782,16 +812,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 +889,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).
|
||||
@@ -1108,7 +1135,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')),
|
||||
}
|
||||
@@ -1220,15 +1248,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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,7 +8,7 @@ import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
|
||||
import type { DistanceUnit, Place } from '../../types'
|
||||
import { normalizeTileUrl } from '../../utils/tileUrl'
|
||||
import { normalizeTileUrl, withTileApiKey } from '../../utils/tileUrl'
|
||||
import {
|
||||
MAPBOX_DEFAULT_STYLE,
|
||||
defaultStyleForProvider,
|
||||
@@ -378,7 +378,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
onMapContextMenu: null,
|
||||
center: [48.8566, 2.3522],
|
||||
zoom: 10,
|
||||
tileUrl: mapTileUrl,
|
||||
// Same as the user-facing map tab: the field holds what is being
|
||||
// edited, so the key goes back on before the preview resolves it.
|
||||
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -5,6 +5,7 @@ import { act, fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { buildPlace, buildReservation } from '../../../tests/helpers/factories'
|
||||
import { MAP_MAX_ZOOM } from '../../constants/mapDefaults'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import * as photoService from '../../services/photoService'
|
||||
@@ -35,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(),
|
||||
}))
|
||||
@@ -48,9 +50,10 @@ vi.mock('../../hooks/useGeolocation', () => ({
|
||||
const thumbCallbacks = vi.hoisted(() => new Map<string, (thumb: string) => void>())
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
// center/zoom are surfaced so tests can assert the camera the map is built with.
|
||||
MapContainer: ({ children, center, zoom }: any) => (
|
||||
<div data-testid="map-container" data-center={JSON.stringify(center)} data-zoom={zoom}>{children}</div>
|
||||
// center/zoom are surfaced so tests can assert the camera the map is built
|
||||
// with; maxZoom because a cluster refuses to attach to a map without one.
|
||||
MapContainer: ({ children, center, zoom, maxZoom }: any) => (
|
||||
<div data-testid="map-container" data-center={JSON.stringify(center)} data-zoom={zoom} data-maxzoom={maxZoom}>{children}</div>
|
||||
),
|
||||
TileLayer: () => <div data-testid="tile-layer" />,
|
||||
Marker: ({ children, eventHandlers, position, icon, zIndexOffset }: any) => (
|
||||
@@ -245,6 +248,14 @@ describe('MapView', () => {
|
||||
expect(screen.getByTestId('cluster-group')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-010b: the map carries a zoom ceiling of its own, whatever the basemap is', () => {
|
||||
// Without it, a vector basemap leaves the map with none and the cluster
|
||||
// above throws on attach, taking the planner to the error boundary. The
|
||||
// ceiling has to sit on the map because only a GridLayer can contribute one.
|
||||
render(<MapView places={[buildMapPlace({ lat: 48.8584, lng: 2.2945 })]} />)
|
||||
expect(screen.getByTestId('map-container').getAttribute('data-maxzoom')).toBe(String(MAP_MAX_ZOOM))
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-011: renders the route polyline; travel times are no longer drawn on the map', () => {
|
||||
const route = [[[48.0, 2.0], [49.0, 3.0]]] as unknown as [number, number][][]
|
||||
render(<MapView route={route} />)
|
||||
|
||||
@@ -19,7 +19,7 @@ import { escapeHtml } from '@trek/shared'
|
||||
import type { Day, Reservation, RouteVia } from '../../types'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, attributionForTile } from '../../constants/mapDefaults'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, attributionForTile } from '../../constants/mapDefaults'
|
||||
import { resolveBasemap } from '../../utils/tileUrl'
|
||||
import VectorBasemap from './VectorBasemap'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -802,7 +802,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
|
||||
@@ -832,6 +832,12 @@ export const MapView = memo(function MapView({
|
||||
center={initialView.center}
|
||||
zoom={initialView.zoom}
|
||||
zoomControl={false}
|
||||
// On the map itself, not left to the base layer. Leaflet reads its zoom
|
||||
// ceiling from the map options or, failing that, from a GridLayer that
|
||||
// brought one; a vector basemap is neither, so a map drawn by
|
||||
// VectorBasemap had no ceiling at all. MarkerClusterGroup.onAdd throws
|
||||
// outright on an infinite one, which took the whole planner down.
|
||||
maxZoom={MAP_MAX_ZOOM}
|
||||
className="w-full h-full bg-[#e5e7eb]"
|
||||
>
|
||||
{/* The basemap is a vector style by default and a raster template when the
|
||||
@@ -935,6 +941,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)) => {}),
|
||||
}))
|
||||
|
||||
@@ -509,7 +509,7 @@ export function MapViewGL({
|
||||
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
|
||||
@@ -1548,6 +1548,7 @@ export function MapViewGL({
|
||||
<LocationButton
|
||||
mode={trackingMode}
|
||||
error={trackingError}
|
||||
errorCode={trackingErrorCode}
|
||||
onClick={cycleTrackingMode}
|
||||
bottomOffset={buttonBottom as unknown as number}
|
||||
/>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* The zoom ceiling a Leaflet map needs before a marker cluster will attach.
|
||||
*
|
||||
* This is a test about Leaflet's own semantics rather than about our components,
|
||||
* because that semantic is what a basemap change quietly broke: switching the
|
||||
* default basemap to a vector style replaced the TileLayer with a GL canvas, the
|
||||
* ceiling went with it, and MarkerClusterGroup.onAdd threw on the way in. The
|
||||
* planner and the map settings preview both went to the error boundary, which
|
||||
* left no screen from which to pick a different basemap.
|
||||
*
|
||||
* Nothing is mocked here on purpose. A mocked react-leaflet would have kept
|
||||
* passing throughout.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster'
|
||||
import { MAP_MAX_ZOOM } from '../../constants/mapDefaults'
|
||||
|
||||
/**
|
||||
* A layer that is not a GridLayer, which is what a vector basemap amounts to:
|
||||
* maplibre-gl-leaflet hands back an L.Layer holding a canvas, so it has no
|
||||
* beforeAdd and never reaches Leaflet's _addZoomLimit.
|
||||
*/
|
||||
const GlCanvasLayer = L.Layer.extend({ onAdd: () => undefined, onRemove: () => undefined })
|
||||
function glCanvasLayer(): L.Layer {
|
||||
return new GlCanvasLayer() as L.Layer
|
||||
}
|
||||
|
||||
/**
|
||||
* `leaflet.markercluster` ships no types and we do not depend on
|
||||
* `@types/leaflet.markercluster`, so the one factory this file needs is named
|
||||
* here rather than pulling a package in for a single call.
|
||||
*/
|
||||
const markerClusterGroup = (L as unknown as { markerClusterGroup: () => L.Layer }).markerClusterGroup
|
||||
|
||||
const containers: HTMLElement[] = []
|
||||
|
||||
function mapOn(options: L.MapOptions): L.Map {
|
||||
const el = document.createElement('div')
|
||||
Object.defineProperty(el, 'clientWidth', { value: 800 })
|
||||
Object.defineProperty(el, 'clientHeight', { value: 600 })
|
||||
document.body.appendChild(el)
|
||||
containers.push(el)
|
||||
return L.map(el, { center: [48.85, 2.35], zoom: 12, ...options })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const el of containers.splice(0)) el.remove()
|
||||
})
|
||||
|
||||
describe('the zoom ceiling of a Leaflet map', () => {
|
||||
it('MAPZOOM-001: a vector basemap contributes none, so the map has none', () => {
|
||||
const map = mapOn({})
|
||||
glCanvasLayer().addTo(map)
|
||||
// Only a GridLayer feeds Leaflet's _layersMaxZoom, via its beforeAdd hook.
|
||||
expect(map.getMaxZoom()).toBe(Infinity)
|
||||
})
|
||||
|
||||
it('MAPZOOM-002: a raster basemap contributes one, which is why raster maps never broke', () => {
|
||||
const map = mapOn({})
|
||||
L.tileLayer('https://example.test/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map)
|
||||
expect(map.getMaxZoom()).toBe(19)
|
||||
})
|
||||
|
||||
it('MAPZOOM-003: a cluster refuses to attach to a map with no ceiling', () => {
|
||||
const map = mapOn({})
|
||||
glCanvasLayer().addTo(map)
|
||||
// The throw is in onAdd, before any clustering, so an empty trip hits it too.
|
||||
expect(() => markerClusterGroup().addTo(map)).toThrow(/maxZoom/)
|
||||
})
|
||||
|
||||
it('MAPZOOM-004: the ceiling on the map itself carries every basemap kind', () => {
|
||||
const map = mapOn({ maxZoom: MAP_MAX_ZOOM })
|
||||
glCanvasLayer().addTo(map)
|
||||
expect(map.getMaxZoom()).toBe(MAP_MAX_ZOOM)
|
||||
expect(() => markerClusterGroup().addTo(map)).not.toThrow()
|
||||
})
|
||||
|
||||
it('MAPZOOM-005: it does not lower the raster and satellite layers, which already sat at 19', () => {
|
||||
const map = mapOn({ maxZoom: MAP_MAX_ZOOM })
|
||||
L.tileLayer('https://example.test/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map)
|
||||
expect(map.getMaxZoom()).toBe(19)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -442,6 +442,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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -5,7 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { 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'}
|
||||
|
||||
@@ -19,6 +19,11 @@ 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
|
||||
}
|
||||
|
||||
export function isGoogleMapsUrl(input: string): boolean {
|
||||
|
||||
@@ -143,6 +143,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
notes: place.notes || '',
|
||||
transport_mode: place.transport_mode || 'walking',
|
||||
website: place.website || '',
|
||||
// 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({
|
||||
@@ -471,13 +474,22 @@ 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,
|
||||
_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).
|
||||
@@ -924,6 +936,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 && (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -543,7 +543,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
location: null,
|
||||
confirmation_number: form.confirmation_number || null,
|
||||
notes: form.notes || null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
// 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,
|
||||
endpoints,
|
||||
needs_review: false,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Assignment, Day, TranslationFn } from '../../types'
|
||||
|
||||
/** Day and month only, the shape the booking dialog has always shown here. */
|
||||
function dayBadge(dateStr: string, locale?: string): string {
|
||||
return new Date(dateStr + 'T00:00:00Z')
|
||||
.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
export interface AssignmentOption {
|
||||
value: number | string
|
||||
label: string
|
||||
searchLabel?: string
|
||||
groupLabel?: string
|
||||
dayDate?: string | null
|
||||
disabled?: boolean
|
||||
isHeader?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The day plan as a flat option list: a disabled header per day, then its stops
|
||||
* in plan order. Shared by the desktop booking dialog and the phone sheet so a
|
||||
* booking can be linked to the same stop from either (#2216).
|
||||
*/
|
||||
export function buildAssignmentOptions(
|
||||
days: Day[] | undefined,
|
||||
assignments: Record<string, Assignment[]> | undefined,
|
||||
t: TranslationFn,
|
||||
locale?: string,
|
||||
): AssignmentOption[] {
|
||||
const options: AssignmentOption[] = []
|
||||
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 ? ` · ${dayBadge(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
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getNavigationTargets, showsAppleMaps } from './placeNavigation'
|
||||
import { getNavigationTargets, openNavigationTarget, showsAppleMaps } from './placeNavigation'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
// FE-PLANNER-NAV-001 to FE-PLANNER-NAV-008
|
||||
@@ -105,3 +105,42 @@ describe('getNavigationTargets', () => {
|
||||
expect(targets.find(t => t.id === 'waze')!.url).toBe('https://waze.com/ul?ll=48.2038,16.3616&navigate=yes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('openNavigationTarget', () => {
|
||||
const target = { id: 'google', label: 'Google Maps', url: 'https://maps.google.com/?q=1,2' } as const
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
|
||||
|
||||
function stubDisplayMode(installed: boolean, iosStandalone = false) {
|
||||
const open = vi.fn()
|
||||
const assigned: string[] = []
|
||||
vi.stubGlobal('window', {
|
||||
matchMedia: (q: string) => ({ matches: installed && q.includes('display-mode') }),
|
||||
navigator: { standalone: iosStandalone },
|
||||
open,
|
||||
get location() { return { get href() { return '' }, set href(v: string) { assigned.push(v) } } },
|
||||
})
|
||||
return { open, assigned }
|
||||
}
|
||||
|
||||
it('FE-PLANNER-NAV-101: a browser tab keeps opening a tab', () => {
|
||||
const { open, assigned } = stubDisplayMode(false)
|
||||
openNavigationTarget(target)
|
||||
expect(open).toHaveBeenCalledWith(target.url, '_blank', 'noopener,noreferrer')
|
||||
expect(assigned).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NAV-102: an installed app hands over from the current context, leaving no blank window (#2218)', () => {
|
||||
const { open, assigned } = stubDisplayMode(true)
|
||||
openNavigationTarget(target)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(assigned).toEqual([target.url])
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NAV-103: an iOS home-screen app is recognised without the display-mode query', () => {
|
||||
const { open, assigned } = stubDisplayMode(false, true)
|
||||
openNavigationTarget(target)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(assigned).toEqual([target.url])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,7 +95,28 @@ export function getNavigationTargets(
|
||||
return targets
|
||||
}
|
||||
|
||||
/** Opens a target the way every external link in the planner is opened. */
|
||||
/**
|
||||
* Hands the place over to a maps application.
|
||||
*
|
||||
* In a browser tab this opens a tab, which is what a link should do. In an
|
||||
* installed app it must not: the maps application takes over from the new
|
||||
* context before it paints, so what the user comes back to is an empty window
|
||||
* they have to dismiss before TREK is usable again (#2218). Navigating the
|
||||
* current context instead means the handover happens from the page the user is
|
||||
* already on, and returning lands them back where they were, because the app
|
||||
* shell was never replaced by the time the platform switched away.
|
||||
*/
|
||||
export function openNavigationTarget(target: NavigationTarget): void {
|
||||
window.open(target.url, '_blank', 'noopener,noreferrer')
|
||||
if (isInstalledApp()) window.location.href = target.url
|
||||
else window.open(target.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
/** True in a display mode that has no tab strip to close a stray window from. */
|
||||
function isInstalledApp(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
const standalone = ['standalone', 'fullscreen', 'minimal-ui'].some(
|
||||
mode => window.matchMedia?.(`(display-mode: ${mode})`).matches,
|
||||
)
|
||||
// iOS predates the display-mode query for home-screen apps.
|
||||
return standalone || (window.navigator as { standalone?: boolean }).standalone === true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The one set of rules the three plugin-settings forms (admin instance form, desktop user
|
||||
* tab, mobile user screen) share: how a form is seeded from stored values + manifest
|
||||
* defaults, which required fields block Save, and what a save patch carries.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PluginUserSettingField } from '../../api/client';
|
||||
import { findMissingRequired, SECRET_MASK, seedSettingsValues, settingsPatch } from './settingsForm';
|
||||
|
||||
const f = (over: Partial<PluginUserSettingField> & { key: string }): PluginUserSettingField =>
|
||||
({ label: over.key, input_type: 'text', required: false, secret: false, ...over }) as PluginUserSettingField;
|
||||
|
||||
describe('seedSettingsValues', () => {
|
||||
it('seeds a declared default when nothing is stored, and a stored value wins', () => {
|
||||
const fields = [
|
||||
f({ key: 'url', default: 'https://d.example' }),
|
||||
f({ key: 'retries', input_type: 'number', default: 3 }),
|
||||
];
|
||||
expect(seedSettingsValues(fields, {})).toEqual({ url: 'https://d.example', retries: '3' });
|
||||
expect(seedSettingsValues(fields, { url: 'https://mine.example' })).toEqual({
|
||||
url: 'https://mine.example',
|
||||
retries: '3',
|
||||
});
|
||||
});
|
||||
it('coerces a checkbox to a boolean (default or stored) and everything else to a string', () => {
|
||||
const fields = [
|
||||
f({ key: 'on', input_type: 'checkbox', default: true }),
|
||||
f({ key: 'off', input_type: 'checkbox' }),
|
||||
f({ key: 'n' }),
|
||||
];
|
||||
expect(seedSettingsValues(fields, {})).toEqual({ on: true, off: false, n: '' });
|
||||
expect(seedSettingsValues(fields, { on: false, n: 5 })).toEqual({ on: false, off: false, n: '5' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMissingRequired', () => {
|
||||
it('names the first required non-checkbox field that is blank (whitespace counts as blank)', () => {
|
||||
const fields = [f({ key: 'a', required: true }), f({ key: 'b', label: 'Bee', required: true })];
|
||||
expect(findMissingRequired(fields, { a: 'x', b: ' ' })?.key).toBe('b');
|
||||
expect(findMissingRequired(fields, { a: 'x', b: 'y' })).toBeUndefined();
|
||||
});
|
||||
it('exempts a required checkbox and treats an untouched secret (the mask) as filled', () => {
|
||||
const fields = [
|
||||
f({ key: 'consent', input_type: 'checkbox', required: true }),
|
||||
f({ key: 'tok', secret: true, required: true }),
|
||||
];
|
||||
expect(findMissingRequired(fields, { consent: false, tok: SECRET_MASK })).toBeUndefined();
|
||||
expect(findMissingRequired(fields, { consent: false, tok: '' })?.key).toBe('tok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('settingsPatch', () => {
|
||||
it('sends every field except an untouched secret still showing the mask', () => {
|
||||
const fields = [f({ key: 'tok', secret: true }), f({ key: 'url' }), f({ key: 'on', input_type: 'checkbox' })];
|
||||
expect(settingsPatch(fields, { tok: SECRET_MASK, url: 'u', on: true })).toEqual({ url: 'u', on: true });
|
||||
expect(settingsPatch(fields, { tok: 'new', url: 'u', on: false })).toEqual({ tok: 'new', url: 'u', on: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Shared rules for the plugin-settings forms — the admin instance form
|
||||
* (useInstanceSettings), the desktop user tab (PluginSettingsTab) and the mobile user
|
||||
* screen (MSettingsPlugins) all render the same `fields` + `config` contract and used to
|
||||
* each hand-copy these loops, which is how the mobile screen missed `default` and the
|
||||
* required check.
|
||||
*/
|
||||
import type { PluginUserSettingField } from '../../api/client';
|
||||
|
||||
/** What the server echoes in place of a stored secret; sending it back means "unchanged". */
|
||||
export const SECRET_MASK = '••••••••';
|
||||
|
||||
export type SettingsValues = Record<string, string | boolean>;
|
||||
|
||||
/**
|
||||
* Form draft from the stored config, falling back to each field's manifest `default`
|
||||
* when nothing is stored (the same `stored ?? default` rule the runtime applies, so the
|
||||
* form shows the value the plugin actually gets). Checkboxes become booleans, everything
|
||||
* else a string.
|
||||
*/
|
||||
export function seedSettingsValues(fields: PluginUserSettingField[], config: Record<string, unknown>): SettingsValues {
|
||||
const values: SettingsValues = {};
|
||||
for (const f of fields) {
|
||||
const stored = config[f.key];
|
||||
const seeded = stored === undefined || stored === null ? f.default : stored;
|
||||
values[f.key] = f.input_type === 'checkbox' ? seeded === true : seeded == null ? '' : String(seeded);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first `required` field the draft leaves blank, or undefined when Save may proceed.
|
||||
* Mirrors the server gate: a checkbox is exempt (required would mean "must be true", a
|
||||
* consent flow, not a setting), whitespace is blank, an untouched secret (still the mask)
|
||||
* is filled.
|
||||
*/
|
||||
export function findMissingRequired(
|
||||
fields: PluginUserSettingField[],
|
||||
values: SettingsValues
|
||||
): PluginUserSettingField | undefined {
|
||||
return fields.find((f) => {
|
||||
if (!f.required || f.input_type === 'checkbox') return false;
|
||||
const v = values[f.key];
|
||||
if (f.secret && v === SECRET_MASK) return false;
|
||||
return String(v ?? '').trim() === '';
|
||||
});
|
||||
}
|
||||
|
||||
/** The save body: every field's draft value, minus an untouched secret so the mask never overwrites the stored ciphertext. */
|
||||
export function settingsPatch(fields: PluginUserSettingField[], values: SettingsValues): Record<string, unknown> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
const v = values[f.key];
|
||||
if (f.secret && v === SECRET_MASK) continue;
|
||||
patch[f.key] = v;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import { buildUser, buildSettings } from '../../../tests/helpers/factories';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import MapSettingsTab from './MapSettingsTab';
|
||||
|
||||
// Mock MapView to avoid Leaflet DOM issues in jsdom
|
||||
// Mock MapView to avoid Leaflet DOM issues in jsdom. tileUrl is surfaced because
|
||||
// the preview has to draw the basemap being configured, key included.
|
||||
vi.mock('../Map/MapView', () => ({
|
||||
MapView: ({ onMapClick }: { onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void }) => (
|
||||
<div data-testid="map-view" onClick={() => onMapClick?.({ latlng: { lat: 51.5, lng: -0.1 } })} />
|
||||
MapView: ({ onMapClick, tileUrl }: { onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void; tileUrl?: string }) => (
|
||||
<div data-testid="map-view" data-tile-url={tileUrl} onClick={() => onMapClick?.({ latlng: { lat: 51.5, lng: -0.1 } })} />
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -394,6 +395,27 @@ describe('MapSettingsTab – CARTO key', () => {
|
||||
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ carto_api_key: 'demo-key' }));
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-033b: the preview draws the CARTO basemap being configured, not the app default', async () => {
|
||||
// The fields hold what the user is editing, not what useTileUrl resolved, so
|
||||
// the key has to be put back on before the preview reads the template.
|
||||
// Without it the preview resolves a keyless CARTO url, silently falls back to
|
||||
// the default vector style, and shows a basemap nobody chose.
|
||||
const user = userEvent.setup();
|
||||
seedStore(useSettingsStore, {
|
||||
settings: buildSettings({ map_tile_url: CARTO_DARK_TILES, carto_api_key: 'demo-key' }),
|
||||
});
|
||||
render(<MapSettingsTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('map-view').getAttribute('data-tile-url')).toContain('key=demo-key');
|
||||
});
|
||||
|
||||
await user.clear(cartoInput());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('map-view').getAttribute('data-tile-url')).not.toContain('key=');
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-034: a CARTO template without a key explains the watermark until one is typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useSettingsStore, {
|
||||
|
||||
@@ -11,6 +11,7 @@ import ErrorBoundary from '../shared/ErrorBoundary'
|
||||
import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../Map/glLazy'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { withTileApiKey } from '../../utils/tileUrl'
|
||||
import type { Place } from '../../types'
|
||||
import {
|
||||
MAPBOX_DEFAULT_STYLE,
|
||||
@@ -473,7 +474,12 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
onMarkerClick: null,
|
||||
onMapClick: null,
|
||||
onMapContextMenu: null,
|
||||
tileUrl: mapTileUrl,
|
||||
// With the key on it, or the preview resolves the template as a
|
||||
// keyless CARTO one and quietly shows the app default instead of
|
||||
// the basemap being configured. The fields hold what the user is
|
||||
// editing rather than what useTileUrl already resolved, so the key
|
||||
// has to be put back on here.
|
||||
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-OFFLINETAB-001 to FE-COMP-OFFLINETAB-024
|
||||
// FE-COMP-OFFLINETAB-001 to FE-COMP-OFFLINETAB-028
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -21,16 +21,17 @@ const h = vi.hoisted(() => {
|
||||
const syncMetaToArray = vi.fn();
|
||||
const tripsGet = vi.fn();
|
||||
const tripsToArray = vi.fn();
|
||||
const tripsCount = vi.fn();
|
||||
const placeCount = vi.fn();
|
||||
const fileCount = vi.fn();
|
||||
const counted = (fn: (id: number) => Promise<number>) => ({
|
||||
where: () => ({ equals: (id: number) => ({ count: () => fn(id) }) }),
|
||||
});
|
||||
return {
|
||||
syncMetaToArray, tripsGet, tripsToArray, placeCount, fileCount,
|
||||
syncMetaToArray, tripsGet, tripsToArray, tripsCount, placeCount, fileCount,
|
||||
fakeDb: {
|
||||
syncMeta: { toArray: syncMetaToArray },
|
||||
trips: { get: tripsGet, toArray: tripsToArray },
|
||||
trips: { get: tripsGet, toArray: tripsToArray, count: tripsCount },
|
||||
places: counted(placeCount),
|
||||
tripFiles: counted(fileCount),
|
||||
},
|
||||
@@ -104,6 +105,7 @@ const conflict = (over: Partial<QueuedMutation> = {}): QueuedMutation => ({
|
||||
/** Seed the Dexie cache with the given (trip, meta) pairs. */
|
||||
function cache(rows: { trip: Trip; meta?: Partial<SyncMeta>; places?: number; files?: number }[]): void {
|
||||
h.syncMetaToArray.mockResolvedValue(rows.map(r => meta(r.trip.id, r.meta)));
|
||||
h.tripsCount.mockResolvedValue(rows.length);
|
||||
h.tripsGet.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.trip);
|
||||
h.placeCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.places ?? 0);
|
||||
h.fileCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.files ?? 0);
|
||||
@@ -130,6 +132,7 @@ beforeEach(() => {
|
||||
h.syncMetaToArray.mockResolvedValue([]);
|
||||
h.tripsGet.mockResolvedValue(undefined);
|
||||
h.tripsToArray.mockResolvedValue([]);
|
||||
h.tripsCount.mockResolvedValue(0);
|
||||
h.placeCount.mockResolvedValue(0);
|
||||
h.fileCount.mockResolvedValue(0);
|
||||
h.pendingCount.mockResolvedValue(0);
|
||||
@@ -138,8 +141,8 @@ beforeEach(() => {
|
||||
h.clearAll.mockResolvedValue(undefined);
|
||||
h.clearTripData.mockResolvedValue(undefined);
|
||||
h.clearTileCache.mockResolvedValue(undefined);
|
||||
h.prepareForOffline.mockResolvedValue(undefined);
|
||||
h.syncAll.mockResolvedValue(undefined);
|
||||
h.prepareForOffline.mockResolvedValue({ status: 'done', trips: 1 });
|
||||
h.syncAll.mockResolvedValue({ status: 'done', trips: 1 });
|
||||
h.resolveKeepMine.mockResolvedValue(undefined);
|
||||
h.resolveKeepServer.mockResolvedValue(undefined);
|
||||
|
||||
@@ -284,6 +287,7 @@ describe('OfflineTab', () => {
|
||||
cb({ phase: 'files', current: 1, total: 4, label: 'Paris' });
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
cb({ phase: 'done', current: 4, total: 4 });
|
||||
return { status: 'done', trips: 4 };
|
||||
});
|
||||
render(<OfflineTab />);
|
||||
|
||||
@@ -294,7 +298,7 @@ describe('OfflineTab', () => {
|
||||
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
||||
|
||||
await act(async () => { release(); });
|
||||
expect(await screen.findByText('Ready for offline use')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Stored 4 trip(s) on this device')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -304,6 +308,7 @@ describe('OfflineTab', () => {
|
||||
h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => {
|
||||
cb({ phase: 'tiles', current: 0, total: 0 });
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
return { status: 'done', trips: 1 };
|
||||
});
|
||||
render(<OfflineTab />);
|
||||
|
||||
@@ -322,6 +327,7 @@ describe('OfflineTab', () => {
|
||||
h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => {
|
||||
cb({ phase: 'trips', current: 1, total: 4 });
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
return { status: 'done', trips: 4 };
|
||||
});
|
||||
render(<OfflineTab />);
|
||||
|
||||
@@ -380,7 +386,7 @@ describe('OfflineTab', () => {
|
||||
it('FE-COMP-OFFLINETAB-017: Re-sync shows the syncing label while it runs', async () => {
|
||||
const user = userEvent.setup();
|
||||
let release!: () => void;
|
||||
h.syncAll.mockImplementation(() => new Promise<void>(resolve => { release = resolve; }));
|
||||
h.syncAll.mockImplementation(() => new Promise(resolve => { release = () => resolve({ status: 'done', trips: 1 }); }));
|
||||
render(<OfflineTab />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
@@ -408,21 +414,69 @@ describe('OfflineTab', () => {
|
||||
expect(h.clearTileCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-019: switching a trip off evicts it, switching it back on re-syncs', async () => {
|
||||
it('FE-COMP-OFFLINETAB-019: switching a finished trip on pins and re-syncs it, switching it off evicts it', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OfflineTab />);
|
||||
|
||||
// Both fixture trips ended in 2025, so the date rule would skip them: the
|
||||
// switch has to read "off" rather than promising storage that never happens.
|
||||
const toggle = await screen.findByRole('button', { name: 'Tokyo' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getAllByText('Stored offline')).toHaveLength(2);
|
||||
|
||||
await user.click(toggle);
|
||||
expect(h.clearTripData).toHaveBeenCalledWith(2);
|
||||
await waitFor(() => expect(screen.getByText('Not stored')).toBeInTheDocument());
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getAllByText(/Not stored · Finished/)).toHaveLength(2);
|
||||
|
||||
await user.click(toggle);
|
||||
await waitFor(() => expect(h.syncAll).toHaveBeenCalledTimes(1));
|
||||
expect(h.clearTripData).toHaveBeenCalledTimes(1);
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([2]);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Tokyo' })).toHaveAttribute('aria-pressed', 'true'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Tokyo' }));
|
||||
expect(h.clearTripData).toHaveBeenCalledWith(2);
|
||||
// Turning it off releases the pin, so "off then on" cannot leave it pinned.
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-025: an ongoing trip is stored without needing a pin', async () => {
|
||||
const soon = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10);
|
||||
const ongoing = buildTrip({ id: 7, title: 'Lisbon', start_date: '2025-01-01', end_date: soon });
|
||||
server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [ongoing] })));
|
||||
render(<OfflineTab />);
|
||||
|
||||
const toggle = await screen.findByRole('button', { name: 'Lisbon' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText('Stored offline')).toBeInTheDocument();
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-026: a sync that never ran says so instead of reporting success', async () => {
|
||||
const user = userEvent.setup();
|
||||
h.prepareForOffline.mockResolvedValue({ status: 'skipped', reason: 'busy' });
|
||||
render(<OfflineTab />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
await user.click(screen.getByRole('button', { name: 'Download for offline use' }));
|
||||
|
||||
expect(await screen.findByText('A sync is already running. Try again in a moment.')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Stored .* trip/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-028: a download that throws is reported instead of leaving the last frame up', async () => {
|
||||
const user = userEvent.setup();
|
||||
h.prepareForOffline.mockRejectedValue(new Error('network went away'));
|
||||
render(<OfflineTab />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
await user.click(screen.getByRole('button', { name: 'Download for offline use' }));
|
||||
|
||||
expect(await screen.findByText('The download could not finish. Check your connection and try again.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-027: an unreadable offline database is reported, not shown as empty', async () => {
|
||||
h.syncMetaToArray.mockRejectedValue(new Error('DatabaseClosedError'));
|
||||
render(<OfflineTab />);
|
||||
|
||||
expect(await screen.findByText(/Could not read this device’s offline storage/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('No trips cached yet. Connect to the internet to sync.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-020: the per-trip section disappears when there are no trips at all', async () => {
|
||||
@@ -478,6 +532,7 @@ describe('OfflineTab', () => {
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Clear cache' })).toBeEnabled());
|
||||
h.syncMetaToArray.mockResolvedValue([]);
|
||||
h.tripsCount.mockResolvedValue(0);
|
||||
await user.click(screen.getByRole('button', { name: 'Clear cache' }));
|
||||
|
||||
expect(h.clearAll).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -6,33 +6,18 @@
|
||||
* - What to store: a map-tiles toggle plus a per-trip on/off.
|
||||
* - Sync conflicts: a keep-mine / keep-theirs resolver and a default strategy.
|
||||
* - Cache stats + clear.
|
||||
*
|
||||
* All of the logic lives in `useOfflineSettings`, shared with the phone twin
|
||||
* `MSettingsOffline`; this file is the desktop markup over it.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon, AlertTriangle } from 'lucide-react'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { offlineDb, clearAll, clearTripData } from '../../db/offlineDb'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { tripSyncManager, type PrepareProgress } from '../../sync/tripSyncManager'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import { clearTileCache } from '../../sync/tilePrefetcher'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import {
|
||||
getOfflinePrefs, setCacheTiles, setConflictStrategy,
|
||||
isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange,
|
||||
type ConflictStrategy,
|
||||
} from '../../sync/offlinePrefs'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { useOfflineSettings, offlineNoticeKey, isOfflineNoticeWarning } from './useOfflineSettings'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { SyncMeta, QueuedMutation } from '../../db/offlineDb'
|
||||
import type { Trip } from '../../types'
|
||||
|
||||
interface CachedTripRow {
|
||||
trip: Trip
|
||||
meta: SyncMeta
|
||||
placeCount: number
|
||||
fileCount: number
|
||||
}
|
||||
import type { ConflictStrategy } from '../../sync/offlinePrefs'
|
||||
import type { QueuedMutation } from '../../db/offlineDb'
|
||||
|
||||
function conflictName(m: QueuedMutation): string {
|
||||
const body = (m.body ?? {}) as { name?: unknown }
|
||||
@@ -44,132 +29,14 @@ function conflictName(m: QueuedMutation): string {
|
||||
|
||||
export default function OfflineTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const { offline, forced, setForced } = useNetworkMode()
|
||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||
const [allTrips, setAllTrips] = useState<Trip[]>([])
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
const [conflicts, setConflicts] = useState<QueuedMutation[]>([])
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [preparing, setPreparing] = useState(false)
|
||||
const [progress, setProgress] = useState<PrepareProgress | null>(null)
|
||||
const [prefs, setPrefs] = useState(getOfflinePrefs())
|
||||
|
||||
useEffect(() => onOfflinePrefsChange(() => setPrefs(getOfflinePrefs())), [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metas, pending, failed, conflictList] = await Promise.all([
|
||||
offlineDb.syncMeta.toArray(),
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
mutationQueue.conflicts(),
|
||||
])
|
||||
setPendingCount(pending)
|
||||
setFailedCount(failed)
|
||||
setConflicts(conflictList)
|
||||
|
||||
const result: CachedTripRow[] = []
|
||||
for (const meta of metas) {
|
||||
const trip = await offlineDb.trips.get(meta.tripId)
|
||||
if (!trip) continue
|
||||
const [placeCount, fileCount] = await Promise.all([
|
||||
offlineDb.places.where('trip_id').equals(meta.tripId).count(),
|
||||
offlineDb.tripFiles.where('trip_id').equals(meta.tripId).count(),
|
||||
])
|
||||
result.push({ trip, meta, placeCount, fileCount })
|
||||
}
|
||||
result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? ''))
|
||||
setRows(result)
|
||||
|
||||
// The per-trip storage toggles are driven by the FULL trip list, not just
|
||||
// the cached ones, so a trip turned off stays visible and re-enableable.
|
||||
try {
|
||||
const trips = isEffectivelyOffline()
|
||||
? await offlineDb.trips.toArray()
|
||||
: await tripsApi.list().then(r => (r as { trips: Trip[] }).trips).catch(() => offlineDb.trips.toArray())
|
||||
trips.sort((a, b) => (a.start_date ?? '').localeCompare(b.start_date ?? ''))
|
||||
setAllTrips(trips)
|
||||
} catch {
|
||||
setAllTrips([])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const runPrepare = useCallback(async () => {
|
||||
setPreparing(true)
|
||||
setProgress(null)
|
||||
try {
|
||||
await tripSyncManager.prepareForOffline(p => setProgress(p))
|
||||
await load()
|
||||
} finally {
|
||||
setPreparing(false)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
async function handleToggleForce() {
|
||||
if (!forced) {
|
||||
// Turning offline mode on: download everything first (while still online),
|
||||
// then engage so the app has all it needs before the network drops.
|
||||
if (navigator.onLine) await runPrepare()
|
||||
setForced(true)
|
||||
} else {
|
||||
// Back online: lifting the switch flushes the queue + re-syncs (syncTriggers).
|
||||
setForced(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResync() {
|
||||
setSyncing(true)
|
||||
try {
|
||||
await tripSyncManager.syncAll()
|
||||
await load()
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!window.confirm(t('settings.offline.clearConfirm'))) return
|
||||
setClearing(true)
|
||||
try {
|
||||
await clearAll()
|
||||
await load()
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTiles() {
|
||||
const next = !prefs.cacheTiles
|
||||
setCacheTiles(next)
|
||||
// Turning tiles off reclaims the bulk tile storage straight away.
|
||||
if (!next) await clearTileCache()
|
||||
}
|
||||
|
||||
async function handleToggleTrip(tripId: number) {
|
||||
const next = !isTripOfflineEnabled(tripId)
|
||||
setTripOfflineEnabled(tripId, next)
|
||||
if (!next) {
|
||||
await clearTripData(tripId)
|
||||
await load()
|
||||
} else if (navigator.onLine) {
|
||||
tripSyncManager.syncAll().then(load).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveConflict(id: string, keepMine: boolean) {
|
||||
if (keepMine) await mutationQueue.resolveKeepMine(id)
|
||||
else await mutationQueue.resolveKeepServer(id)
|
||||
await load()
|
||||
}
|
||||
const {
|
||||
offline, forced,
|
||||
rows, allTrips, pendingCount, failedCount, conflicts,
|
||||
syncing, clearing, loading, preparing, progress, notice, prefs, canClear,
|
||||
runPrepare, handleToggleForce, handleResync, handleClear,
|
||||
handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict,
|
||||
handleConflictStrategy,
|
||||
} = useOfflineSettings()
|
||||
|
||||
const formatDate = (d: string | null | undefined) =>
|
||||
d ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'
|
||||
@@ -237,9 +104,10 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!preparing && progress?.phase === 'done' && (
|
||||
<div className="text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 10, color: '#10b981' }}>
|
||||
<Check size={14} /> {t('settings.offline.prepare.done')}
|
||||
{!preparing && notice && notice.kind !== 'load-failed' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 10, color: isOfflineNoticeWarning(notice) ? 'var(--warning)' : 'var(--success)' }}>
|
||||
{isOfflineNoticeWarning(notice) ? <AlertTriangle size={14} /> : <Check size={14} />}
|
||||
{t(offlineNoticeKey(notice), notice.kind === 'stored' ? { count: notice.trips } : undefined)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -273,7 +141,7 @@ export default function OfflineTab(): React.ReactElement {
|
||||
control={
|
||||
<select
|
||||
value={prefs.conflictStrategy}
|
||||
onChange={e => setConflictStrategy(e.target.value as ConflictStrategy)}
|
||||
onChange={e => handleConflictStrategy(e.target.value as ConflictStrategy)}
|
||||
className="border border-edge bg-surface-secondary text-content"
|
||||
style={{ padding: '6px 10px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
|
||||
>
|
||||
@@ -302,7 +170,7 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{allTrips.map((trip) => {
|
||||
const on = isTripOfflineEnabled(trip.id)
|
||||
const { on, dateEligible } = tripStorageState(trip)
|
||||
return (
|
||||
<div key={trip.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
@@ -311,9 +179,10 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
|
||||
{on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')}
|
||||
{!dateEligible && ` · ${t('settings.offline.storage.tripFinished')}`}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip.id)} label={trip.title} />
|
||||
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip)} label={trip.title} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -335,10 +204,10 @@ export default function OfflineTab(): React.ReactElement {
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button"
|
||||
onClick={handleClear}
|
||||
disabled={clearing || rows.length === 0}
|
||||
onClick={() => { if (window.confirm(t('settings.offline.clearConfirm'))) void handleClear() }}
|
||||
disabled={clearing || !canClear}
|
||||
className="border border-edge bg-surface-secondary text-[#ef4444]"
|
||||
style={btnStyle(clearing || rows.length === 0)}
|
||||
style={btnStyle(clearing || !canClear)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.offline.clear')}
|
||||
@@ -349,7 +218,7 @@ export default function OfflineTab(): React.ReactElement {
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('settings.offline.loading')}</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{t('settings.offline.empty')}
|
||||
{t(notice?.kind === 'load-failed' ? 'settings.offline.notice.loadFailed' : 'settings.offline.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
|
||||
@@ -275,7 +275,7 @@ describe('PluginSettingsTab', () => {
|
||||
});
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-014: a plugin with only actions gets a card without a Save button', async () => {
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', hint: 'Pings the API', danger: false }] });
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', hint: 'Pings the API', danger: false, scope: 'user' }] });
|
||||
setPlugins([plugin()]);
|
||||
render(<PluginSettingsTab />);
|
||||
|
||||
@@ -288,7 +288,7 @@ describe('PluginSettingsTab', () => {
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-015: running an action shows the message it returns', async () => {
|
||||
const user = userEvent.setup();
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false }] });
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false, scope: 'user' }] });
|
||||
server.use(http.post('/api/plugin-settings/weather/actions/test', () =>
|
||||
HttpResponse.json({ ok: true, message: 'Reached the API' })));
|
||||
setPlugins([plugin()]);
|
||||
@@ -300,7 +300,7 @@ describe('PluginSettingsTab', () => {
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-016: a message-less result falls back to the generic ok/error labels', async () => {
|
||||
const user = userEvent.setup();
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false }] });
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false, scope: 'user' }] });
|
||||
server.use(http.post('/api/plugin-settings/weather/actions/test', () => HttpResponse.json({ ok: false })));
|
||||
setPlugins([plugin()]);
|
||||
render(<PluginSettingsTab />);
|
||||
@@ -311,7 +311,7 @@ describe('PluginSettingsTab', () => {
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-017: a failing action reports an error result', async () => {
|
||||
const user = userEvent.setup();
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false }] });
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false, scope: 'user' }] });
|
||||
server.use(http.post('/api/plugin-settings/weather/actions/test', () =>
|
||||
HttpResponse.json({ error: 'down' }, { status: 500 })));
|
||||
setPlugins([plugin()]);
|
||||
@@ -327,7 +327,7 @@ describe('PluginSettingsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
let ran = 0;
|
||||
serve('weather', { actions: [{ key: 'wipe', label: 'Wipe cache', danger: true }] });
|
||||
serve('weather', { actions: [{ key: 'wipe', label: 'Wipe cache', danger: true, scope: 'user' }] });
|
||||
server.use(http.post('/api/plugin-settings/weather/actions/wipe', () => {
|
||||
ran += 1;
|
||||
return HttpResponse.json({ ok: true, message: 'Wiped' });
|
||||
@@ -448,4 +448,67 @@ describe('PluginSettingsTab', () => {
|
||||
expect(await screen.findByDisplayValue('Trips')).toBeInTheDocument();
|
||||
expect(within(cardFor('Notes')).getByText('Folder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-026: pre-fills a declared default when no value is stored', async () => {
|
||||
serve('weather', {
|
||||
fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: true, default: 'https://auth.openbnb.org/authorize' }],
|
||||
config: {},
|
||||
});
|
||||
setPlugins([plugin()]);
|
||||
render(<PluginSettingsTab />);
|
||||
|
||||
expect(await screen.findByDisplayValue('https://auth.openbnb.org/authorize')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-027: a stored value wins over the default', async () => {
|
||||
serve('weather', {
|
||||
fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', default: 'https://auth.openbnb.org/authorize' }],
|
||||
config: { oauth_authorize_url: 'https://mine.example' },
|
||||
});
|
||||
setPlugins([plugin()]);
|
||||
render(<PluginSettingsTab />);
|
||||
|
||||
expect(await screen.findByDisplayValue('https://mine.example')).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('https://auth.openbnb.org/authorize')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-028: blocks Save while a required field is empty and names it', async () => {
|
||||
const user = userEvent.setup();
|
||||
let called = false;
|
||||
serve('weather', {
|
||||
fields: [{ key: 'client_id', label: 'Client ID', input_type: 'text', required: true }],
|
||||
config: {},
|
||||
});
|
||||
server.use(http.post('/api/plugin-settings/weather', () => {
|
||||
called = true;
|
||||
return HttpResponse.json({ config: {} });
|
||||
}));
|
||||
setPlugins([plugin()]);
|
||||
render(<><ToastContainer /><PluginSettingsTab /></>);
|
||||
|
||||
await screen.findByText('Client ID');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(await screen.findByText('"Client ID" is required')).toBeInTheDocument();
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-029: a 400 from the server names the field it refused; a 500 stays generic', async () => {
|
||||
// The field list was loaded before a plugin update made `city` required, so the
|
||||
// client pre-check passes and only the server knows — its message must reach the user.
|
||||
const user = userEvent.setup();
|
||||
serve('weather', {
|
||||
fields: [{ key: 'city', label: 'City', input_type: 'text' }],
|
||||
config: {},
|
||||
});
|
||||
server.use(http.post('/api/plugin-settings/weather', () =>
|
||||
HttpResponse.json({ error: 'Missing required setting "city"' }, { status: 400 })));
|
||||
setPlugins([plugin()]);
|
||||
render(<><ToastContainer /><PluginSettingsTab /></>);
|
||||
|
||||
await screen.findByText('City');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(await screen.findByText('Missing required setting "city"')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import PluginActivityPanel from './PluginActivityPanel'
|
||||
import { seedSettingsValues, findMissingRequired, settingsPatch } from '../Plugins/settingsForm'
|
||||
|
||||
/** Host-brokered OAuth: a Connect/Disconnect control. The host runs the whole flow +
|
||||
* holds the tokens; this only triggers connect (redirect to the provider) / disconnect. */
|
||||
@@ -51,7 +52,6 @@ function PluginOAuthSection({ id, state, setState }: {
|
||||
)
|
||||
}
|
||||
|
||||
const SECRET_MASK = '••••••••'
|
||||
|
||||
/**
|
||||
* A user's own per-plugin settings (#plugins). The host renders the plugin's
|
||||
@@ -77,12 +77,7 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
|
||||
if (!alive) return
|
||||
setFields(r.fields)
|
||||
setActions(r.actions ?? [])
|
||||
const init: Record<string, string | boolean> = {}
|
||||
for (const f of r.fields) {
|
||||
const v = r.config[f.key]
|
||||
init[f.key] = f.input_type === 'checkbox' ? v === true : (v == null ? '' : String(v))
|
||||
}
|
||||
setValues(init)
|
||||
setValues(seedSettingsValues(r.fields, r.config))
|
||||
})
|
||||
.catch(() => { if (alive) setFields([]) })
|
||||
pluginsApi.oauthStatus(id).then(s => { if (alive) setOauth(s) }).catch(() => { if (alive) setOauth(null) })
|
||||
@@ -109,25 +104,22 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const missing = findMissingRequired(fields, values)
|
||||
if (missing) {
|
||||
toast.error(t('settings.plugins.requiredMissing', { field: missing.label || missing.key }))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
// Skip an untouched secret (still shows the mask) so we never overwrite it with the mask.
|
||||
const patch: Record<string, unknown> = {}
|
||||
for (const f of fields) {
|
||||
const v = values[f.key]
|
||||
if (f.secret && v === SECRET_MASK) continue
|
||||
patch[f.key] = v
|
||||
}
|
||||
const r = await pluginsApi.saveUserSettings(id, patch)
|
||||
const next: Record<string, string | boolean> = {}
|
||||
for (const f of fields) {
|
||||
const v = r.config[f.key]
|
||||
next[f.key] = f.input_type === 'checkbox' ? v === true : (v == null ? '' : String(v))
|
||||
}
|
||||
setValues(next)
|
||||
const r = await pluginsApi.saveUserSettings(id, settingsPatch(fields, values))
|
||||
setValues(seedSettingsValues(fields, r.config))
|
||||
toast.success(t('settings.plugins.saved'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} catch (e) {
|
||||
// A 4xx names what the server refused (a required field it knows about and this
|
||||
// stale field list doesn't); a 5xx body is not for the user.
|
||||
const err = e as { response?: { status?: number; data?: { error?: string } } }
|
||||
const refused = err.response?.status && err.response.status < 500 ? err.response.data?.error : undefined
|
||||
toast.error(refused || t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
@@ -9,10 +9,14 @@ export default function ToggleSwitch({ on, onToggle, label }: { on: boolean; onT
|
||||
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||
transition: 'background 0.2s',
|
||||
}}>
|
||||
{/* The knob reads against its own track: --accent-text is what the palette
|
||||
already picked to sit on --accent, so a light accent gets a dark knob
|
||||
instead of the white-on-near-white it used to be in dark mode. */}
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, left: on ? 22 : 2,
|
||||
width: 20, height: 20, borderRadius: '50%', background: 'white',
|
||||
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
width: 20, height: 20, borderRadius: '50%',
|
||||
background: on ? 'var(--accent-text, #ffffff)' : 'var(--bg-card, #ffffff)',
|
||||
transition: 'left 0.2s', boxShadow: 'var(--shadow-sm, 0 1px 3px rgba(0,0,0,0.2))',
|
||||
}} />
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Shared state for the offline settings screen (#1135), used by both the desktop
|
||||
* `OfflineTab` and its phone twin `MSettingsOffline`. The two shells render very
|
||||
* different markup over identical logic, so the logic lives here once, the same
|
||||
* arrangement as `useInstanceSettings` / `useRangeBypass`.
|
||||
*
|
||||
* It also carries the honesty fixes from #2228: a sync that never started is
|
||||
* reported as such instead of silently painting a finished progress bar, a
|
||||
* failed read of the offline database surfaces instead of rendering as "nothing
|
||||
* cached", and a trip the user switches on is pinned so the date rule cannot
|
||||
* quietly refuse to store it.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { offlineDb, clearAll, clearTripData } from '../../db/offlineDb'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { tripSyncManager, isDateEligible, type PrepareProgress, type SyncOutcome } from '../../sync/tripSyncManager'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import { clearTileCache } from '../../sync/tilePrefetcher'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import {
|
||||
getOfflinePrefs, setCacheTiles, setConflictStrategy,
|
||||
setTripOfflineEnabled, setTripPinned, onOfflinePrefsChange,
|
||||
type ConflictStrategy,
|
||||
} from '../../sync/offlinePrefs'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import type { SyncMeta, QueuedMutation } from '../../db/offlineDb'
|
||||
import type { Trip } from '../../types'
|
||||
|
||||
export interface CachedTripRow {
|
||||
trip: Trip
|
||||
meta: SyncMeta
|
||||
placeCount: number
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
/** How a trip's storage switch should read, and why. */
|
||||
export interface TripStorageState {
|
||||
/** The switch position: is this trip kept on the device? */
|
||||
on: boolean
|
||||
/** The user asked for it explicitly, overriding the date rule. */
|
||||
pinned: boolean
|
||||
/** The date rule would keep it on its own (ongoing, future, or open-ended). */
|
||||
dateEligible: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The last thing a download / re-sync run did. `null` while nothing has been
|
||||
* run yet. The screen shows no banner in that state.
|
||||
*/
|
||||
export type OfflineNotice =
|
||||
| { kind: 'stored'; trips: number }
|
||||
| { kind: 'skipped'; reason: 'busy' | 'offline' | 'signed-out' }
|
||||
| { kind: 'sync-failed' }
|
||||
| { kind: 'load-failed' }
|
||||
|
||||
/**
|
||||
* The message a notice reads as. Shared so both shells say the same thing and
|
||||
* only the chrome around it differs.
|
||||
*/
|
||||
export function offlineNoticeKey(notice: OfflineNotice): string {
|
||||
if (notice.kind === 'load-failed') return 'settings.offline.notice.loadFailed'
|
||||
if (notice.kind === 'sync-failed') return 'settings.offline.notice.failed'
|
||||
if (notice.kind === 'skipped') {
|
||||
if (notice.reason === 'busy') return 'settings.offline.notice.busy'
|
||||
if (notice.reason === 'offline') return 'settings.offline.notice.offline'
|
||||
return 'settings.offline.notice.signedOut'
|
||||
}
|
||||
return notice.trips > 0 ? 'settings.offline.notice.stored' : 'settings.offline.notice.nothing'
|
||||
}
|
||||
|
||||
/** A notice that reports a problem rather than a result. */
|
||||
export function isOfflineNoticeWarning(notice: OfflineNotice): boolean {
|
||||
return notice.kind !== 'stored' || notice.trips === 0
|
||||
}
|
||||
|
||||
function noticeFor(outcome: SyncOutcome): OfflineNotice {
|
||||
return outcome.status === 'done'
|
||||
? { kind: 'stored', trips: outcome.trips }
|
||||
: { kind: 'skipped', reason: outcome.reason }
|
||||
}
|
||||
|
||||
export function useOfflineSettings() {
|
||||
const { offline, forced, setForced } = useNetworkMode()
|
||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||
const [allTrips, setAllTrips] = useState<Trip[]>([])
|
||||
const [storedTripCount, setStoredTripCount] = useState(0)
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
const [conflicts, setConflicts] = useState<QueuedMutation[]>([])
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [preparing, setPreparing] = useState(false)
|
||||
const [progress, setProgress] = useState<PrepareProgress | null>(null)
|
||||
const [notice, setNotice] = useState<OfflineNotice | null>(null)
|
||||
const [prefs, setPrefs] = useState(getOfflinePrefs())
|
||||
|
||||
useEffect(() => onOfflinePrefsChange(() => setPrefs(getOfflinePrefs())), [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metas, storedTrips, pending, failed, conflictList] = await Promise.all([
|
||||
offlineDb.syncMeta.toArray(),
|
||||
offlineDb.trips.count(),
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
mutationQueue.conflicts(),
|
||||
])
|
||||
setStoredTripCount(storedTrips)
|
||||
setPendingCount(pending)
|
||||
setFailedCount(failed)
|
||||
setConflicts(conflictList)
|
||||
|
||||
const result: CachedTripRow[] = []
|
||||
for (const meta of metas) {
|
||||
const trip = await offlineDb.trips.get(meta.tripId)
|
||||
if (!trip) continue
|
||||
const [placeCount, fileCount] = await Promise.all([
|
||||
offlineDb.places.where('trip_id').equals(meta.tripId).count(),
|
||||
offlineDb.tripFiles.where('trip_id').equals(meta.tripId).count(),
|
||||
])
|
||||
result.push({ trip, meta, placeCount, fileCount })
|
||||
}
|
||||
result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? ''))
|
||||
setRows(result)
|
||||
|
||||
// The per-trip storage toggles are driven by the FULL trip list, not just
|
||||
// the cached ones, so a trip turned off stays visible and re-enableable.
|
||||
try {
|
||||
const trips = isEffectivelyOffline()
|
||||
? await offlineDb.trips.toArray()
|
||||
: await tripsApi.list().then(r => (r as { trips: Trip[] }).trips).catch(() => offlineDb.trips.toArray())
|
||||
trips.sort((a, b) => (a.start_date ?? '').localeCompare(b.start_date ?? ''))
|
||||
setAllTrips(trips)
|
||||
} catch {
|
||||
setAllTrips([])
|
||||
}
|
||||
} catch (err) {
|
||||
// A Dexie read that rejects (a closed handle, a failed upgrade) used to
|
||||
// leave every list at its initial empty value, so a broken offline
|
||||
// database was indistinguishable from an empty one: the screen read
|
||||
// "No trips cached yet" and gave the user nothing to act on (#2228).
|
||||
console.error('[offline] could not read the offline database:', err)
|
||||
setNotice({ kind: 'load-failed' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const runPrepare = useCallback(async () => {
|
||||
setPreparing(true)
|
||||
setProgress(null)
|
||||
setNotice(null)
|
||||
try {
|
||||
const outcome = await tripSyncManager.prepareForOffline(p => setProgress(p))
|
||||
setNotice(noticeFor(outcome))
|
||||
// A run that never started never reported progress either; clearing it
|
||||
// keeps the bar from painting "finished" over a download that did not
|
||||
// happen.
|
||||
if (outcome.status !== 'done') setProgress(null)
|
||||
} catch (err) {
|
||||
// The trip list this starts from is a plain fetch, so a network blip
|
||||
// rejects the whole run. Reporting that beats leaving the last progress
|
||||
// frame on screen as if the download had completed.
|
||||
console.error('[offline] the download could not finish:', err)
|
||||
setProgress(null)
|
||||
setNotice({ kind: 'sync-failed' })
|
||||
} finally {
|
||||
setPreparing(false)
|
||||
}
|
||||
await load()
|
||||
}, [load])
|
||||
|
||||
const handleToggleForce = useCallback(async () => {
|
||||
if (!forced) {
|
||||
// Turning offline mode on: download everything first (while still online),
|
||||
// then engage so the app has all it needs before the network drops.
|
||||
if (!isEffectivelyOffline()) await runPrepare()
|
||||
setForced(true)
|
||||
} else {
|
||||
// Back online: lifting the switch flushes the queue + re-syncs (syncTriggers).
|
||||
setForced(false)
|
||||
}
|
||||
}, [forced, runPrepare, setForced])
|
||||
|
||||
const handleResync = useCallback(async () => {
|
||||
setSyncing(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
setNotice(noticeFor(await tripSyncManager.syncAll()))
|
||||
} catch (err) {
|
||||
console.error('[offline] the sync could not finish:', err)
|
||||
setNotice({ kind: 'sync-failed' })
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
await load()
|
||||
}, [load])
|
||||
|
||||
/** Wipes the offline database. Each shell asks for confirmation its own way. */
|
||||
const handleClear = useCallback(async () => {
|
||||
setClearing(true)
|
||||
try {
|
||||
await clearAll()
|
||||
setNotice(null)
|
||||
await load()
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const handleToggleTiles = useCallback(async () => {
|
||||
const next = !prefs.cacheTiles
|
||||
setCacheTiles(next)
|
||||
// Turning tiles off reclaims the bulk tile storage straight away.
|
||||
if (!next) await clearTileCache()
|
||||
}, [prefs.cacheTiles])
|
||||
|
||||
/**
|
||||
* How a trip's switch should read. A finished trip is only stored when the
|
||||
* user pinned it, so reporting the raw opt-out flag would show "on" over a
|
||||
* trip the sync deliberately skips, which is what made the whole feature
|
||||
* look broken for anyone whose trips are all in the past (#2228).
|
||||
*/
|
||||
const tripStorageState = useCallback((trip: Trip): TripStorageState => {
|
||||
const dateEligible = isDateEligible(trip)
|
||||
const pinned = prefs.pinnedTripIds.includes(trip.id)
|
||||
const allowed = !prefs.disabledTripIds.includes(trip.id)
|
||||
return { on: allowed && (pinned || dateEligible), pinned, dateEligible }
|
||||
}, [prefs.pinnedTripIds, prefs.disabledTripIds])
|
||||
|
||||
const handleToggleTrip = useCallback(async (trip: Trip) => {
|
||||
const next = !tripStorageState(trip).on
|
||||
setTripOfflineEnabled(trip.id, next)
|
||||
// Switching a finished trip on has to pin it, or the date rule would skip
|
||||
// it on the next sync and evict it a week after it ended.
|
||||
setTripPinned(trip.id, next && !isDateEligible(trip))
|
||||
if (!next) {
|
||||
await clearTripData(trip.id)
|
||||
await load()
|
||||
return
|
||||
}
|
||||
if (isEffectivelyOffline()) {
|
||||
setNotice({ kind: 'skipped', reason: 'offline' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
setNotice(noticeFor(await tripSyncManager.syncAll()))
|
||||
} catch (err) {
|
||||
console.error('[offline] the sync could not finish:', err)
|
||||
setNotice({ kind: 'sync-failed' })
|
||||
}
|
||||
await load()
|
||||
}, [load, tripStorageState])
|
||||
|
||||
const resolveConflict = useCallback(async (id: string, keepMine: boolean) => {
|
||||
if (keepMine) await mutationQueue.resolveKeepMine(id)
|
||||
else await mutationQueue.resolveKeepServer(id)
|
||||
await load()
|
||||
}, [load])
|
||||
|
||||
const handleConflictStrategy = useCallback((strategy: ConflictStrategy) => {
|
||||
setConflictStrategy(strategy)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
offline, forced,
|
||||
rows, allTrips, pendingCount, failedCount, conflicts,
|
||||
syncing, clearing, loading, preparing, progress, notice, prefs, storedTripCount,
|
||||
// Clearable once the database actually holds something, not only once a
|
||||
// sync wrote its bookkeeping row: a download that died between writing the
|
||||
// trip and writing its syncMeta left `rows` empty over a non-empty Dexie,
|
||||
// and the clear button was disabled on exactly the state that needed it.
|
||||
canClear: storedTripCount > 0 || pendingCount > 0,
|
||||
load, runPrepare, handleToggleForce, handleResync, handleClear,
|
||||
handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict,
|
||||
handleConflictStrategy,
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import type { CSSProperties } from 'react'
|
||||
import type {
|
||||
BookElement, BookIconElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread,
|
||||
} from '@trek/shared'
|
||||
import { FONT_STACKS, photoSrc } from './bookRender'
|
||||
import { photoSrc } from './bookRender'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { iconComponent } from './iconLibrary'
|
||||
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
|
||||
import { TravelElementView } from './TravelElements'
|
||||
@@ -223,7 +224,7 @@ function PhotoView({ el, big, print, dropLabel }: {
|
||||
{roomy && dropLabel && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT_STACKS.sans,
|
||||
fontFamily: fontStack('sans'),
|
||||
fontSize: `${labelSize}mm`,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.16em',
|
||||
@@ -403,7 +404,7 @@ export function ElementView({
|
||||
// pt, not px: the document speaks the print's language, and CSS knows
|
||||
// the conversion exactly.
|
||||
fontSize: `${el.size}pt`,
|
||||
fontFamily: FONT_STACKS[el.font],
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: el.weight,
|
||||
fontStyle: el.italic ? 'italic' : undefined,
|
||||
lineHeight: el.leading,
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
|
||||
import { SpreadFold, SpreadView } from './SpreadView'
|
||||
import { PeerCursors } from './PeerCursors'
|
||||
import type { PeerCursor } from './useBookPresence'
|
||||
import { FONT_STACKS } from './bookRender'
|
||||
import { elementId } from './bookIds'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -65,6 +66,8 @@ export function StudioCanvas({
|
||||
dropLabel,
|
||||
cursors,
|
||||
onCursor,
|
||||
onDropFiles,
|
||||
fileDropLabel,
|
||||
}: {
|
||||
spread: BookSpread | null
|
||||
spreadIndex: number
|
||||
@@ -73,6 +76,14 @@ export function StudioCanvas({
|
||||
pxPerMm: number
|
||||
bookView: boolean
|
||||
dropLabel: string
|
||||
/**
|
||||
* Files from outside the browser, let go over the sheet. Absent for a
|
||||
* viewer, and then a file drag is left to the browser, which opens it.
|
||||
* `targetFrameId` is the photo frame under the pointer, when there is one.
|
||||
*/
|
||||
onDropFiles?: (files: File[], at: { x: number; y: number }, targetFrameId: string | null) => void
|
||||
/** What the veil says while a file drag is over the sheet. */
|
||||
fileDropLabel?: string
|
||||
/** The other editors' pointers, on this spread and elsewhere. */
|
||||
cursors?: PeerCursor[]
|
||||
/** Where this one is, in the spread's millimetres. Null once it leaves. */
|
||||
@@ -87,6 +98,8 @@ export function StudioCanvas({
|
||||
const commit = useStudioStore(s => s.commit)
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
||||
/** A file drag from outside the browser is over the sheet. */
|
||||
const [fileOver, setFileOver] = useState(false)
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
|
||||
/** Where on the spread, in millimetres, a drop landed. */
|
||||
@@ -166,22 +179,34 @@ export function StudioCanvas({
|
||||
onPointerUp={finish}
|
||||
onPointerCancel={finish}
|
||||
onDragOver={e => {
|
||||
if (!e.dataTransfer.types.includes('application/x-trek-photo')) return
|
||||
const fromPanel = e.dataTransfer.types.includes('application/x-trek-photo')
|
||||
// A file drag is only claimed when somebody is there to take it; for a
|
||||
// viewer the browser keeps its default and opens the picture itself.
|
||||
const fromDisk = !!onDropFiles && e.dataTransfer.types.includes('Files')
|
||||
if (!fromPanel && !fromDisk) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
const p = pointInMm(e)
|
||||
setDropTarget(frameUnder(p.x, p.y)?.id ?? null)
|
||||
setFileOver(fromDisk && !fromPanel)
|
||||
}}
|
||||
onDragLeave={() => setDropTarget(null)}
|
||||
onDragLeave={() => { setDropTarget(null); setFileOver(false) }}
|
||||
onDrop={e => {
|
||||
const raw = e.dataTransfer.getData('application/x-trek-photo')
|
||||
if (!raw) return
|
||||
const files = Array.from(e.dataTransfer.files ?? [])
|
||||
if (!raw && !(files.length && onDropFiles)) return
|
||||
e.preventDefault()
|
||||
setDropTarget(null)
|
||||
setFileOver(false)
|
||||
|
||||
const p = pointInMm(e)
|
||||
if (!raw) {
|
||||
onDropFiles!(files, p, frameUnder(p.x, p.y)?.id ?? null)
|
||||
return
|
||||
}
|
||||
const photoId = Number(raw)
|
||||
if (!Number.isFinite(photoId)) return
|
||||
|
||||
const p = pointInMm(e)
|
||||
const target = frameUnder(p.x, p.y)
|
||||
if (target) {
|
||||
// Dropped onto a frame: fill it. That is the whole point of an empty
|
||||
@@ -200,7 +225,7 @@ export function StudioCanvas({
|
||||
// Dropped on bare paper: a new frame, centred on the cursor.
|
||||
const w = Math.min(page.pageWidth, page.pageHeight) * 0.5
|
||||
const h = w * 0.72
|
||||
const id = `p-${Math.random().toString(36).slice(2, 9)}`
|
||||
const id = elementId('p')
|
||||
addElement(spreadIndex, {
|
||||
id, kind: 'photo',
|
||||
frame: { x: p.x - w / 2, y: p.y - h / 2, w, h },
|
||||
@@ -211,6 +236,17 @@ export function StudioCanvas({
|
||||
select([id])
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
Over the stage rather than inside the sheet, which is scaled: at 40%
|
||||
a label drawn on the page would be unreadable. It says what letting go
|
||||
will do, because a page is not an obvious drop target for a file the
|
||||
way an upload box is.
|
||||
*/}
|
||||
{fileOver && fileDropLabel && (
|
||||
<div className="st-file-veil" aria-hidden>
|
||||
<span>{fileDropLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="st-sheet"
|
||||
style={{
|
||||
@@ -271,7 +307,7 @@ export function StudioCanvas({
|
||||
height: el.frame.h * scaled,
|
||||
// The document is in pt; the overlay is on screen at the same zoom.
|
||||
fontSize: `${el.size * (96 / 72) * zoom}px`,
|
||||
fontFamily: FONT_STACKS[el.font],
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: el.weight,
|
||||
fontStyle: el.italic ? 'italic' : undefined,
|
||||
lineHeight: el.leading,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, X } from 'lucide-react'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import type { JourneySource, StudioT } from './StudioSidebar'
|
||||
import type { PhotoFilter } from './photoFilter'
|
||||
|
||||
/**
|
||||
* Which day's pictures the content browser is showing.
|
||||
*
|
||||
* A row of its own under the tabs rather than a control inside the search box:
|
||||
* the search answers "which words" and this answers "which day", and the two
|
||||
* combine. It takes the panel's full width, so the entry in force reads as a
|
||||
* heading over the grid rather than as one control among several, and a long
|
||||
* entry name has somewhere to go.
|
||||
*
|
||||
* The popover is the top bar's format menu, hung from the left edge instead of
|
||||
* the right: the side panel is 236px wide and a menu anchored right would open
|
||||
* into the rail.
|
||||
*/
|
||||
export function StudioContentFilter({
|
||||
source, filter, setFilter, admitted, pageEntryId, t, locale,
|
||||
}: {
|
||||
source: JourneySource
|
||||
filter: PhotoFilter
|
||||
setFilter: (f: PhotoFilter) => void
|
||||
/** How many pictures the current filter admits, for the chip's own count. */
|
||||
admitted: number
|
||||
/** The entry the spread on the sheet was laid out from, when it has one. */
|
||||
pageEntryId: number | null
|
||||
t: StudioT
|
||||
locale: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Swallowed, as the format picker does it: the shell's Escape closes Studio.
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const pick = (f: PhotoFilter) => { setFilter(f); setOpen(false) }
|
||||
const withPhotos = source.entries.filter(e => e.photoIds.length > 0)
|
||||
const looseCount = source.photos.filter(p => !p.entryIds.length).length
|
||||
const nameOf = (e: { title: string | null; location: string | null } | undefined) =>
|
||||
e?.title || e?.location || t('journey.studio.untitled')
|
||||
|
||||
const label = filter.kind === 'loose' ? t('journey.studio.filterLoose')
|
||||
: filter.kind === 'recent' ? t('journey.studio.filterRecent')
|
||||
: filter.kind === 'entry' ? nameOf(source.entries.find(e => e.id === filter.id))
|
||||
: t('journey.studio.filterAll')
|
||||
|
||||
/*
|
||||
* The page on the sheet belongs to a day, so its pictures are one press
|
||||
* away. Offered only while the filter is not already there: a chip for the
|
||||
* state you are in is a chip that does nothing.
|
||||
*/
|
||||
const pageEntry = pageEntryId != null ? withPhotos.find(e => e.id === pageEntryId) : undefined
|
||||
const offerThisPage = pageEntry && !(filter.kind === 'entry' && filter.id === pageEntry.id)
|
||||
|
||||
return (
|
||||
<div className="st-filter-row">
|
||||
<div className="st-picker" ref={box}>
|
||||
<span className={`st-chip st-filter-chip ${filter.kind !== 'all' ? 'is-on' : ''}`}>
|
||||
<button type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={t('journey.studio.filterPhotos')}
|
||||
>
|
||||
<span className="st-filter-name">{label}</span>
|
||||
<em>{admitted}</em>
|
||||
<ChevronDown size={12} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
{filter.kind !== 'all' && (
|
||||
<button type="button" onClick={() => setFilter({ kind: 'all' })} aria-label={t('common.clear')}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="menu">
|
||||
<FilterItem
|
||||
on={filter.kind === 'all'}
|
||||
label={t('journey.studio.filterAll')}
|
||||
count={source.photos.length}
|
||||
onPick={() => pick({ kind: 'all' })}
|
||||
/>
|
||||
<FilterItem
|
||||
on={filter.kind === 'loose'}
|
||||
label={t('journey.studio.filterLoose')}
|
||||
count={looseCount}
|
||||
onPick={() => pick({ kind: 'loose' })}
|
||||
/>
|
||||
{withPhotos.length > 0 && <div className="st-menu-sep" />}
|
||||
{withPhotos.map(e => (
|
||||
<FilterItem
|
||||
key={e.id}
|
||||
on={filter.kind === 'entry' && filter.id === e.id}
|
||||
label={nameOf(e)}
|
||||
// The day, and the place when the title took the top line.
|
||||
dim={[e.date ? formatDate(e.date, locale) ?? e.date : null, e.title ? e.location : null]
|
||||
.filter(Boolean).join(', ')}
|
||||
count={e.photoIds.length}
|
||||
onPick={() => pick({ kind: 'entry', id: e.id })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{offerThisPage && (
|
||||
<button type="button" className="st-chip" onClick={() => setFilter({ kind: 'entry', id: pageEntry.id })}>
|
||||
{t('journey.studio.filterThisPage')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One line of the menu: a name, a count, and a tick on the one in force. */
|
||||
function FilterItem({ on, label, dim, count, onPick }: {
|
||||
on: boolean
|
||||
label: string
|
||||
dim?: string
|
||||
count: number
|
||||
onPick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={on}
|
||||
className={`st-menu-item ${on ? 'is-active' : ''}`}
|
||||
onClick={onPick}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{label}</span>
|
||||
{dim && <span className="st-menu-dim">{dim}</span>}
|
||||
</span>
|
||||
<span className="st-menu-dim st-filter-count">{count}</span>
|
||||
{on && <Check size={14} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -36,18 +36,26 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
* remembered: coming back to an element and finding its panel folded up
|
||||
* differently from last time is worse than opening one group again.
|
||||
*/
|
||||
export function Section({ label, children, defaultOpen = true, hint }: {
|
||||
export function Section({ label, children, defaultOpen = true, hint, badge }: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
defaultOpen?: boolean
|
||||
/** A line under the label, for a group whose name cannot carry the whole idea. */
|
||||
hint?: string
|
||||
/**
|
||||
* A figure beside the label, for a group worth reading while it is folded.
|
||||
* The stops list is the case: how many of them count is the thing you came
|
||||
* to check, and opening a section to read one number is a section that
|
||||
* cannot usefully be closed.
|
||||
*/
|
||||
badge?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div className={`st-section is-fold ${open ? '' : 'is-folded'}`}>
|
||||
<button type="button" className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}>
|
||||
<span className="st-section-label">{label}</span>
|
||||
{badge && <span className="st-section-badge">{badge}</span>}
|
||||
<ChevronDown size={13} className="st-section-caret" />
|
||||
</button>
|
||||
{open && (
|
||||
|
||||
@@ -191,6 +191,34 @@ export function StudioInspector({
|
||||
|
||||
const live = (patch: Partial<BookElement>) => update(spreadIndex, el.id, patch)
|
||||
|
||||
/**
|
||||
* Stretch the picture over the page it is on, or over both pages, bleed
|
||||
* included.
|
||||
*
|
||||
* One commit rather than `set` followed by `raise`: the frame, the rotation
|
||||
* put back to zero (a turned picture leaves bare corners), the cover crop and
|
||||
* the move to the back are one press, and one press comes back with one
|
||||
* undo. Which page is the one under the frame's centre; a cover has only
|
||||
* the one.
|
||||
*/
|
||||
const fill = (scope: 'page' | 'spread') => commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => {
|
||||
if (i !== spreadIndex) return sp
|
||||
const bleed = page.bleed
|
||||
const onRight = sp.role === 'inner' && scope === 'page'
|
||||
&& el.frame.x + el.frame.w / 2 >= page.pageWidth
|
||||
const frame = {
|
||||
x: onRight ? page.pageWidth - bleed : -bleed,
|
||||
y: -bleed,
|
||||
w: (scope === 'spread' ? 2 : 1) * page.pageWidth + 2 * bleed,
|
||||
h: page.pageHeight + 2 * bleed,
|
||||
}
|
||||
const filled = { ...el, frame, rotation: 0, fit: 'cover' } as BookElement
|
||||
return { ...sp, elements: [filled, ...sp.elements.filter(e => e.id !== el.id)] }
|
||||
}),
|
||||
}))
|
||||
|
||||
return (
|
||||
<aside className="st-panel st-inspector">
|
||||
{/*
|
||||
@@ -696,6 +724,28 @@ export function StudioInspector({
|
||||
<ChevronsDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/*
|
||||
The picture as the page.
|
||||
|
||||
A full-bleed photograph is the commonest layout in any photo book
|
||||
and the hardest to make by hand: the frame has to reach exactly to
|
||||
the bleed on every side, and a millimetre short leaves a white
|
||||
hairline on the trimmed page. So it is one press. It goes to the
|
||||
back as well, since a page-sized picture in front of everything is
|
||||
a page with nothing else on it.
|
||||
*/}
|
||||
{one && el.kind === 'photo' && (
|
||||
<div className="st-row" style={{ marginTop: 6 }}>
|
||||
<button type="button" className="st-chip" onClick={() => fill('page')} title={t('journey.studio.fillHint')}>
|
||||
{t('journey.studio.fillPage')}
|
||||
</button>
|
||||
{spread.role === 'inner' && (
|
||||
<button type="button" className="st-chip" onClick={() => fill('spread')} title={t('journey.studio.fillHint')}>
|
||||
{t('journey.studio.fillSpread')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PeerBadges } from './PeerBadges'
|
||||
import { TrimField } from './TrimField'
|
||||
import '../../styles/dashboard.css'
|
||||
import '../../styles/studio.css'
|
||||
import './bookFontFaces'
|
||||
|
||||
/**
|
||||
* The Studio shell: top bar, page rail, workbench, inspector.
|
||||
@@ -88,6 +89,9 @@ export default function StudioShell() {
|
||||
path={s.path}
|
||||
t={s.t}
|
||||
locale={s.locale}
|
||||
canEdit={s.canEdit}
|
||||
onUpload={s.uploadPhotos}
|
||||
onToggleStop={s.setStopExcluded}
|
||||
/>
|
||||
<Workbench s={s} bookView={bookView} />
|
||||
<StudioInspector
|
||||
@@ -430,7 +434,20 @@ function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
|
||||
dropLabel={s.t('journey.studio.dropPhotoHere')}
|
||||
cursors={s.cursors}
|
||||
onCursor={(x, y) => s.moveCursor(s.activeSpread, x, y)}
|
||||
onDropFiles={s.canEdit ? s.dropFiles : undefined}
|
||||
fileDropLabel={s.t('journey.studio.dropFilesHere')}
|
||||
/>
|
||||
{/*
|
||||
Pictures dropped on the sheet are on their way. The page keeps
|
||||
rendering underneath; the veil only says that something is happening
|
||||
where the pointer let go, so the second or two before the frame
|
||||
appears does not read as the drop having been ignored.
|
||||
*/}
|
||||
{s.canvasUpload && (
|
||||
<div className="st-upload-veil" role="status" aria-live="polite">
|
||||
{s.t('journey.studio.uploading', { done: s.canvasUpload.done, total: s.canvasUpload.total })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="st-zoom">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user