mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
273 Commits
v4.0.0-pre.2
...
v4.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| c87e6d2fc2 | |||
| e6ccaf89de | |||
| 061a3d91a0 | |||
| 88ebf89ab3 | |||
| 9162f5d7ac | |||
| 3c865c9605 | |||
| a8a05ace40 | |||
| 937e32924d | |||
| eb7d595203 | |||
| 957cdffa7b | |||
| 65a6a46d70 | |||
| 21fd04647f | |||
| c6027f9e68 | |||
| 29de8dd9c1 | |||
| 41de91668b | |||
| 21f4a1241b | |||
| 092223c259 | |||
| c56521b4a4 | |||
| a6c96f4053 | |||
| b4f00571dc | |||
| c8c11fdb7a | |||
| 84b51a8af5 | |||
| 4854377603 | |||
| 8069b88057 | |||
| 44a117390b | |||
| b401da2264 | |||
| 1d12535b1b | |||
| 9e0fb19e79 | |||
| 8f6d5fd21f | |||
| 71ff191bb4 | |||
| 63a57b9269 | |||
| 4f15d87cbe | |||
| adf88e1d61 | |||
| 910c62ea90 | |||
| 6c7e257c17 | |||
| 661777f81f | |||
| 1c5c3ae983 | |||
| 5a7b8d75aa | |||
| 7cbd4e5c42 | |||
| 1e1a80c199 | |||
| d559fdda1d | |||
| 4b120d39c8 | |||
| 9cddf683a5 | |||
| fd6381c814 | |||
| d2402c798c | |||
| d091f4a815 | |||
| 130272cfb0 | |||
| 016fdeec64 | |||
| 15588d20c9 | |||
| 8641788de0 | |||
| 66530a074f | |||
| 8a846e4b91 | |||
| fa67a29e76 | |||
| dd469f7d1a | |||
| fa79923663 | |||
| 9ed35d182f | |||
| ed67a5e9f8 | |||
| 6270495327 | |||
| 42ba5d4849 | |||
| ab3751761c | |||
| aa251cdb8e | |||
| 2ba10a9126 | |||
| 44c2573abb | |||
| 4f98c5ea44 | |||
| 9e21eb5d10 | |||
| 5d0fac3026 | |||
| 922c60c167 | |||
| a5ac6b15e6 | |||
| 4225d883c0 | |||
| fed562d564 | |||
| e0169388d3 | |||
| f85a87e6d0 | |||
| f1bbd94f2b | |||
| aa5002b2ed | |||
| 86c0c197db | |||
| 4b21a70ab2 | |||
| 34522ad3bc | |||
| d8fd2deb12 | |||
| 922f37ab52 | |||
| f513881c01 | |||
| 92575c0d49 | |||
| 4d6820a4d4 | |||
| 88c34f6415 | |||
| bbe4d62c90 | |||
| eb3e0ca7c9 | |||
| 0ca4dbaa94 | |||
| a9b0cccb74 | |||
| f15a124016 | |||
| 99387812a1 | |||
| a00b84b216 | |||
| 77dddd5e8a | |||
| 4784a1e5dd | |||
| de9bd94279 | |||
| 1f66ef1d32 | |||
| 521e52c853 | |||
| 800f97b8ce | |||
| 495c5e95ec | |||
| f61bab3ffd | |||
| d194d2cf6f | |||
| 1a126fb6fc | |||
| f605b730d8 | |||
| 5b9aaab847 | |||
| 0269273d01 | |||
| f5bfc321d7 | |||
| 105cb68316 | |||
| f052d9d26b | |||
| 167e2e9428 | |||
| ce584da168 | |||
| c563159072 | |||
| 8087919d31 | |||
| b597d71836 | |||
| 9bbbef6279 | |||
| d4ecf96b64 | |||
| b58515e47e | |||
| c435033343 | |||
| a3ad53b3bf | |||
| b98363b2b0 | |||
| 981a634dfb | |||
| 62b13a278e | |||
| 071f516c0f | |||
| 1ad2e92142 | |||
| 52d1a0720f | |||
| 4ba407aead | |||
| b5c8182410 | |||
| 1cb67d5e64 | |||
| fe5c6f182e | |||
| 6fe5009c20 | |||
| 616b39a9f0 | |||
| ad2119efd6 | |||
| 2c2d008dc2 | |||
| 7da0080557 | |||
| dc74d59b11 | |||
| bc59289f73 | |||
| 40957e909b | |||
| fb76074e7c | |||
| 23196aa038 | |||
| e3c524dd02 | |||
| decc0e1a12 | |||
| 4548743874 | |||
| 190dc482e4 | |||
| efcdbd94fb | |||
| 67b71a52d1 | |||
| bd33d0b1cf | |||
| d110b3bb6d | |||
| 4bac447ac8 | |||
| 6779cd2dd1 | |||
| 477e44ef50 | |||
| 4eee08e45c | |||
| 44ddfa510e | |||
| 7c6a17c020 | |||
| 818021908f | |||
| 502a3fd4e8 | |||
| 38f3cb3101 | |||
| 3e0bfbbb29 | |||
| e72338bbbc | |||
| 27be764164 | |||
| c75e958fc0 | |||
| b3d8bf08f1 | |||
| 19745b7b0d | |||
| 7959470802 | |||
| 61d4fd4d64 | |||
| 47a4276e84 | |||
| eadb3703bc | |||
| a7aaa29c16 | |||
| 8e5fdaa877 | |||
| 4bfb277423 | |||
| 515b832fa3 | |||
| 97ebe23d3b | |||
| b163c1e05d | |||
| 3f8a7a1ea9 | |||
| d91eda690a | |||
| 39c8a22352 | |||
| e91ad69c83 | |||
| e9090fa33c | |||
| bb7c484ca4 | |||
| e60427f813 | |||
| 94736112cf |
@@ -132,7 +132,8 @@ jobs:
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
# Deliberately NOT merged: the per-platform folder names are what lets
|
||||
# the manifest below be assembled in a fixed order.
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -144,9 +145,24 @@ jobs:
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
|
||||
# amd64 first, always. This used to be a glob over hash-named files, so
|
||||
# the order changed from one release to the next: 3.4.1 listed amd64
|
||||
# first and 4.0.0 listed arm64 first. Clients that read the first entry
|
||||
# of the index rather than matching their own platform then compare the
|
||||
# wrong digest and report "no update" — which is what Unraid users hit
|
||||
# on :latest after 4.0.0 shipped. The image was fine; the order was not.
|
||||
digests=()
|
||||
for platform in linux-amd64 linux-arm64; do
|
||||
dir="digests-$platform"
|
||||
[ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; }
|
||||
for f in "$dir"/*; do
|
||||
[ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; }
|
||||
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
|
||||
done
|
||||
done
|
||||
docker buildx imagetools create \
|
||||
-t "mauriceboe/trek:latest-pre" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
|
||||
@@ -176,7 +176,8 @@ jobs:
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
# Deliberately NOT merged: the per-platform folder names are what lets
|
||||
# the manifest below be assembled in a fixed order.
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -188,9 +189,24 @@ jobs:
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
|
||||
# amd64 first, always. This used to be a glob over hash-named files, so
|
||||
# the order changed from one release to the next: 3.4.1 listed amd64
|
||||
# first and 4.0.0 listed arm64 first. Clients that read the first entry
|
||||
# of the index rather than matching their own platform then compare the
|
||||
# wrong digest and report "no update" — which is what Unraid users hit
|
||||
# on :latest after 4.0.0 shipped. The image was fine; the order was not.
|
||||
digests=()
|
||||
for platform in linux-amd64 linux-arm64; do
|
||||
dir="digests-$platform"
|
||||
[ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; }
|
||||
for f in "$dir"/*; do
|
||||
[ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; }
|
||||
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
|
||||
done
|
||||
done
|
||||
docker buildx imagetools create \
|
||||
-t "mauriceboe/trek:latest" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
|
||||
@@ -2,6 +2,11 @@ name: Lint & Prettier
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -9,6 +9,10 @@ on:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
scout:
|
||||
# Docker Hub secrets are not exposed to pull requests from forks, so the
|
||||
|
||||
+296
-77
@@ -1,9 +1,60 @@
|
||||
name: Tests
|
||||
name: Tests & SonarQube
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# The repo's test/quality CI and SonarQube Cloud analysis in one workflow, so
|
||||
# each test suite runs once per event instead of once for the old test.yml and
|
||||
# once more for Sonar's coverage. Pushes to dev and main produce branch
|
||||
# analysis; pull requests get PR analysis with decoration on the PR itself
|
||||
# (the scanner picks the context up from the Actions environment).
|
||||
#
|
||||
# Job layout — three tiers:
|
||||
# - Preflight: plugin-facts, i18n-parity and the quality jobs
|
||||
# (server-quality, client-quality, plugin-sdk-quality) hold the cheap
|
||||
# generated-code/typecheck/lint/smoke gates. Every test job `needs:` all
|
||||
# of them, so a gate failure stops the run before the expensive coverage
|
||||
# fan-out spends ~15 runner-minutes on a result the gates already sank.
|
||||
# - Tests: the *-coverage jobs run the suites and upload lcov; s3-contract
|
||||
# runs the MinIO contract suite. Coverage jobs run tests only — with the
|
||||
# gates cleared in preflight, they can only fail because tests failed.
|
||||
# - Scan `needs:` exactly the four coverage jobs. A coverage job failing
|
||||
# must stop the scan — a missing lcov file would make every line of that
|
||||
# package report as uncovered, and the dashboard would show a collapse
|
||||
# that did not happen. (The preflight gates stop it transitively.)
|
||||
# - Fork PRs don't receive secrets, so the two jobs that need them skip
|
||||
# themselves: s3-contract (MINIO_LICENSE_KEY) and scan (SONAR_TOKEN).
|
||||
# Everything else runs on fork PRs so outside contributors still get the
|
||||
# full test signal.
|
||||
#
|
||||
# Automatic Analysis must stay disabled on sonarcloud.io — it is mutually
|
||||
# exclusive with CI-based analysis, and it cannot import the lcov coverage
|
||||
# this workflow produces.
|
||||
# A push or PR that touches none of these paths can't change the tests or the
|
||||
# analysis, so don't spend the coverage fan-out on it. For a docs-only push
|
||||
# that means no run and no fresh branch analysis — harmless, since the last
|
||||
# analysis is still accurate for unchanged code. GitHub Actions doesn't
|
||||
# support YAML anchors, so the two lists are literal copies: keep them
|
||||
# identical when editing either one.
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'server/**'
|
||||
- 'client/**'
|
||||
- 'shared/**'
|
||||
- 'plugin-sdk/**'
|
||||
# npm workspaces hoist every dependency into the single root lockfile,
|
||||
# so a dependency bump touches none of the four directories above — and
|
||||
# a native binding like better-sqlite3 is exactly what needs the suite.
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'sonar-project.properties'
|
||||
- '.github/workflows/test.yml'
|
||||
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
|
||||
# neither the tests nor the scan — no test reads checked-in markdown
|
||||
# and SonarCloud has no markdown analyzer. Negations must stay last.
|
||||
- '!**/*.md'
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
@@ -11,29 +62,51 @@ on:
|
||||
- 'client/**'
|
||||
- 'shared/**'
|
||||
- 'plugin-sdk/**'
|
||||
# npm workspaces hoist every dependency into the single root lockfile,
|
||||
# so a dependency bump touches none of the four directories above — and
|
||||
# a native binding like better-sqlite3 is exactly what needs the suite.
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'sonar-project.properties'
|
||||
- '.github/workflows/test.yml'
|
||||
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
|
||||
# neither the tests nor the scan — no test reads checked-in markdown
|
||||
# and SonarCloud has no markdown analyzer. Negations must stay last.
|
||||
- '!**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same branch/PR supersedes the running one — cancel it
|
||||
# rather than spending the coverage fan-out on a stale commit. Safe here
|
||||
# because the Sonar scan job runs last and only the latest commit matters.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
plugin-facts:
|
||||
name: Plugin Permission Facts
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
# 22 matches the TREK server runtime (same choice as the plugin-sdk
|
||||
# jobs and publish-plugin-sdk.yml).
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace server
|
||||
|
||||
# The plugin permission facts live once, in
|
||||
# server/src/nest/plugins/protocol/envelope.ts. Every other copy - the SDK's
|
||||
# generated/host-facts.ts and shared's plugin-permissions.ts - is generated from it
|
||||
# and checked in, because publish-plugin-sdk builds from a standalone checkout with
|
||||
# no server present. This fails the PR if a copy drifts, if a new permission has no
|
||||
# admin.plugins.perm.* translation, or if the CLI has no hint for it.
|
||||
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
|
||||
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
|
||||
# generated from it and checked in, because publish-plugin-sdk builds
|
||||
# from a standalone checkout with no server present. This fails the run
|
||||
# if a copy drifts, if a new permission has no admin.plugins.perm.*
|
||||
# translation, or if the CLI has no hint for it.
|
||||
- name: Check the generated plugin facts are in sync
|
||||
run: cd server && npm run check:plugin-facts
|
||||
|
||||
@@ -51,32 +124,9 @@ jobs:
|
||||
- name: Check i18n key parity
|
||||
run: node shared/scripts/i18n-parity.mjs --strict
|
||||
|
||||
shared-contracts:
|
||||
name: Shared Contracts (Zod)
|
||||
server-quality:
|
||||
name: Server Types & Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared
|
||||
|
||||
- name: Typecheck
|
||||
run: cd shared && npm run typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: cd shared && npm test
|
||||
|
||||
server-tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -89,18 +139,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Ensure @swc/core's Linux binary for unplugin-swc
|
||||
# The lockfile was generated on Windows and omits @swc/core's Linux
|
||||
# optional native binary, so npm ci/install skips it on the runner.
|
||||
# Install the matching version explicitly so the server's SWC transform
|
||||
# (server/vitest.config.ts) can load.
|
||||
run: |
|
||||
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
|
||||
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Typecheck shared
|
||||
# Rides along here rather than in shared-coverage: this job already
|
||||
# pays the root install and shared build, and a coverage job may only
|
||||
# fail because tests failed (it gates the scan).
|
||||
run: cd shared && npm run typecheck
|
||||
|
||||
- name: Build server (tsc -> dist)
|
||||
run: cd server && npm run build
|
||||
|
||||
@@ -116,30 +163,73 @@ jobs:
|
||||
- name: Typecheck tests
|
||||
# tests/ is outside the build tsconfig, so a service that grows a
|
||||
# constructor parameter does not break its hand-wired call sites at
|
||||
# compile time: the argument arrives as undefined and throws at runtime,
|
||||
# if anything reads it at all. This is the step that catches it.
|
||||
# compile time: the argument arrives as undefined and throws at
|
||||
# runtime, if anything reads it at all. This is the step that
|
||||
# catches it.
|
||||
run: cd server && npm run typecheck:tests
|
||||
|
||||
- name: Lint
|
||||
run: cd server && npm run lint:check
|
||||
|
||||
- name: Run tests
|
||||
run: cd server && npm run test:coverage
|
||||
client-quality:
|
||||
name: Client Types & Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
- name: Upload coverage
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
name: backend-coverage
|
||||
path: server/coverage/
|
||||
retention-days: 7
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared && npm ci --workspace client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Typecheck
|
||||
run: cd client && npm run typecheck
|
||||
|
||||
- name: Lint
|
||||
run: cd client && npm run lint:check
|
||||
|
||||
- name: Page pattern check
|
||||
run: cd client && npm run lint:pages
|
||||
|
||||
plugin-sdk-quality:
|
||||
name: Plugin SDK Types
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: plugin-sdk
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
|
||||
# installs from its own directory.
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
s3-contract:
|
||||
name: S3 Driver Contract (MinIO)
|
||||
runs-on: ubuntu-latest
|
||||
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty
|
||||
# and the AIStor container would fail to boot.
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty and
|
||||
# the AIStor container would fail to boot. Pushes and manual dispatches do
|
||||
# have secrets, so only fork PRs are filtered out. (The implicit success()
|
||||
# on this expression also keeps the job skipped when a preflight fails.)
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -203,14 +293,10 @@ jobs:
|
||||
TREK_TEST_S3_SECRET_ACCESS_KEY: trekci-secret
|
||||
run: cd server && npx vitest run tests/unit/nest/storage/s3.driver.contract.test.ts
|
||||
|
||||
client-quality:
|
||||
# Split out of client-tests: the suite takes ~11 minutes, and having the
|
||||
# gates in front of it meant a single lint finding threw away the whole test
|
||||
# signal for that run. Both jobs pay the install/build, which is cheap next
|
||||
# to running the two in series.
|
||||
name: Client Types & Lint
|
||||
server-coverage:
|
||||
name: Server Coverage
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -221,24 +307,34 @@ jobs:
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared && npm ci --workspace client
|
||||
run: npm ci
|
||||
|
||||
- name: Ensure @swc/core's Linux binary for unplugin-swc
|
||||
# The lockfile was generated on Windows and omits @swc/core's Linux
|
||||
# optional native binary, so npm ci/install skips it on the runner.
|
||||
# Install the matching version explicitly so the server's SWC transform
|
||||
# (server/vitest.config.ts) can load.
|
||||
run: |
|
||||
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
|
||||
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Typecheck
|
||||
run: cd client && npm run typecheck
|
||||
- name: Run tests with coverage
|
||||
run: cd server && npm run test:coverage
|
||||
|
||||
- name: Lint
|
||||
run: cd client && npm run lint:check
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: sonar-server-coverage
|
||||
path: server/coverage/
|
||||
retention-days: 1
|
||||
|
||||
- name: Page pattern check
|
||||
run: cd client && npm run lint:pages
|
||||
|
||||
client-tests:
|
||||
name: Client Tests
|
||||
client-coverage:
|
||||
name: Client Coverage
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -248,19 +344,142 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
# Only the shared and client workspaces — a full root npm ci is not
|
||||
# needed, and the scoped install keeps @testing-library/dom (a peer of
|
||||
# @testing-library/react) in the tree without the re-resolution dance
|
||||
# the folded coverage job used to need.
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared && npm ci --workspace client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Run tests
|
||||
- name: Run tests with coverage
|
||||
run: cd client && npm run test:coverage
|
||||
|
||||
- name: Upload coverage
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: frontend-coverage
|
||||
name: sonar-client-coverage
|
||||
path: client/coverage/
|
||||
retention-days: 7
|
||||
retention-days: 1
|
||||
|
||||
shared-coverage:
|
||||
name: Shared Coverage
|
||||
runs-on: ubuntu-latest
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd shared && npm run test:coverage
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: sonar-shared-coverage
|
||||
path: shared/coverage/
|
||||
retention-days: 1
|
||||
|
||||
plugin-sdk-coverage:
|
||||
name: Plugin SDK Coverage
|
||||
runs-on: ubuntu-latest
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: plugin-sdk
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
# 22 matches the TREK server runtime and has node:sqlite, which the
|
||||
# dev-server tests exercise (same choice as publish-plugin-sdk.yml).
|
||||
node-version: 22
|
||||
|
||||
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
|
||||
# installs from its own directory.
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: sonar-plugin-sdk-coverage
|
||||
path: plugin-sdk/coverage/
|
||||
retention-days: 1
|
||||
|
||||
scan:
|
||||
name: Scan
|
||||
runs-on: ubuntu-latest
|
||||
# Fork PRs don't receive SONAR_TOKEN — skip the scan rather than fail it.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
# Coverage jobs failing must stop the scan: a missing lcov file would make
|
||||
# every line of that package report as uncovered, and the dashboard would
|
||||
# show a collapse that did not happen. The preflight gates already stop it
|
||||
# transitively through these four, so they aren't repeated here.
|
||||
needs: [server-coverage, client-coverage, shared-coverage, plugin-sdk-coverage]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Sonar reads git blame to date each issue and to decide what counts
|
||||
# as new code. A shallow clone leaves it blind to both.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download server coverage
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: sonar-server-coverage
|
||||
path: server/coverage/
|
||||
|
||||
- name: Download client coverage
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: sonar-client-coverage
|
||||
path: client/coverage/
|
||||
|
||||
- name: Download shared coverage
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: sonar-shared-coverage
|
||||
path: shared/coverage/
|
||||
|
||||
- name: Download plugin-sdk coverage
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: sonar-plugin-sdk-coverage
|
||||
path: plugin-sdk/coverage/
|
||||
|
||||
- name: Scan
|
||||
uses: SonarSource/sonarqube-scan-action@v8
|
||||
env:
|
||||
# SonarQube Cloud is the scan action's default host, so no
|
||||
# SONAR_HOST_URL — the token is a sonarcloud.io token for the
|
||||
# liketrek org.
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
with:
|
||||
# New Code is configured as "previous version", so this string is what
|
||||
# draws that line — changing it re-bases every "new code" metric.
|
||||
# Pinned rather than read from package.json on purpose: package.json is
|
||||
# still on 3.4.1 while the project has been analysed as 4.0.0 all along,
|
||||
# and taking the lower number would silently move the baseline. Bump it
|
||||
# here as part of the release, together with the version bump.
|
||||
#
|
||||
# Everything else — sources, tests, exclusions, the documented
|
||||
# false-positive list — lives in sonar-project.properties so it stays
|
||||
# reviewable in a PR rather than buried in workflow YAML.
|
||||
args: -Dsonar.projectVersion=4.0.0
|
||||
|
||||
@@ -4,11 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## What TREK is
|
||||
|
||||
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with four workspaces:
|
||||
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with three workspaces:
|
||||
|
||||
- **`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.
|
||||
- **`nest-mcp/`** (`@trek/nest-mcp`) — decorator-driven MCP tool/resource/prompt registration for NestJS, consumed by the server. Deliberately **extraction-clean**: no imports from `@trek/server`/`@trek/shared`, no TREK semantics.
|
||||
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server, sandboxed plugin runtime.
|
||||
- **`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.
|
||||
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
|
||||
|
||||
**`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.
|
||||
@@ -17,16 +16,16 @@ Each package has its own `CLAUDE.md` with internals; this file covers the monore
|
||||
|
||||
## Commands
|
||||
|
||||
Run from the repo root unless noted. Per the user's preference, **use `npm run ... --workspace=<pkg>`, not `npm -w <pkg>`**.
|
||||
Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not `npm -w <pkg>`**.
|
||||
|
||||
```bash
|
||||
npm run dev # build shared + nest-mcp, then watch shared + server + client concurrently
|
||||
npm run build # build shared → nest-mcp → server → client (order matters)
|
||||
npm run lint # lint all four workspaces
|
||||
npm run format # prettier --write all four
|
||||
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 format # prettier --write all three
|
||||
```
|
||||
|
||||
Tests (the user runs client tests as `cd client && npm run test`):
|
||||
Tests:
|
||||
|
||||
```bash
|
||||
npm run test # all workspaces
|
||||
@@ -35,7 +34,7 @@ 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, server + client
|
||||
npm run test:cov # coverage (lcov), all four packages incl. shared + 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)
|
||||
@@ -89,12 +88,12 @@ Offline-first, with a layered data flow. A Page never owns state directly:
|
||||
- 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.
|
||||
|
||||
## Project direction (from the 2026 full-repo audit)
|
||||
## Project direction
|
||||
|
||||
A verified audit of the whole repo lives in `audit_report/TREK_audit_reports/` (`00_MASTER_SUMMARY.md` is the entrypoint, `01_STEERING_ROADMAP.md` the plan). Its verdict shapes how all new code should be written:
|
||||
These principles come out of a verified 2026 full-repo audit and shape how all new code should be written:
|
||||
|
||||
- **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 feature components calling the raw API past the offline core, still ~190 files). 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 (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.
|
||||
- **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.
|
||||
@@ -105,7 +104,7 @@ A verified audit of the whole repo lives in `audit_report/TREK_audit_reports/` (
|
||||
- **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): ...`). Per user preference, **do not add Co-Authored-By Claude attribution**.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -21,10 +21,14 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://sonarcloud.io/project/overview?id=liketrek_TREK"><img alt="Sonar Quality Gate" src="https://img.shields.io/sonar/quality_gate/liketrek_TREK?server=https%3A%2F%2Fsonarcloud.io&style=for-the-badge" /></a>
|
||||
|
||||
<img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/liketrek/TREK/test.yml?branch=main&style=for-the-badge">
|
||||
|
||||
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
|
||||
<br />
|
||||
|
||||
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
|
||||
@@ -33,7 +37,6 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
@@ -238,64 +241,15 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A
|
||||
|
||||
<h2 id="docker-compose-production">Docker Compose (production)</h2>
|
||||
|
||||
<details>
|
||||
<summary>Full compose example with secure defaults</summary>
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: mauriceboe/trek:latest
|
||||
container_name: trek
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- SETUID
|
||||
- SETGID
|
||||
tmpfs:
|
||||
- /tmp:noexec,nosuid,size=64m
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
|
||||
- TZ=${TZ:-UTC}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
||||
- APP_URL=${APP_URL:-} # required for OIDC + email links
|
||||
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
|
||||
# - TRUST_PROXY=1
|
||||
# - OIDC_ISSUER=https://auth.example.com
|
||||
# - OIDC_CLIENT_ID=trek
|
||||
# - OIDC_CLIENT_SECRET=supersecret
|
||||
# - OIDC_DISPLAY_NAME=SSO
|
||||
# - OIDC_ADMIN_CLAIM=groups
|
||||
# - OIDC_ADMIN_VALUE=app-trek-admins
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
```
|
||||
|
||||
Then:
|
||||
The repository ships a ready-to-use [`docker-compose.yml`](docker-compose.yml)
|
||||
with secure defaults and every option documented inline. Download it, then:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
|
||||
</details>
|
||||
See [Install with Docker Compose](https://github.com/liketrek/TREK/wiki/Install-Docker-Compose)
|
||||
for the full walkthrough.
|
||||
|
||||
<br />
|
||||
|
||||
@@ -309,6 +263,8 @@ helm install trek trek/trek
|
||||
|
||||
See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/README.md) for values.
|
||||
|
||||
<br />
|
||||
|
||||
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
|
||||
|
||||
TREK works as a Progressive Web App — no App Store needed.
|
||||
@@ -323,36 +279,10 @@ TREK then launches fullscreen with its own icon, just like a native app.
|
||||
|
||||
## Updating
|
||||
|
||||
**Docker Compose:**
|
||||
See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose,
|
||||
Docker run, Helm, Portainer, Unraid and Proxmox, plus the encryption-key note.
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
**Docker run** — reuse the original volume paths:
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/trek
|
||||
docker rm -f trek
|
||||
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
|
||||
```
|
||||
|
||||
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||
|
||||
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
|
||||
|
||||
<h3>Rotating the Encryption Key</h3>
|
||||
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||
|
||||
```bash
|
||||
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
|
||||
```
|
||||
|
||||
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
|
||||
<br />
|
||||
|
||||
<h2 id="reverse-proxy">Reverse Proxy</h2>
|
||||
|
||||
@@ -432,175 +362,18 @@ Caddy handles TLS and WebSockets automatically.
|
||||
|
||||
## Environment variables
|
||||
|
||||
> [!NOTE]
|
||||
> Variables are validated at startup (fail-fast). An unset or blank variable
|
||||
> always falls back to its default, but a variable set to a malformed value
|
||||
> (e.g. `PORT=abc`, `SESSION_DURATION=bogus`, `DEMO_MODE=maybe`) aborts boot
|
||||
> with a report listing every offending variable. Boolean switches accept
|
||||
> `true`/`false`, `1`/`0`, `on`/`off` and `yes`/`no` (any casing).
|
||||
Every variable, its default and what it does: see
|
||||
[Environment Variables](https://github.com/liketrek/TREK/wiki/Environment-Variables).
|
||||
|
||||
<details>
|
||||
<summary><b>Full reference</b></summary>
|
||||
## Star History
|
||||
|
||||
<br />
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| **Core** | | |
|
||||
| `PORT` | Server port | `3000` |
|
||||
| `NODE_ENV` | Environment (`production` / `development`) | `production` |
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||
| `TREK_WIKI_DIR` | Where the in-app Help pages (`/help`) read their content from. TREK ships its wiki and serves it from disk, so Help always matches the version you are running — you should not need to set this. Point it at your own directory to serve custom docs. If the path does not exist, Help falls back to fetching the public GitHub wiki (needs outbound network, and tracks the latest release). | bundled `wiki/` |
|
||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
|
||||
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||
| **OIDC / SSO** | | |
|
||||
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
||||
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
||||
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
|
||||
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
|
||||
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
|
||||
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
|
||||
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
|
||||
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
|
||||
| **Initial setup** | | |
|
||||
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
|
||||
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
|
||||
| **Other** | | |
|
||||
| `TREK_MANAGED` | Marks the install as centrally administered: the operator owns the configuration, credentials and upgrades, so the instance admin is not offered settings the operator sets. Leave unset when you run TREK yourself. | `false` |
|
||||
| `PLACES_API_KEY` | Places credential supplied by whoever operates the install. When set, it is used for every lookup and the per-user keys in Admin > Settings are ignored. Leave unset to keep the stored keys. | — |
|
||||
| `PLACES_API_BASE` | Send the Places calls to this origin instead of `https://places.googleapis.com` (an egress proxy, a cache, a gateway holding the key). Path and query are unchanged, so it has to speak the same API. | — |
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user. At the cap, the least-recently-active session is closed to make room | `20` |
|
||||
|
||||
</details>
|
||||
|
||||
<br />
|
||||
|
||||
<h2 id="storage">Storage</h2>
|
||||
|
||||
TREK separates *what* it stores (eight content categories — trip documents,
|
||||
journey photos, cover images, profile pictures, place images, the two photo
|
||||
caches, and backups) from *where* it stores it (named backends). Out of the
|
||||
box everything lives on local disk (`uploads/` and `data/backups`). From
|
||||
**Admin → Storage** you can:
|
||||
|
||||
- **Add S3-compatible backends** (AWS S3, Cloudflare R2, Backblaze B2, Garage,
|
||||
MinIO/AIStor) and assign any category to them.
|
||||
- **Replicate a backend**: edit it and pick **Mirror targets** — every write is
|
||||
then also copied to each target. The classic off-site backup setup is:
|
||||
add an S3 backend, edit `backups-local`, tick the S3 backend as a mirror
|
||||
target, save. Replica writes happen one after another during each upload, so
|
||||
a slow or unreachable target slows every upload of every category on that
|
||||
backend — fine for backups, worth weighing for hot categories. Existing
|
||||
objects are not replicated when you add a target — press **Sync now** on
|
||||
the backend to copy them (one sync at a time; progress and cancel in the
|
||||
panel).
|
||||
- **Watch the Health strip**: replica failures never fail the original
|
||||
request; they are recorded and shown there instead. All-clear means every
|
||||
replicated write landed. Failures also notify admins over the configured
|
||||
notification channels (first failure immediately, then hourly summaries) —
|
||||
tune it under Admin → Notifications.
|
||||
- **Test** any backend from its row (for a replicated backend it probes the
|
||||
primary and each target individually). Targets must be saved before Test
|
||||
can probe them.
|
||||
- **Usage**: per-category and per-backend object counts and sizes, scanned
|
||||
nightly and on demand.
|
||||
|
||||
Backend credentials are encrypted at rest. Without an explicit
|
||||
`ENCRYPTION_KEY` the implicit key from the data directory is used — that
|
||||
works, but the implicit key rides inside backups, so setting `ENCRYPTION_KEY`
|
||||
explicitly is recommended for credentialed backends. On an existing install
|
||||
that has been running without an explicit key, do not just set a new one —
|
||||
that would orphan already-stored secrets; use the key-rotation procedure (see
|
||||
*Rotating the Encryption Key* under Updating) to move to an explicit key.
|
||||
|
||||
Notes and limits: reassigning a populated category prompts to move its
|
||||
existing objects — accepting copies everything to the new backend, flips the
|
||||
category, then sweeps any writes that raced the copy; source objects are kept
|
||||
(not deleted) and reported as reclaimable (count + size) to reclaim manually;
|
||||
declining routes only new writes to the new backend, leaving old objects in
|
||||
place as before. Only one storage job (a mirror sync or a category migration)
|
||||
runs at a time, cancelling before the flip is always safe, and a failed copy
|
||||
never flips the category; media categories on S3 work but every served byte
|
||||
proxies through the server — the proxy answers conditional GETs (ETag /
|
||||
Last-Modified) and a single `Range` request, while a multi-range request is
|
||||
answered with the full object; AWS buckets
|
||||
with Object-Lock or checksum-requiring policies
|
||||
reject the server's uploads; self-hosted endpoints (MinIO/Garage) should be
|
||||
addressed by IP or `localhost` unless configured for virtual-hosted buckets.
|
||||
The legacy `/uploads/photos` directory from older TREK versions is still
|
||||
served and included in backups, but it is not a configurable category.
|
||||
Running the storage contract suite against a live MinIO/AIStor is a
|
||||
license-gated procedure — CI runs it automatically for same-repo pull
|
||||
requests (fork PRs skip it, since they have no access to the license
|
||||
secret); to run it yourself locally, see `docker-compose.minio-test.yml`'s
|
||||
header for the manual procedure.
|
||||
|
||||
### Provisioning at first boot (seed file)
|
||||
|
||||
For infrastructure-as-code setups, mount a `storage-config.json` into the
|
||||
data directory — it is imported **once**, on the first boot that has no
|
||||
stored storage configuration, and loudly ignored afterwards:
|
||||
|
||||
```jsonc
|
||||
// storage-config.json — secrets may be plaintext (encrypted on import)
|
||||
// or already-encrypted enc:v1: values.
|
||||
{
|
||||
"backends": [
|
||||
{ "name": "off-site", "type": "s3", "options": {
|
||||
"endpoint": "https://s3.example.com", "bucket": "trek",
|
||||
"accessKeyId": "…", "secretAccessKey": "…" } },
|
||||
{ "name": "backups-mirror", "type": "mirror", "options": {
|
||||
"primary": "backups-local", "replicas": ["off-site"] } }
|
||||
],
|
||||
"categories": { "backups": "backups-mirror" }
|
||||
}
|
||||
```
|
||||
|
||||
(The panel presents this exact setup as **Mirror targets** on `backups-local`.)
|
||||
|
||||
```yaml
|
||||
# docker-compose: add under the trek service's volumes
|
||||
- ./storage-config.json:/app/data/storage-config.json:ro
|
||||
```
|
||||
|
||||
An invalid seed file aborts boot with the exact validation error — an
|
||||
actively-provisioning operator sees the problem instead of a silent default.
|
||||
|
||||
### Recovery and restore
|
||||
|
||||
To reset storage configuration to the built-in defaults (or to re-import a
|
||||
seed file): stop the server, run
|
||||
`sqlite3 data/travel.db "DELETE FROM app_settings WHERE key LIKE 'storage.%';"`,
|
||||
and start it again.
|
||||
|
||||
Backups include the storage configuration (it lives in the database). If your
|
||||
only backup sits on S3 and the credentials for it sit inside that backup:
|
||||
start a fresh instance, enter the S3 credentials in **Admin → Storage** (or
|
||||
mount a seed file), then restore the backup from the Backup panel.
|
||||
|
||||
## Data & Backups
|
||||
|
||||
- **Database** — SQLite, stored in `./data/travel.db`
|
||||
- **Uploads** — stored in `./uploads/` by default; every content category can be reassigned to another backend in **Admin → Storage**
|
||||
- **Logs** — `./data/logs/trek.log` (auto-rotated)
|
||||
- **Backups** — create and restore via Admin Panel
|
||||
- **Auto-Backups** — configurable schedule and retention in Admin Panel
|
||||
|
||||
<br />
|
||||
<a href="https://www.star-history.com/?repos=liketrek%2FTREK&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&theme=dark&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Data sources
|
||||
|
||||
@@ -609,6 +382,8 @@ The Atlas map's country and sub-national (province/county) boundaries come from
|
||||
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
|
||||
for full third-party attributions.
|
||||
|
||||
<br />
|
||||
|
||||
## License
|
||||
|
||||
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.4.1
|
||||
version: 4.2.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.4.1"
|
||||
appVersion: "4.2.0"
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# 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.
|
||||
|
||||
## Commands (run from `client/`)
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server; proxies /api,/ws,/uploads,/oauth → 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)
|
||||
```
|
||||
|
||||
Single test:
|
||||
|
||||
```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 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` 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.
|
||||
|
||||
## Data flow — offline-first
|
||||
|
||||
The layering is **store → 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.
|
||||
|
||||
## Direction (from the 2026 audit)
|
||||
|
||||
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:
|
||||
|
||||
- **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.
|
||||
|
||||
## Other 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`.
|
||||
|
||||
## 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`).
|
||||
@@ -0,0 +1,98 @@
|
||||
import { test, expect, devices } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// Phone coverage for #2051: the day details area steps days under a horizontal
|
||||
// swipe, so the trip is navigable one-handed instead of only from the chip rail
|
||||
// pinned to the top of the screen.
|
||||
//
|
||||
// jsdom can drive the handlers but not the browser around them: whether the
|
||||
// gesture survives real touch dispatch, whether the browser hands the vertical
|
||||
// axis to its own scroller first, and whether a swipe at the first day stays put
|
||||
// are all engine behaviour. This drives CDP's Input.dispatchTouchEvent, so the
|
||||
// events are the ones a finger produces — not synthesised TouchEvent objects.
|
||||
//
|
||||
// Chromium, because CDP is where real touch injection lives. The file-level
|
||||
// device sets a phone viewport with a coarse pointer, which is what puts the
|
||||
// mobile shell on screen at all.
|
||||
test.use({ ...devices['Pixel 7'] })
|
||||
|
||||
/** One finger, dispatched through the browser's own input pipeline. */
|
||||
async function swipe(page: Page, from: { x: number; y: number }, to: { x: number; y: number }, steps = 8) {
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: from.x, y: from.y }] })
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{
|
||||
x: from.x + ((to.x - from.x) * i) / steps,
|
||||
y: from.y + ((to.y - from.y) * i) / steps,
|
||||
}],
|
||||
})
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
await cdp.detach()
|
||||
// The out leg is 150ms and the in leg 220ms.
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
const activeChip = (page: Page) => page.locator('button[aria-current="true"]')
|
||||
|
||||
test('#2051 phone: the day details area swipes between days', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
// Without a phone viewport and a coarse pointer the mobile shell never mounts,
|
||||
// and everything below would be proving nothing.
|
||||
const env = await page.evaluate(() => ({
|
||||
width: window.innerWidth,
|
||||
coarse: window.matchMedia('(pointer: coarse)').matches,
|
||||
}))
|
||||
expect(env.width, 'Pixel sits below the 768px phone breakpoint').toBeLessThan(768)
|
||||
expect(env.coarse, 'Pixel reports a coarse primary pointer').toBe(true)
|
||||
|
||||
const stamp = Date.now()
|
||||
const created = await page.request.post('/api/trips', {
|
||||
data: { title: `Day swipe ${stamp}`, start_date: '2099-06-01', end_date: '2099-06-05' },
|
||||
})
|
||||
expect(created.ok(), 'seed trip').toBeTruthy()
|
||||
const tripId = (await created.json()).trip?.id ?? (await created.json()).id
|
||||
|
||||
await page.goto(`/trips/${tripId}`)
|
||||
await expect(activeChip(page)).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const chips = page.locator('button[aria-current], button[aria-current="true"]')
|
||||
const rail = activeChip(page).locator('xpath=..')
|
||||
expect(await rail.locator('button').count(), 'the seeded trip has five days').toBe(5)
|
||||
expect(await chips.count()).toBeGreaterThan(0)
|
||||
|
||||
// The swipe zone: below the chip rail, above the dock, across the middle.
|
||||
const mid = Math.round(env.width / 2)
|
||||
const y = 460
|
||||
|
||||
const day1 = (await activeChip(page).textContent())!.trim()
|
||||
|
||||
// 1. A swipe left steps forward.
|
||||
await swipe(page, { x: mid + 90, y }, { x: mid - 90, y })
|
||||
const day2 = (await activeChip(page).textContent())!.trim()
|
||||
expect(day2, 'swiping left moved to the next day').not.toBe(day1)
|
||||
|
||||
// 2. A swipe right steps back to where it started.
|
||||
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
|
||||
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
|
||||
|
||||
// 3. The first day has nowhere to go back to — it must stay put, and the
|
||||
// browser must not have been handed a page-back either.
|
||||
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
|
||||
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
|
||||
expect(page.url()).toContain(`/trips/${tripId}`)
|
||||
|
||||
// 4. A vertical drag belongs to the card's own scroller, never to the day.
|
||||
await swipe(page, { x: mid, y: y + 120 }, { x: mid, y: y - 120 })
|
||||
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
|
||||
|
||||
// 5. And the rail itself still works, so the gesture added a path rather than
|
||||
// replacing one.
|
||||
await rail.locator('button').nth(2).click()
|
||||
await expect(activeChip(page)).not.toHaveText(day1)
|
||||
})
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "3.4.1",
|
||||
"version": "4.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -37,6 +37,7 @@
|
||||
"@fontsource/museomoderno": "^5.3.0",
|
||||
"@fontsource/playfair-display": "^5.3.0",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
"axios": "^1.6.7",
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a
|
||||
// co-located use<Page>() hook into JSX — it must not own state/effects itself.
|
||||
//
|
||||
// We scan only the default-export component body (from `export default function`
|
||||
// up to the next top-level `function` declaration or EOF), so presentational
|
||||
// sub-components and helper hooks living in the same file are not flagged.
|
||||
// We scan the default-export component body (from `export default function` up
|
||||
// to the next top-level `function` declaration or EOF), and the same span of a
|
||||
// top-level `*PageDesktop`/`*PageMobile` — since ViewportRoute started picking
|
||||
// the branch, the default export is a two-line shim and the real container is
|
||||
// one of those two. Everything else at top level stays exempt: presentational
|
||||
// sub-components and helper hooks live in these files on purpose.
|
||||
// Context hooks like useTranslation/useParams are fine; the smell is stateful
|
||||
// logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef.
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
@@ -13,23 +16,38 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages')
|
||||
const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef']
|
||||
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`)
|
||||
// `[<(]` rather than `\(`: a generic call — `useRef<() => void>(null)` — is the
|
||||
// same thing, and it slipped through for as long as the pattern only knew `(`.
|
||||
const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*[<(]`)
|
||||
|
||||
// Declarations the widened scan caught that are not this change's to move.
|
||||
// Listed by the name they declare rather than by line, so an entry dies with
|
||||
// its declaration instead of drifting onto whatever follows it. The rest of
|
||||
// each listed file is still checked.
|
||||
const KNOWN_ESCAPES = {
|
||||
'TripPlannerPage.tsx': ['glMap', 'bookingExpense'],
|
||||
}
|
||||
|
||||
const violations = []
|
||||
for (const file of readdirSync(pagesDir)) {
|
||||
if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue
|
||||
const src = readFileSync(join(pagesDir, file), 'utf8')
|
||||
const lines = src.split('\n')
|
||||
const start = lines.findIndex(l => /export default function/.test(l))
|
||||
if (start === -1) continue
|
||||
// The page body ends at the next top-level declaration (a `function` at
|
||||
// column 0) — everything after that is a sub-component or helper.
|
||||
let end = lines.length
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
|
||||
}
|
||||
for (let i = start; i < end; i++) {
|
||||
if (bannedRe.test(lines[i])) {
|
||||
const allowed = KNOWN_ESCAPES[file] ?? []
|
||||
const starts = []
|
||||
lines.forEach((line, i) => {
|
||||
if (/export default function/.test(line) || /^function \w+Page(Desktop|Mobile)\b/.test(line)) starts.push(i)
|
||||
})
|
||||
for (const start of starts) {
|
||||
// The page body ends at the next top-level declaration (a `function` at
|
||||
// column 0) — everything after that is a sub-component or helper.
|
||||
let end = lines.length
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break }
|
||||
}
|
||||
for (let i = start; i < end; i++) {
|
||||
if (!bannedRe.test(lines[i])) continue
|
||||
if (allowed.some(name => new RegExp(`\\b${name}\\b`).test(lines[i]))) continue
|
||||
violations.push(`${file}:${i + 1} ${lines[i].trim()}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
|
||||
+26
-1
@@ -7,7 +7,8 @@ import { server } from '../tests/helpers/msw/server'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { resetAllStores } from '../tests/helpers/store'
|
||||
import { buildUser, buildSettings } from '../tests/helpers/factories'
|
||||
import { buildUser, buildSettings, buildTrip } from '../tests/helpers/factories'
|
||||
import { offlineDb } from './db/offlineDb'
|
||||
import { SETTINGS_WAIT_MS } from './utils/startDestination'
|
||||
import App from './App'
|
||||
|
||||
@@ -142,6 +143,30 @@ describe('RootRedirect — startup destination', () => {
|
||||
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-028b: opens the cached active trip when the launch is offline', async () => {
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
useSettingsStore.setState({
|
||||
isLoaded: true,
|
||||
settings: buildSettings({ start_page: 'active_trip' }),
|
||||
})
|
||||
const today = new Date()
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10)
|
||||
await offlineDb.trips.put(buildTrip({ id: 42, title: 'Japan', start_date: iso(today), end_date: iso(today) }))
|
||||
const onLine = Object.getOwnPropertyDescriptor(Navigator.prototype, 'onLine')
|
||||
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
|
||||
|
||||
// navigator and the Dexie table are shared with every other test in the
|
||||
// file, so a failed assertion must not leave the rest of them offline.
|
||||
try {
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument())
|
||||
} finally {
|
||||
if (onLine) Object.defineProperty(Navigator.prototype, 'onLine', onLine)
|
||||
delete (navigator as unknown as { onLine?: boolean }).onLine
|
||||
await offlineDb.trips.clear()
|
||||
}
|
||||
})
|
||||
|
||||
// The whole point of the localStorage mirror: the default launch must not pay
|
||||
// for a lookup it doesn't need.
|
||||
it('FE-COMP-APP-029: never asks for the active trip when starting on the dashboard', async () => {
|
||||
|
||||
+6
-3
@@ -19,7 +19,8 @@ import ErrorBoundary from './components/shared/ErrorBoundary'
|
||||
import { lazyWithRetry } from './utils/lazyWithRetry'
|
||||
import { useIsPhone } from './mobile/useIsPhone'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi, tripsApi } from './api/client'
|
||||
import { authApi } 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'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
@@ -105,7 +106,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
||||
// A session that ended on its own should come back to where it left off; a
|
||||
// deliberate sign-out is a fresh start and gets no return ticket, so the
|
||||
// startup destination decides where the next login lands.
|
||||
if (loggingOut) return <Navigate to="/login" replace />
|
||||
if (loggingOut) return <Navigate to="/login" replace state={{ noRedirect: true }} />
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
|
||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||
}
|
||||
@@ -244,7 +245,9 @@ function RootRedirect() {
|
||||
: (mirrored ?? { page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB })
|
||||
if (page !== 'active_trip') { setTarget('/dashboard'); return }
|
||||
let cancelled = false
|
||||
tripsApi.active()
|
||||
// Through the repo, not the api: offline this answers from Dexie instead of
|
||||
// bouncing a cached trip to the dashboard.
|
||||
tripRepo.active()
|
||||
.then(({ trip }) => { if (!cancelled) setTarget(trip ? tripStartPath(trip.id, tab) : '/dashboard') })
|
||||
.catch(() => { if (!cancelled) setTarget('/dashboard') })
|
||||
return () => { cancelled = true }
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('client > request interceptor', () => {
|
||||
expect(sink[0].headers['X-Idempotency-Key']).toBe('queued-key')
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-004: falls back to a random token when crypto.randomUUID is missing', async () => {
|
||||
it('FE-APIWIRE-004: falls back to getRandomValues when crypto.randomUUID is missing', async () => {
|
||||
const realCrypto = globalThis.crypto
|
||||
vi.stubGlobal('crypto', {
|
||||
getRandomValues: realCrypto.getRandomValues.bind(realCrypto),
|
||||
@@ -120,9 +120,30 @@ describe('client > request interceptor', () => {
|
||||
const sink: InternalAxiosRequestConfig[] = []
|
||||
await apiClient.post('/probe', {}, { adapter: okAdapter(sink) })
|
||||
|
||||
const key = String(sink[0].headers['X-Idempotency-Key'])
|
||||
expect(key).toMatch(/^[a-z0-9]+$/)
|
||||
expect(key).not.toMatch(/-/)
|
||||
// randomUUID needs a secure context, so on the plain-http installs the
|
||||
// README documents this branch is what actually runs. getRandomValues has no
|
||||
// such requirement, so the fallback is still a full v4 UUID.
|
||||
expect(String(sink[0].headers['X-Idempotency-Key'])).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-004b: the last-resort key is never degenerate, even with no Web Crypto at all', async () => {
|
||||
vi.stubGlobal('crypto', undefined)
|
||||
|
||||
const keys = new Set<string>()
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const sink: InternalAxiosRequestConfig[] = []
|
||||
await apiClient.post('/probe', {}, { adapter: okAdapter(sink) })
|
||||
keys.add(String(sink[0].headers['X-Idempotency-Key']))
|
||||
}
|
||||
|
||||
// The old fallback was Math.random().toString(36).slice(2), which is not
|
||||
// length-stable: 0.5 yields a single character and 0 yields the empty string.
|
||||
// An empty key makes the server skip deduplication entirely, so a retried
|
||||
// write applies twice.
|
||||
for (const k of keys) expect(k.length).toBeGreaterThan(16)
|
||||
expect(keys.size).toBe(50)
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-005: the socket id header is omitted while no socket is connected', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-APISURF-001 to FE-APISURF-052
|
||||
// FE-APISURF-001 to FE-APISURF-054
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
tagsApi, categoriesApi, adminApi, addonsApi, pluginsApi, airtrailApi, journeyApi,
|
||||
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
|
||||
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
|
||||
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi,
|
||||
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi,
|
||||
} from './client'
|
||||
|
||||
interface Recorded { method: string; url: string; body: unknown }
|
||||
@@ -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' },
|
||||
@@ -396,6 +399,20 @@ describe('client > endpoint wiring', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-053: memoriesApi maps the photo-provider endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'status', r: () => memoriesApi.status('immich'), e: 'GET /api/integrations/memories/immich/status' },
|
||||
{ n: 'search', r: () => memoriesApi.search('immich', { from: '2026-01-01', to: '2026-01-02', page: 1, size: 50 }), e: 'POST /api/integrations/memories/immich/search' },
|
||||
{ n: 'albums', r: () => memoriesApi.albums('immich'), e: 'GET /api/integrations/memories/immich/albums' },
|
||||
{ n: 'albumPhotos', r: () => memoriesApi.albumPhotos('immich', 'alb-1'), e: 'GET /api/integrations/memories/immich/albums/alb-1/photos' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-054: memoriesApi passes the album passphrase as a query parameter', async () => {
|
||||
const rec = await traceOne(() => memoriesApi.albumPhotos('synologyphotos', 'alb-2', 'p/w?'))
|
||||
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
|
||||
})
|
||||
|
||||
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
|
||||
@@ -848,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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+86
-31
@@ -1,6 +1,7 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Place } from '../types'
|
||||
import { randomId } from '../utils/randomId'
|
||||
import {
|
||||
weatherResultSchema, type WeatherResult,
|
||||
inAppListResultSchema, type InAppListResult,
|
||||
@@ -29,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,
|
||||
@@ -52,9 +53,16 @@ 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'
|
||||
import { downloadBlob } from '../utils/fileDownload'
|
||||
|
||||
/**
|
||||
* Validate a response payload against its @trek/shared Zod schema — but only in
|
||||
@@ -120,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
|
||||
@@ -150,10 +158,7 @@ apiClient.interceptors.request.use(
|
||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||
const method = (config.method ?? '').toLowerCase()
|
||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2)
|
||||
config.headers['X-Idempotency-Key'] = key
|
||||
config.headers['X-Idempotency-Key'] = randomId()
|
||||
}
|
||||
return config
|
||||
},
|
||||
@@ -316,6 +321,14 @@ export const authApi = {
|
||||
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
||||
},
|
||||
// Keys for the public API. Same shape as the MCP tokens above and a separate
|
||||
// credential: one drives the assistant tools, the other reads trips over HTTP,
|
||||
// and a key of the wrong kind is refused like one that does not exist.
|
||||
apiKeys: {
|
||||
list: () => apiClient.get('/auth/api-tokens').then(r => r.data),
|
||||
create: (name: string) => apiClient.post('/auth/api-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/auth/api-tokens/${id}`).then(r => r.data),
|
||||
},
|
||||
passkey: {
|
||||
registerOptions: (password: string) => apiClient.post('/auth/passkey/register/options', { password }).then(r => r.data),
|
||||
registerVerify: (attestationResponse: unknown, name?: string) => apiClient.post('/auth/passkey/register/verify', { attestationResponse, name }).then(r => r.data),
|
||||
@@ -460,6 +473,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.
|
||||
@@ -528,11 +543,16 @@ 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),
|
||||
pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data),
|
||||
// `version` pins the exact version to install (the rollback path); omitted, the server
|
||||
// resolves the newest TREK-compatible version itself.
|
||||
pluginUpdate: (id: string, version?: string) =>
|
||||
apiClient.post(`/admin/plugins/${id}/update`, version ? { version } : {}).then(r => r.data),
|
||||
// Release a per-plugin update hold (set by a deliberate non-latest install).
|
||||
pluginResumeUpdates: (id: string) => apiClient.post(`/admin/plugins/${id}/resume-updates`).then(r => r.data),
|
||||
// Re-trust a ROTATED author signing key and update, in ONE call. `publicKey` is the
|
||||
// full key the admin was shown (not a fingerprint): the server compares it exactly, so
|
||||
// it can refuse if the registry entry was re-keyed again since the dialog rendered.
|
||||
@@ -544,6 +564,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.
|
||||
@@ -770,16 +801,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).
|
||||
@@ -850,7 +878,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).
|
||||
@@ -968,6 +996,32 @@ export const journeyApi = {
|
||||
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
// Photo providers (Immich, Synology Photos, …) behind /api/integrations/memories.
|
||||
// The provider sits on the user's own hardware and the server proxies through to
|
||||
// it, so the 8s default does not apply — and neither does any number picked
|
||||
// here. The server already holds the deadline, and its worst case is longer than
|
||||
// anything that would look reasonable in this file: a Synology call is 30s, and
|
||||
// an expired session makes it re-authenticate and try again. A client cap below
|
||||
// that turns a slow NAS into a NAS that reads as disconnected, which is what
|
||||
// these calls looked like before they moved off raw fetch (which had no timeout
|
||||
// either). Callers that need to give up early pass an AbortSignal.
|
||||
const MEMORIES_TIMEOUT = 0
|
||||
|
||||
export const memoriesApi = {
|
||||
status: (provider: string): Promise<{ connected: boolean }> =>
|
||||
apiClient.get(`/integrations/memories/${provider}/status`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
|
||||
search: (provider: string, body: { from: string; to: string; page: number; size: number }, signal?: AbortSignal) =>
|
||||
apiClient.post(`/integrations/memories/${provider}/search`, body, { timeout: MEMORIES_TIMEOUT, signal }).then(r => r.data),
|
||||
albums: (provider: string) =>
|
||||
apiClient.get(`/integrations/memories/${provider}/albums`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
|
||||
albumPhotos: (provider: string, albumId: string, passphrase?: string, signal?: AbortSignal) =>
|
||||
apiClient.get(`/integrations/memories/${provider}/albums/${albumId}/photos`, {
|
||||
timeout: MEMORIES_TIMEOUT,
|
||||
params: passphrase ? { passphrase } : undefined,
|
||||
signal,
|
||||
}).then(r => r.data),
|
||||
}
|
||||
|
||||
export const mapsApi = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
|
||||
@@ -1070,7 +1124,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')),
|
||||
}
|
||||
@@ -1144,13 +1199,7 @@ export const backupApi = {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
downloadBlob(await res.blob(), filename)
|
||||
},
|
||||
delete: (filename: string) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
|
||||
restore: (filename: string) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
|
||||
@@ -1188,15 +1237,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')),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-010
|
||||
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-014
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup, PRESET_OPT_IN_ONLY, PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from './oauthScopes'
|
||||
|
||||
// The consent page mirrors the server's scope list by hand, so read the server
|
||||
// file and compare. Parsing keeps this a client-only test (no server import).
|
||||
function readServerScopes(): string[] {
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const src = readFileSync(resolve(here, '../../../server/src/mcp/scopes.ts'), 'utf8')
|
||||
const block = src.slice(src.indexOf('export const SCOPES = {'))
|
||||
const body = block.slice(0, block.indexOf('} as const;'))
|
||||
return [...body.matchAll(/^\s*[A-Z_]+:\s*'([a-z]+:[a-z]+)'/gm)].map(m => m[1])
|
||||
}
|
||||
|
||||
describe('SCOPE_GROUPS', () => {
|
||||
it('FE-OAUTH-SCOPES-001: contains all expected scope keys', () => {
|
||||
@@ -18,6 +31,7 @@ describe('SCOPE_GROUPS', () => {
|
||||
'vacay:read', 'vacay:write',
|
||||
'geo:read', 'weather:read',
|
||||
'journey:read', 'journey:write', 'journey:share',
|
||||
'plugins:use',
|
||||
]
|
||||
for (const scope of expected) {
|
||||
expect(SCOPE_GROUPS).toHaveProperty(scope)
|
||||
@@ -34,8 +48,16 @@ describe('SCOPE_GROUPS', () => {
|
||||
})
|
||||
|
||||
describe('ALL_SCOPES', () => {
|
||||
it('FE-OAUTH-SCOPES-003: contains exactly 29 scopes', () => {
|
||||
expect(ALL_SCOPES).toHaveLength(29)
|
||||
it('FE-OAUTH-SCOPES-003: contains exactly as many scopes as the server defines', () => {
|
||||
expect(ALL_SCOPES).toHaveLength(readServerScopes().length)
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-011: matches server/src/mcp/scopes.ts exactly', () => {
|
||||
const serverScopes = readServerScopes()
|
||||
// Guards the parse itself: a moved file or a reformatted list must fail
|
||||
// here rather than quietly compare two empty sets.
|
||||
expect(serverScopes.length).toBeGreaterThan(0)
|
||||
expect(new Set(ALL_SCOPES)).toEqual(new Set(serverScopes))
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
|
||||
@@ -102,3 +124,26 @@ describe('getScopesByGroup', () => {
|
||||
expect(groups['oauth.scope.group.trips']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('client presets', () => {
|
||||
// The presets are written as "everything except deletes", so a new scope joins
|
||||
// them silently. That is right for a scope over the user's own data and wrong
|
||||
// for one that runs third-party code, so the exclusion gets a test.
|
||||
it('FE-OAUTH-SCOPES-012: never offers an opt-in-only scope by default', () => {
|
||||
expect(PRESET_OPT_IN_ONLY.size).toBeGreaterThan(0)
|
||||
for (const scope of PRESET_OPT_IN_ONLY) {
|
||||
expect(PRESET_SCOPES_DEFAULT).not.toContain(scope)
|
||||
expect(PRESET_SCOPES_READONLY).not.toContain(scope)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-013: still offers every other non-destructive scope', () => {
|
||||
const expected = ALL_SCOPES.filter(s => !s.includes(':delete') && !PRESET_OPT_IN_ONLY.has(s))
|
||||
expect(PRESET_SCOPES_DEFAULT).toEqual(expected)
|
||||
expect(PRESET_SCOPES_DEFAULT).toContain('trips:write')
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-014: the read-only preset stays read-only', () => {
|
||||
expect(PRESET_SCOPES_READONLY.every(s => s.endsWith(':read'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,10 +43,32 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
|
||||
'journey:read': { labelKey: 'oauth.scope.journey:read.label', descriptionKey: 'oauth.scope.journey:read.description', groupKey: 'oauth.scope.group.journey' },
|
||||
'journey:write': { labelKey: 'oauth.scope.journey:write.label', descriptionKey: 'oauth.scope.journey:write.description', groupKey: 'oauth.scope.group.journey' },
|
||||
'journey:share': { labelKey: 'oauth.scope.journey:share.label', descriptionKey: 'oauth.scope.journey:share.description', groupKey: 'oauth.scope.group.journey' },
|
||||
'files:read': { labelKey: 'oauth.scope.files:read.label', descriptionKey: 'oauth.scope.files:read.description', groupKey: 'oauth.scope.group.files' },
|
||||
'files:write': { labelKey: 'oauth.scope.files:write.label', descriptionKey: 'oauth.scope.files:write.description', groupKey: 'oauth.scope.group.files' },
|
||||
'files:content': { labelKey: 'oauth.scope.files:content.label', descriptionKey: 'oauth.scope.files:content.description', groupKey: 'oauth.scope.group.files' },
|
||||
'settings:read': { labelKey: 'oauth.scope.settings:read.label', descriptionKey: 'oauth.scope.settings:read.description', groupKey: 'oauth.scope.group.settings' },
|
||||
'settings:write': { labelKey: 'oauth.scope.settings:write.label', descriptionKey: 'oauth.scope.settings:write.description', groupKey: 'oauth.scope.group.settings' },
|
||||
'plugins:use': { labelKey: 'oauth.scope.plugins:use.label', descriptionKey: 'oauth.scope.plugins:use.description', groupKey: 'oauth.scope.group.plugins' },
|
||||
}
|
||||
|
||||
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
|
||||
|
||||
/**
|
||||
* Scopes a client preset must never tick for you.
|
||||
*
|
||||
* The presets are written as "everything except deletes", so a new scope joins
|
||||
* them silently. That is right for a new read or write scope on data the user
|
||||
* already owns, and wrong for plugins:use: it turns on third-party tool
|
||||
* execution, which nobody would have chosen by installing an editor.
|
||||
*/
|
||||
export const PRESET_OPT_IN_ONLY = new Set(['plugins:use'])
|
||||
|
||||
/** The five full-access presets: everything destructive or opt-in-only removed. */
|
||||
export const PRESET_SCOPES_DEFAULT = ALL_SCOPES.filter(s => !s.includes(':delete') && !PRESET_OPT_IN_ONLY.has(s))
|
||||
|
||||
/** The read-only preset (VS Code). Kept beside the default so the pair stays visible. */
|
||||
export const PRESET_SCOPES_READONLY = ALL_SCOPES.filter(s => s.endsWith(':read') && !PRESET_OPT_IN_ONLY.has(s))
|
||||
|
||||
// Group all scopes for the client registration form
|
||||
export const SCOPE_GROUP_NAMES = [...new Set(Object.values(SCOPE_GROUPS).map(s => s.groupKey))]
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -98,7 +95,7 @@ describe('AddonManager', () => {
|
||||
|
||||
it('FE-ADMIN-ADDON-002: empty state when addons list is empty', async () => {
|
||||
render(<AddonManager />);
|
||||
await screen.findByText('No addons available');
|
||||
expect(await screen.findByText('No addons available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-003: trip addons section renders with correct section header', async () => {
|
||||
@@ -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 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 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
|
||||
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,130 +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">
|
||||
<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 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
|
||||
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 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 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 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`
|
||||
@@ -554,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
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -49,7 +49,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
|
||||
it('FE-ADMIN-MCP-002: empty state rendered when no tokens', async () => {
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('No MCP tokens have been created yet');
|
||||
expect(await screen.findByText('No MCP tokens have been created yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-003: token list renders correctly', async () => {
|
||||
@@ -195,7 +195,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
await screen.findByText('Failed to load tokens');
|
||||
expect(await screen.findByText('Failed to load tokens')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
|
||||
@@ -216,7 +216,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
await screen.findByText('No active OAuth sessions');
|
||||
expect(await screen.findByText('No active OAuth sessions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => {
|
||||
@@ -318,6 +318,6 @@ describe('AdminMcpTokensPanel', () => {
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
await screen.findByText('Failed to revoke session');
|
||||
expect(await screen.findByText('Failed to revoke session')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,13 +126,13 @@ export default function AdminMcpTokensPanel() {
|
||||
</span>
|
||||
))}
|
||||
{!expanded && hidden > 0 && (
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
<button type="button" onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
+{hidden} more
|
||||
</button>
|
||||
)}
|
||||
{expanded && hidden > 0 && (
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
<button type="button" onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
show less
|
||||
</button>
|
||||
@@ -146,7 +146,7 @@ export default function AdminMcpTokensPanel() {
|
||||
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(session.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<button onClick={() => setRevokeConfirmId(session.id)}
|
||||
<button type="button" onClick={() => setRevokeConfirmId(session.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
@@ -199,7 +199,7 @@ export default function AdminMcpTokensPanel() {
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
||||
</span>
|
||||
<button onClick={() => setDeleteConfirmId(token.id)}
|
||||
<button type="button" onClick={() => setDeleteConfirmId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
@@ -214,16 +214,21 @@ export default function AdminMcpTokensPanel() {
|
||||
{/* Revoke OAuth session modal */}
|
||||
{revokeConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
|
||||
role="button" tabIndex={0} aria-label={t('common.close')}
|
||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRevokeConfirmId(null) }
|
||||
}}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.oauthSessions.revokeMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setRevokeConfirmId(null)}
|
||||
<button type="button" onClick={() => setRevokeConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleRevoke(revokeConfirmId)}
|
||||
<button type="button" onClick={() => handleRevoke(revokeConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
@@ -235,16 +240,21 @@ export default function AdminMcpTokensPanel() {
|
||||
{/* Delete MCP token modal */}
|
||||
{deleteConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
||||
role="button" tabIndex={0} aria-label={t('common.close')}
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setDeleteConfirmId(null) }
|
||||
}}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.mcpTokens.deleteMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setDeleteConfirmId(null)}
|
||||
<button type="button" onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(deleteConfirmId)}
|
||||
<button type="button" onClick={() => handleDelete(deleteConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -201,6 +203,9 @@ function registryEntry(over: Record<string, unknown> = {}) {
|
||||
id: 'trek-gotify', name: 'Gotify', author: 'Acme', description: 'Push', repo: 'acme/gotify',
|
||||
type: 'integration', latest: '2.0.0', minTrekVersion: null, reviewedAt: null,
|
||||
screenshotUrl: null, signed: true, authorPublicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
// The real browse response always carries latestCompatible (server-side hostCompat);
|
||||
// mirror `latest` by default, exactly like a fully-compatible entry.
|
||||
latestCompatible: (over.latest as string | undefined) ?? '2.0.0',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
@@ -278,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.
|
||||
@@ -840,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', () => {
|
||||
@@ -944,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)
|
||||
@@ -964,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()
|
||||
@@ -1087,6 +1129,235 @@ describe('AdminPluginsPanel — updates', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Update availability must follow what this TREK can actually INSTALL (latestCompatible,
|
||||
* computed server-side), never the absolute newest published version — a banner counting
|
||||
* versions the update endpoint would refuse nags the admin toward a guaranteed 400.
|
||||
*/
|
||||
describe('AdminPluginsPanel — compatible updates only', () => {
|
||||
const outdated = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false })
|
||||
|
||||
it('FE-COMP-PLUGINS-UPD-001: an update this TREK cannot install is neither offered nor counted', async () => {
|
||||
panelWith([outdated], {
|
||||
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
|
||||
})
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-UPD-002: the update offer is the newest compatible version, not the absolute latest', async () => {
|
||||
panelWith([outdated], {
|
||||
registry: [registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0' })],
|
||||
})
|
||||
render(<AdminPluginsPanel />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument()
|
||||
expect(screen.getByText('1 updates available for your plugins.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /update → v3\.0\.0/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-UPD-003: a newer version needing a newer TREK leaves a passive hint on the row', async () => {
|
||||
panelWith([outdated], {
|
||||
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
|
||||
})
|
||||
render(<AdminPluginsPanel />)
|
||||
|
||||
expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-UPD-004: a legacy entry with only minTrekVersion still gets a hint range', async () => {
|
||||
panelWith([outdated], {
|
||||
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: null, minTrekVersion: '4.0.0', hostVersion: '3.3.0' })],
|
||||
})
|
||||
render(<AdminPluginsPanel />)
|
||||
|
||||
expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-UPD-005: no hint when the latest version is the one on offer', async () => {
|
||||
panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0' })] })
|
||||
render(<AdminPluginsPanel />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument()
|
||||
expect(screen.queryByText(/needs TREK/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The update hold (#plugins): a deliberate non-latest install sets updateHold on the row,
|
||||
* which takes it out of the banner count and "Update all" — a rollback the admin just
|
||||
* made must not be nagged straight back. The row says so and offers to resume.
|
||||
*/
|
||||
describe('AdminPluginsPanel — update hold', () => {
|
||||
const held = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false, updateHold: true })
|
||||
|
||||
it('FE-COMP-PLUGINS-HOLD-001: a held plugin leaves the banner and loses its update button', async () => {
|
||||
panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] })
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Updates paused at v1.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-HOLD-002: Resume updates releases the hold on the server', async () => {
|
||||
let resumed = false
|
||||
panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] })
|
||||
server.use(http.post('*/api/admin/plugins/trek-gotify/resume-updates', () => {
|
||||
resumed = true
|
||||
return HttpResponse.json({ updateHold: false })
|
||||
}))
|
||||
withToast()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /resume updates/i }))
|
||||
|
||||
await waitFor(() => expect(resumed).toBe(true))
|
||||
expect(await screen.findByText('Updates resumed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-HOLD-003: Update all skips held plugins', async () => {
|
||||
const updated: string[] = []
|
||||
panelWith(
|
||||
[held, plugin({ id: 'trek-ntfy', name: 'Ntfy', source_repo: 'acme/ntfy', version: '1.0.0', operatorEgress: false })],
|
||||
{ registry: [registryEntry({ latest: '2.0.0' }), registryEntry({ id: 'trek-ntfy', latest: '3.0.0' })] },
|
||||
)
|
||||
server.use(http.post('*/api/admin/plugins/:id/update', ({ params }) => {
|
||||
updated.push(String(params.id))
|
||||
return HttpResponse.json({ version: '3.0.0', activated: true, newPermissions: [], newEgress: [] })
|
||||
}))
|
||||
withToast()
|
||||
|
||||
expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /update all/i }))
|
||||
|
||||
await waitFor(() => expect(updated).toEqual(['trek-ntfy']))
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The version picker (#plugins): install any published version from the Discover detail
|
||||
* modal, and roll an installed plugin back through the update pipeline (which owns the
|
||||
* stop-child + re-consent machinery). Compat verdicts are SERVER-computed per version —
|
||||
* the picker only renders them, it never re-derives range logic.
|
||||
*/
|
||||
describe('AdminPluginsPanel — version picker', () => {
|
||||
const VERSIONS = [
|
||||
{ version: '3.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false },
|
||||
{ version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
|
||||
{ version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
|
||||
]
|
||||
const detailWithVersions = () => ({
|
||||
...registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false }),
|
||||
size: 1024, publishedAt: null, manifest: null, versions: VERSIONS,
|
||||
})
|
||||
|
||||
function mockDiscoverDetail() {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
|
||||
http.get('*/api/admin/plugins/registry', () =>
|
||||
HttpResponse.json([registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false })])),
|
||||
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
|
||||
)
|
||||
}
|
||||
|
||||
it('FE-COMP-PLUGINS-VPICK-001: the detail modal lists every version and installs the picked one', async () => {
|
||||
let body: unknown = null
|
||||
mockDiscoverDetail()
|
||||
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
||||
body = await request.json()
|
||||
return HttpResponse.json({ id: 'trek-gotify', version: '1.5.0' })
|
||||
}))
|
||||
withToast()
|
||||
const tabs = await screen.findAllByRole('tab', { name: /^discover/i })
|
||||
fireEvent.click(tabs[0])
|
||||
fireEvent.click(await screen.findByText('Gotify'))
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i }))
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' }))
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-VPICK-002: an incompatible version is greyed with its TREK requirement, not installable', async () => {
|
||||
mockDiscoverDetail()
|
||||
render(<AdminPluginsPanel />)
|
||||
const tabs = await screen.findAllByRole('tab', { name: /^discover/i })
|
||||
fireEvent.click(tabs[0])
|
||||
fireEvent.click(await screen.findByText('Gotify'))
|
||||
|
||||
expect(await screen.findByText(/^needs TREK >=4\.0\.0$/i)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /^install 3\.0\.0$/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-VPICK-003: Change version rolls back through update, after an explicit data warning', async () => {
|
||||
let body: unknown = null
|
||||
panelWith([plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false })], {
|
||||
registry: [registryEntry({ latest: '2.0.0' })],
|
||||
})
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
|
||||
http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => {
|
||||
body = await request.json()
|
||||
return HttpResponse.json({ version: '1.5.0', activated: true, newPermissions: [], newEgress: [] })
|
||||
}),
|
||||
)
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^switch to 1\.5\.0$/i }))
|
||||
|
||||
// The rollback keeps the plugin's data — the older code may not read it. Informed consent, no snapshot.
|
||||
expect(await screen.findByText(/data written by the newer version stays in place/i)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /roll back/i }))
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ version: '1.5.0' }))
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-VPICK-004: picking a NEWER version in the picker updates without the downgrade warning', async () => {
|
||||
let body: unknown = null
|
||||
panelWith([plugin({ source_repo: 'acme/gotify', version: '1.5.0', operatorEgress: false })], {
|
||||
registry: [registryEntry({ latest: '2.0.0' })],
|
||||
})
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
|
||||
http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => {
|
||||
body = await request.json()
|
||||
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
|
||||
}),
|
||||
)
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^switch to 2\.0\.0$/i }))
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ version: '2.0.0' }))
|
||||
expect(screen.queryByText(/data written by the newer version stays in place/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-VPICK-005: the installed version is marked and not switchable; sideloads get no picker', async () => {
|
||||
panelWith([
|
||||
plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false }),
|
||||
plugin({ id: 'side-load', name: 'Sideload', source_repo: 'local:upload', version: '1.0.0', operatorEgress: false }),
|
||||
], { registry: [registryEntry({ latest: '2.0.0' })] })
|
||||
server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())))
|
||||
withToast()
|
||||
|
||||
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-side-load'))
|
||||
expect(screen.queryByRole('button', { name: /change version/i })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('plugin-row-menu-btn-side-load')) // close
|
||||
|
||||
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
|
||||
|
||||
const current = await screen.findByTestId('version-row-2.0.0')
|
||||
expect(within(current).getByText('Installed')).toBeInTheDocument()
|
||||
expect(within(current).queryByRole('button', { name: /switch/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdminPluginsPanel — capability and dependency chips', () => {
|
||||
it('FE-COMP-PLUGINS-PANEL-035: a permission-rich plugin renders one chip per capability', async () => {
|
||||
panelWith([plugin({
|
||||
@@ -1207,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' },
|
||||
@@ -1405,7 +1688,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
|
||||
it('FE-W5PLG-005: the update filter and the updates-first sort both use the registry version', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }])
|
||||
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }])
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
|
||||
@@ -1423,7 +1706,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
|
||||
it('FE-W5PLG-006: switching tabs snaps a tab-only sort key back to name', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }])
|
||||
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }])
|
||||
render(<AdminPluginsPanel />)
|
||||
await screen.findByText('Alpha Widget')
|
||||
|
||||
@@ -1531,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: [] })),
|
||||
)
|
||||
@@ -1546,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 })),
|
||||
)
|
||||
@@ -1862,3 +2147,315 @@ describe('AdminPluginsPanel signature fingerprints and dismissals', () => {
|
||||
await waitFor(() => expect(uploaded).toBe(false))
|
||||
})
|
||||
})
|
||||
|
||||
// FE-W5PLG-030 — Update all drives the same single-slot busy flag as a per-row update,
|
||||
// so the requests have to go out one after another.
|
||||
describe('AdminPluginsPanel — update all', () => {
|
||||
const updatableRow = (over: Record<string, unknown> = {}) => plugin({
|
||||
id: 'a-widget', name: 'Alpha Widget', type: 'widget', version: '1.0.0', operatorEgress: false, ...over,
|
||||
})
|
||||
|
||||
it('FE-W5PLG-030: Update all sends the second update only after the first came back', async () => {
|
||||
const started: string[] = []
|
||||
let release = () => {}
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () => HttpResponse.json({
|
||||
enabled: true, devLink: false,
|
||||
plugins: [updatableRow(), updatableRow({ id: 'b-widget', name: 'Beta Widget' })],
|
||||
})),
|
||||
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([
|
||||
{ id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' },
|
||||
{ id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' },
|
||||
])),
|
||||
http.post('*/api/admin/plugins/:id/update', async ({ params }) => {
|
||||
started.push(String(params.id))
|
||||
if (started.length === 1) await gate
|
||||
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
|
||||
}),
|
||||
)
|
||||
render(<AdminPluginsPanel />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /update all/i }))
|
||||
|
||||
await waitFor(() => expect(started).toHaveLength(1))
|
||||
// Give a parallel dispatch every chance to show up before releasing the first.
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(started).toEqual(['a-widget'])
|
||||
|
||||
release()
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
|
||||
Radio, Luggage, Globe, Image, CalendarDays, Bell,
|
||||
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette,
|
||||
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 { usePluginStore } from '../../store/pluginStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -49,6 +51,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
|
||||
@@ -63,6 +71,8 @@ interface PluginRow {
|
||||
keyFingerprint?: string | null
|
||||
/** Why an update was refused, if one was. `version` is the version that was refused. */
|
||||
updateBlock?: { code: string; detail: string | null; version: string | null } | null
|
||||
/** A deliberate non-latest install paused updates: out of the banner/Update all until resumed. */
|
||||
updateHold?: boolean
|
||||
}
|
||||
interface RegistryItem {
|
||||
id: string
|
||||
@@ -93,9 +103,21 @@ interface RegistryItem {
|
||||
/** The full key — public, and carried in full because re-trust compares it exactly. */
|
||||
authorPublicKey?: string | null
|
||||
}
|
||||
/** One published version, with its SERVER-computed compat verdict (the client has no semver). */
|
||||
interface VersionInfo {
|
||||
version: string
|
||||
publishedAt: string | null
|
||||
size: number | null
|
||||
signed: boolean
|
||||
/** The declared TREK requirement, or null when the entry carries no bounds at all. */
|
||||
trek: string | null
|
||||
compatible: boolean
|
||||
}
|
||||
interface RegistryDetail extends RegistryItem {
|
||||
size: number | null
|
||||
publishedAt: string | null
|
||||
/** Every published version, newest first — the version picker's data. */
|
||||
versions?: VersionInfo[]
|
||||
manifest: {
|
||||
// Optional because a registry may omit an empty list — the detail view has to
|
||||
// degrade rather than throw halfway through its render.
|
||||
@@ -109,7 +131,12 @@ interface RegistryDetail extends RegistryItem {
|
||||
requiredAddons?: string[]
|
||||
pluginDependencies?: PluginDep[]
|
||||
/** Display slice of the manifest's capabilities — drives the same chips as an installed row. */
|
||||
capabilities?: { widget?: { slot?: string }; tripPage?: { replaces?: string[] } }
|
||||
capabilities?: {
|
||||
widget?: { slot?: string }
|
||||
tripPage?: { replaces?: string[] }
|
||||
/** Tools the plugin will publish on the MCP server once mcp:tools is granted. */
|
||||
mcpTools?: Array<{ name: string; title?: string; description: string }>
|
||||
}
|
||||
} | null
|
||||
}
|
||||
|
||||
@@ -187,7 +214,7 @@ const PERM_KEYS = PLUGIN_PERMISSIONS
|
||||
const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page']
|
||||
|
||||
function isNewer(a: string, b: string): boolean {
|
||||
const nums = (v: string) => v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0)
|
||||
const nums = (v: string) => v.split('-')[0].split('.').map(n => Number.parseInt(n, 10) || 0)
|
||||
const pa = nums(a), pb = nums(b)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const x = pa[i] || 0, y = pb[i] || 0
|
||||
@@ -227,6 +254,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
}
|
||||
// Replacing planner tabs is the one capability that HIDES core UI — always chip it.
|
||||
if (caps.tripPage?.replaces?.length) out.push({ icon: LayoutDashboard, label: t('admin.plugins.cap.replacesTabs') })
|
||||
if (perms.includes('mcp:tools')) out.push({ icon: Bot, label: t('admin.plugins.cap.mcpTools') })
|
||||
if (perms.some(p => p.startsWith('ws:broadcast'))) out.push({ icon: Radio, label: t('admin.plugins.cap.realtime') })
|
||||
if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') })
|
||||
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
|
||||
@@ -388,10 +416,18 @@ 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('')
|
||||
const [confirmUninstall, setConfirmUninstall] = useState<PluginRow | null>(null)
|
||||
// The version picker for an INSTALLED plugin ("Change version…"); versions land async
|
||||
// from the registry detail endpoint, which computes each version's compat verdict.
|
||||
const [versionPicker, setVersionPicker] = useState<{ plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean } | null>(null)
|
||||
// A picked version OLDER than the installed one — held here until the admin consents
|
||||
// to the data risk (the plugin's data dir stays; older code may not understand it).
|
||||
const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null)
|
||||
// A QUEUE, not one slot: "Update All" can produce several re-consent prompts —
|
||||
// each must be shown, not silently overwritten by the last one.
|
||||
const [consentQueue, setConsentQueue] = useState<Array<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }>>([])
|
||||
@@ -420,10 +456,13 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
// Index the registry once per fetch: the version map the update badges read, plus the
|
||||
// whole entry (author key + signed flag) the trust badges and re-trust dialog need.
|
||||
// The map holds latestCompatible — the newest version THIS TREK can install (computed
|
||||
// server-side) — not the absolute latest, so the banner never counts an update the
|
||||
// update endpoint would refuse.
|
||||
const indexRegistry = (items: RegistryItem[]) => {
|
||||
const vers: Record<string, string> = {}
|
||||
const byId: Record<string, RegistryItem> = {}
|
||||
items.forEach((i) => { byId[i.id] = i; if (i.latest) vers[i.id] = i.latest })
|
||||
items.forEach((i) => { byId[i.id] = i; if (i.latestCompatible) vers[i.id] = i.latestCompatible })
|
||||
setLatest(vers)
|
||||
setRegById(byId)
|
||||
}
|
||||
@@ -544,6 +583,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)
|
||||
@@ -551,7 +595,21 @@ export default function AdminPluginsPanel() {
|
||||
.catch(() => setErrorsFor({ id, rows: [] }))
|
||||
}
|
||||
|
||||
const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version))
|
||||
// A held plugin (deliberate non-latest install) is deliberately NOT an update candidate:
|
||||
// the admin just rolled it back, and the banner nagging them straight back would make
|
||||
// the rollback fight the UI. The row carries its own "paused" marker + resume instead.
|
||||
const updateAvailable = (p: PluginRow) => !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version))
|
||||
const resumeUpdates = (p: PluginRow) => act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed'))
|
||||
// A newer version exists but this TREK can't install it (and it isn't the one on offer):
|
||||
// said passively on the row, so the admin learns a TREK upgrade unlocks it instead of
|
||||
// wondering why no update shows. Null when the registry gives no range to point at.
|
||||
const newerIncompatible = (p: PluginRow): { version: string; range: string } | null => {
|
||||
const reg = regById[p.id]
|
||||
if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null
|
||||
if (latest[p.id] === reg.latest) return null
|
||||
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'))
|
||||
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
|
||||
@@ -639,9 +697,11 @@ export default function AdminPluginsPanel() {
|
||||
.finally(() => { setBusy(null); refresh() })
|
||||
}
|
||||
|
||||
const runUpdate = (p: PluginRow) => {
|
||||
// `version` pins the exact version to install (rollback / explicit switch); omitted,
|
||||
// the server resolves the newest TREK-compatible version itself.
|
||||
const runUpdate = (p: PluginRow, version?: string) => {
|
||||
setBusy(p.id); setMenu(null)
|
||||
adminApi.pluginUpdate(p.id)
|
||||
return adminApi.pluginUpdate(p.id, version)
|
||||
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
|
||||
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 }])
|
||||
@@ -673,9 +733,36 @@ export default function AdminPluginsPanel() {
|
||||
.finally(() => { setRetrusting(false); refresh() })
|
||||
}
|
||||
|
||||
// "Change version…" on an installed row: fetch the per-version list (with the server's
|
||||
// compat verdicts) and open the picker. The fetch is keyed to the plugin so a stale
|
||||
// response can't populate a picker that was reopened for another row.
|
||||
const openVersionPicker = (p: PluginRow) => {
|
||||
setMenu(null)
|
||||
setVersionPicker({ plugin: p, versions: null, failed: false })
|
||||
adminApi.pluginDetail(p.id)
|
||||
.then((d: RegistryDetail) => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur)))
|
||||
.catch(() => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur)))
|
||||
}
|
||||
|
||||
// Switching DOWN keeps the plugin's data dir, which the older code may not understand —
|
||||
// that asks for explicit consent first. Any other switch is the ordinary update path.
|
||||
const pickVersion = (version: string) => {
|
||||
if (!versionPicker) return
|
||||
const p = versionPicker.plugin
|
||||
setVersionPicker(null)
|
||||
if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version })
|
||||
else void runUpdate(p, version)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest])
|
||||
|
||||
// One after another: busy is a single slot, so parallel updates would leave the
|
||||
// other rows clickable mid-install and refresh once per plugin.
|
||||
const updateAll = async () => {
|
||||
for (const p of updatable) await runUpdate(p)
|
||||
}
|
||||
|
||||
// Installed list after search / type / status filters + sort.
|
||||
const shownInstalled = useMemo(() => {
|
||||
const term = q.trim().toLowerCase()
|
||||
@@ -733,7 +820,7 @@ export default function AdminPluginsPanel() {
|
||||
</div>
|
||||
)}
|
||||
{/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */}
|
||||
{menu && <div className="fixed inset-0 z-20" onClick={() => setMenu(null)} />}
|
||||
{menu && <div role="presentation" className="fixed inset-0 z-20" onClick={() => setMenu(null)} />}
|
||||
{/* Header */}
|
||||
<div className="px-4 sm:px-6 pt-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -789,11 +876,11 @@ export default function AdminPluginsPanel() {
|
||||
<SegBtn active={view === 'discover'} onClick={openDiscover} label={t('admin.plugins.tabDiscover')} count={registry?.length} />
|
||||
</div>
|
||||
<div className="sm:hidden flex items-center gap-2 shrink-0">
|
||||
<button onClick={pickUpload} title={t('admin.plugins.upload')}
|
||||
<button type="button" onClick={pickUpload} title={t('admin.plugins.upload')}
|
||||
className="h-[38px] w-[38px] grid place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
|
||||
<UploadCloud size={15} />
|
||||
</button>
|
||||
<button onClick={rescan} title={t('admin.plugins.rescan')}
|
||||
<button type="button" onClick={rescan} title={t('admin.plugins.rescan')}
|
||||
className="h-[38px] w-[38px] grid place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
@@ -836,11 +923,11 @@ export default function AdminPluginsPanel() {
|
||||
valueLabel={sortLabel(sort, t)}
|
||||
onPick={v => setSort(v as SortKey)} />
|
||||
|
||||
<button onClick={pickUpload} title={t('admin.plugins.upload')}
|
||||
<button type="button" onClick={pickUpload} title={t('admin.plugins.upload')}
|
||||
className="hidden sm:grid h-[38px] w-[38px] place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
|
||||
<UploadCloud size={15} />
|
||||
</button>
|
||||
<button onClick={rescan} title={t('admin.plugins.rescan')}
|
||||
<button type="button" onClick={rescan} title={t('admin.plugins.rescan')}
|
||||
className="hidden sm:grid h-[38px] w-[38px] place-items-center rounded-xl border border-edge bg-surface-card text-content-muted hover:text-content hover:border-content-faint transition-colors">
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
@@ -865,7 +952,7 @@ export default function AdminPluginsPanel() {
|
||||
<div className="mx-1.5 sm:mx-3 mb-2 mt-1 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl bg-warning-soft border border-warning/30">
|
||||
<ArrowUpCircle size={16} className="text-warning shrink-0" />
|
||||
<span className="text-xs text-content-secondary">{t('admin.plugins.updatesAvailable', { count: updatable.length })}</span>
|
||||
<button onClick={() => updatable.forEach(runUpdate)}
|
||||
<button type="button" onClick={() => void updateAll()}
|
||||
className="ml-auto text-xs font-semibold px-3 py-1.5 rounded-lg bg-warning text-white hover:opacity-90 transition-opacity">
|
||||
{t('admin.plugins.updateAll')}
|
||||
</button>
|
||||
@@ -879,14 +966,18 @@ export default function AdminPluginsPanel() {
|
||||
) : shownInstalled.map(p => (
|
||||
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
|
||||
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
|
||||
newerIncompatible={newerIncompatible(p)}
|
||||
blocked={blockIsCurrent(p, latest[p.id])}
|
||||
onToggle={() => toggle(p)}
|
||||
onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)}
|
||||
onChangeVersion={() => openVersionPicker(p)}
|
||||
onResume={() => void resumeUpdates(p)}
|
||||
onReviewBlock={() => setSignatureBlock({
|
||||
subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null },
|
||||
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>
|
||||
@@ -903,11 +994,11 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
{/* Error-log modal */}
|
||||
{errorsFor && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-2xl max-h-[70vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-2xl max-h-[70vh] 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"><Bug size={15} /> {errorsFor.id} — {t('admin.plugins.errorLog')}</span>
|
||||
<button onClick={() => setErrorsFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
<button type="button" onClick={() => setErrorsFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto text-xs font-mono">
|
||||
{errorsFor.rows.length === 0 ? <p className="text-content-faint py-4 text-center">{t('admin.plugins.noErrors')}</p> :
|
||||
@@ -925,13 +1016,13 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
{/* Operator-supplied egress hosts */}
|
||||
{egressFor && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEgressFor(null)}>
|
||||
<div 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="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 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 onClick={() => setEgressFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
<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>
|
||||
) : (
|
||||
@@ -943,7 +1034,7 @@ export default function AdminPluginsPanel() {
|
||||
{egressFor.hosts.map(h => (
|
||||
<div key={h} className="flex items-center justify-between gap-2 rounded-lg border border-edge-secondary px-3 py-2">
|
||||
<span className="text-sm font-mono text-content break-all">{h}</span>
|
||||
<button
|
||||
<button type="button"
|
||||
disabled={egressSaving}
|
||||
onClick={() => saveEgress(egressFor.hosts.filter(x => x !== h))}
|
||||
className="text-content-faint hover:text-danger disabled:opacity-50"
|
||||
@@ -958,7 +1049,7 @@ export default function AdminPluginsPanel() {
|
||||
placeholder="gotify.example.com"
|
||||
className="flex-1 rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content"
|
||||
/>
|
||||
<button
|
||||
<button type="button"
|
||||
disabled={egressSaving || !egressDraft.trim()}
|
||||
onClick={() => saveEgress([...egressFor.hosts, egressDraft.trim()])}
|
||||
className="rounded-lg bg-content px-3 py-2 text-sm text-surface disabled:opacity-50"
|
||||
@@ -973,6 +1064,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)}
|
||||
@@ -984,6 +1179,20 @@ export default function AdminPluginsPanel() {
|
||||
message={t('admin.plugins.uninstallBody')}
|
||||
/>
|
||||
|
||||
{versionPicker && (
|
||||
<VersionPickerDialog plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
|
||||
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!confirmDowngrade}
|
||||
onClose={() => setConfirmDowngrade(null)}
|
||||
onConfirm={() => { const d = confirmDowngrade!; setConfirmDowngrade(null); void runUpdate(d.plugin, d.version) }}
|
||||
title={t('admin.plugins.downgradeTitle')}
|
||||
message={t('admin.plugins.downgradeBody', { from: confirmDowngrade?.plugin.version ?? '', to: confirmDowngrade?.version ?? '' })}
|
||||
confirmLabel={t('admin.plugins.downgradeConfirm')}
|
||||
/>
|
||||
|
||||
{signatureBlock && (
|
||||
<SignatureBlockDialog
|
||||
data={signatureBlock} entry={regById[signatureBlock.subject.id]} busy={retrusting} t={t}
|
||||
@@ -1037,7 +1246,7 @@ function sortLabel(s: SortKey, t: T): string {
|
||||
|
||||
function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: () => void; label: string; count?: number }) {
|
||||
return (
|
||||
<button onClick={onClick} role="tab" aria-selected={active}
|
||||
<button type="button" onClick={onClick} role="tab" aria-selected={active}
|
||||
className={`inline-flex items-center gap-2 text-[13px] font-medium px-3.5 py-1.5 rounded-lg transition-colors ${
|
||||
active ? 'bg-surface-card text-content shadow-card' : 'text-content-muted hover:text-content'}`}>
|
||||
{label}
|
||||
@@ -1058,7 +1267,7 @@ function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMe
|
||||
const active = value !== options[0]?.[0]
|
||||
return (
|
||||
<div className="relative">
|
||||
<button onClick={() => setMenu(open ? null : id)} title={`${label}: ${valueLabel}`}
|
||||
<button type="button" onClick={() => setMenu(open ? null : id)} title={`${label}: ${valueLabel}`}
|
||||
className={`relative h-[38px] w-[38px] sm:w-auto px-0 sm:px-3 inline-flex items-center justify-center sm:justify-start gap-1.5 rounded-xl border bg-surface-card text-[13px] text-content-secondary transition-colors whitespace-nowrap hover:border-content-faint ${
|
||||
active ? 'border-content-faint' : 'border-edge'}`}>
|
||||
{icon}
|
||||
@@ -1070,7 +1279,7 @@ function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMe
|
||||
{open && (
|
||||
<div className="absolute top-11 right-0 z-30 min-w-[180px] max-w-[calc(100vw-2rem)] p-1.5 rounded-xl border border-edge bg-surface-card shadow-elevated">
|
||||
{options.map(([v, lbl]) => (
|
||||
<button key={v} onClick={() => { onPick(v); setMenu(null) }}
|
||||
<button type="button" key={v} onClick={() => { onPick(v); setMenu(null) }}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] transition-colors hover:bg-surface-tertiary ${
|
||||
value === v ? 'text-content font-semibold' : 'text-content-secondary'}`}>
|
||||
{lbl}<Check size={15} className={`ml-auto text-accent ${value === v ? 'opacity-100' : 'opacity-0'}`} />
|
||||
@@ -1089,18 +1298,18 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {
|
||||
<PackageOpen size={26} className="text-content-faint" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-content-muted">{t('admin.plugins.empty')}</p>
|
||||
<button onClick={onDiscover} className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text">
|
||||
<button type="button" onClick={onDiscover} className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text">
|
||||
<Download size={14} /> {t('admin.plugins.tabDiscover')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked, onToggle, onUpdate, onRestart, 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; blocked: boolean
|
||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onReviewBlock: () => void
|
||||
onErrors: () => void; onEgress: () => void; onUninstall: () => 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; onSettings: () => void; onUninstall: () => void
|
||||
}) {
|
||||
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
|
||||
const deps = deriveDeps(p, t)
|
||||
@@ -1152,12 +1361,32 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-warning">
|
||||
<ShieldAlert size={13} className="shrink-0" />
|
||||
<span className="truncate">{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}</span>
|
||||
<button onClick={onReviewBlock}
|
||||
<button type="button" onClick={onReviewBlock}
|
||||
className="shrink-0 font-semibold underline underline-offset-2 hover:opacity-80 transition-opacity">
|
||||
{t('admin.plugins.reviewBlock')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* A newer version this TREK can't run — informational, never a button: the fix
|
||||
is a TREK upgrade, so the row must not nag or offer a doomed install. */}
|
||||
{newerIncompatible && (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-content-faint">
|
||||
<Info size={13} className="shrink-0" />
|
||||
<span className="truncate">{t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Held: the admin deliberately installed a non-latest version, so updates are
|
||||
paused rather than nagged — resuming is one click, right where the pause shows. */}
|
||||
{p.updateHold && (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-content-faint">
|
||||
<PauseCircle size={13} className="shrink-0" />
|
||||
<span className="truncate">{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}</span>
|
||||
<button type="button" onClick={onResume} disabled={busy === p.id}
|
||||
className="shrink-0 font-semibold underline underline-offset-2 hover:opacity-80 transition-opacity disabled:opacity-50">
|
||||
{t('admin.plugins.resumeUpdates')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{p.status === 'error' && p.last_error ? (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-danger">
|
||||
<AlertTriangle size={13} className="shrink-0" /><span className="truncate">{p.last_error}</span>
|
||||
@@ -1175,7 +1404,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
|
||||
Actionable, and warning-toned until at least one host exists, because
|
||||
until then the plugin cannot reach anything and looks silently broken. */}
|
||||
{p.operatorEgress && (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={onEgress}
|
||||
title={t('admin.plugins.allowedHosts.hint')}
|
||||
className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border transition-colors ${
|
||||
@@ -1205,7 +1434,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
|
||||
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
|
||||
{hasUpdate && (
|
||||
<button onClick={onUpdate} disabled={busy === p.id} title={t('admin.plugins.updateTo', { version: latestVer })}
|
||||
<button type="button" onClick={onUpdate} disabled={busy === p.id} title={t('admin.plugins.updateTo', { version: latestVer })}
|
||||
className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold px-2 sm:px-2.5 py-1.5 rounded-full text-warning bg-warning-soft border border-warning/30 hover:opacity-90 transition-opacity disabled:opacity-50">
|
||||
<ArrowUpCircle size={13} /> <span className="hidden sm:inline">{t('admin.plugins.updateTo', { version: latestVer })}</span>
|
||||
</button>
|
||||
@@ -1215,7 +1444,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
|
||||
</span>
|
||||
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={onToggle} />
|
||||
<div className="relative">
|
||||
<button ref={menuBtnRef} data-testid={`plugin-row-menu-btn-${p.id}`} onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
|
||||
<button type="button" ref={menuBtnRef} data-testid={`plugin-row-menu-btn-${p.id}`} onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
|
||||
className="w-[34px] h-[34px] grid place-items-center rounded-lg text-content-faint hover:text-content hover:bg-surface-tertiary transition-colors">
|
||||
<MoreHorizontal size={17} />
|
||||
</button>
|
||||
@@ -1225,8 +1454,22 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
|
||||
{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} />
|
||||
)}
|
||||
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
|
||||
<>
|
||||
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
|
||||
@@ -1269,7 +1512,7 @@ function anchorMenu(trigger: HTMLElement | null): CSSProperties {
|
||||
|
||||
function MenuItem({ icon, label, onClick, danger }: { icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) {
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
<button type="button" onClick={onClick}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] transition-colors ${
|
||||
danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary hover:bg-surface-tertiary'}`}>
|
||||
{icon} {label}
|
||||
@@ -1315,6 +1558,10 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
const offer = installOffer(item, t)
|
||||
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">
|
||||
@@ -1343,7 +1590,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<Download size={11} /> {formatCompactCount(item.downloadCount)}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
|
||||
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">
|
||||
@@ -1401,12 +1648,12 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
const offer = installOffer(detail ?? item, t)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-xl max-h-[88vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<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-xl max-h-[88vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="relative">
|
||||
<Screenshot url={item.screenshotUrl} className="w-full aspect-[16/9]" iconSize={36} />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
<button onClick={onClose} className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-lg bg-black/40 text-white hover:bg-black/60 transition-colors"><X size={16} /></button>
|
||||
<button type="button" onClick={onClose} className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-lg bg-black/40 text-white hover:bg-black/60 transition-colors"><X size={16} /></button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 sm:gap-3.5 px-4 sm:px-5 -mt-7 relative z-[1]">
|
||||
@@ -1420,7 +1667,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 onClick={() => onInstall(item.id, offer.version)}
|
||||
<button type="button" onClick={() => onInstall(item.id, offer.version)}
|
||||
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">
|
||||
@@ -1461,6 +1708,32 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The tool text a plugin publishes goes straight into every user's
|
||||
assistant context once mcp:tools is granted, and nothing else in
|
||||
this dialog shows it. An admin approving the grant should read the
|
||||
names and descriptions first, not discover them in a chat. */}
|
||||
{manifest && (manifest.capabilities?.mcpTools?.length ?? 0) > 0 && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.mcpToolsTitle')}</h4>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{(manifest.capabilities?.mcpTools ?? []).map(tool => (
|
||||
<li key={tool.name} className="flex items-start gap-2.5 py-0.5">
|
||||
<Bot size={15} className="text-accent mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<code className="text-[12px] font-mono text-content">{tool.name}</code>
|
||||
{tool.title && <p className="text-[12.5px] font-medium text-content leading-snug">{tool.title}</p>}
|
||||
{/* The description is the assistant-facing text: it is what the
|
||||
model reads to decide whether to call the tool, so it is the
|
||||
line an admin most needs to see. Never hidden behind a title. */}
|
||||
<p className="text-[12.5px] text-content-secondary leading-snug">{tool.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-[11.5px] text-content-faint mt-2">{t('admin.plugins.mcpToolsHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manifest && (egress.length > 0 || manifest.operatorEgress) && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.connectsTitle')}</h4>
|
||||
@@ -1503,7 +1776,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.detailsTitle')}</h4>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 mt-2.5">
|
||||
{item.latest && <Meta k={t('admin.plugins.metaVersion')} v={`v${item.latest}`} />}
|
||||
{sizeKb && <Meta k={t('admin.plugins.metaSize')} v={`${sizeKb} KB`} />}
|
||||
{sizeKb !== null && <Meta k={t('admin.plugins.metaSize')} v={`${sizeKb} KB`} />}
|
||||
{/* The range, not just its lower bound: "TREK 3.2.0+" reads as "and anything
|
||||
newer", which is exactly the claim a `<4.0.0` upper bound denies. */}
|
||||
{(item.trek || item.minTrekVersion) && (
|
||||
@@ -1515,6 +1788,34 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Every published version, installable individually. The compat verdict per
|
||||
version is SERVER-computed — an incompatible one is explained, never offered. */}
|
||||
{(detail?.versions?.length ?? 0) > 1 && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.versionsTitle')}</h4>
|
||||
<div className="mt-2 space-y-0.5">
|
||||
{detail!.versions!.map(v => (
|
||||
<div key={v.version} className="flex items-center gap-2.5 py-1">
|
||||
<span className={`text-[12.5px] font-medium tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}>v{v.version}</span>
|
||||
{v.publishedAt && <span className="text-[11.5px] text-content-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
|
||||
{v.signed && <ShieldCheck size={13} className="text-success shrink-0" />}
|
||||
<span className="ml-auto" />
|
||||
{v.compatible ? (
|
||||
!installed && (
|
||||
<button type="button" onClick={() => onInstall(item.id, v.version)} disabled={busy === item.id}
|
||||
className="text-[11.5px] font-semibold px-2.5 py-1 rounded-full border border-edge bg-surface-card text-content-secondary hover:text-content hover:border-content-faint transition-colors disabled:opacity-50">
|
||||
{t('admin.plugins.installCompatible', { version: v.version })}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[11.5px] text-content-faint">{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 sm:px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary">
|
||||
@@ -1542,6 +1843,55 @@ function Meta({ k, v }: { k: string; v: string }) {
|
||||
return <div><div className="text-[12px] text-content-faint">{k}</div><div className="text-[12.5px] font-medium text-content mt-0.5">{v}</div></div>
|
||||
}
|
||||
|
||||
/**
|
||||
* "Change version…" for an INSTALLED plugin. Rows render the server's per-version compat
|
||||
* verdict: the installed version is marked, a compatible one gets a Switch action (the
|
||||
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
|
||||
* explained and never offered.
|
||||
*/
|
||||
function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
|
||||
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
|
||||
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
|
||||
}) {
|
||||
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 max-h-[80vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-3.5 border-b border-edge-secondary">
|
||||
<h3 className="text-[15px] font-semibold text-content truncate">{t('admin.plugins.versionPickerTitle', { name: plugin.name })}</h3>
|
||||
<button type="button" onClick={onClose} className="w-8 h-8 grid place-items-center rounded-lg text-content-faint hover:text-content hover:bg-surface-tertiary transition-colors shrink-0"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
{failed && <p className="text-xs text-danger px-2.5 py-2">{t('admin.plugins.detailError')}</p>}
|
||||
{!failed && !versions && <p className="text-xs text-content-faint px-2.5 py-2">{t('common.loading')}</p>}
|
||||
{versions?.length === 0 && <p className="text-xs text-content-faint px-2.5 py-2">{t('admin.plugins.noVersions')}</p>}
|
||||
{versions?.map(v => {
|
||||
const current = v.version === plugin.version
|
||||
return (
|
||||
<div key={v.version} data-testid={`version-row-${v.version}`}
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-xl hover:bg-surface-secondary transition-colors">
|
||||
<span className={`text-[13px] font-medium tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}>v{v.version}</span>
|
||||
{v.publishedAt && <span className="text-[11.5px] text-content-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
|
||||
{v.signed && <ShieldCheck size={13} className="text-success shrink-0" />}
|
||||
<span className="ml-auto" />
|
||||
{current ? (
|
||||
<span className="text-[11.5px] font-medium text-content-faint">{t('admin.plugins.installed')}</span>
|
||||
) : v.compatible ? (
|
||||
<button type="button" onClick={() => onPick(v.version)} disabled={busy === plugin.id}
|
||||
className="text-[11.5px] font-semibold px-2.5 py-1 rounded-full border border-edge bg-surface-card text-content-secondary hover:text-content hover:border-content-faint transition-colors disabled:opacity-50">
|
||||
{t('admin.plugins.versionSwitch', { version: v.version })}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[11.5px] text-content-faint">{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A signature check refused an install/update.
|
||||
*
|
||||
@@ -1576,16 +1926,16 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
|
||||
: 'admin.plugins.sig.invalidBody'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div 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="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 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 && (
|
||||
@@ -1599,13 +1949,13 @@ 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">
|
||||
<button onClick={onClose}
|
||||
<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')}
|
||||
</button>
|
||||
{offerRetrust && (
|
||||
<button onClick={() => onRetrust(version, newKey)} disabled={busy}
|
||||
<button type="button" onClick={() => onRetrust(version, newKey)} disabled={busy}
|
||||
className="text-xs font-semibold px-4 py-2 rounded-lg bg-warning text-white hover:opacity-90 transition-opacity disabled:opacity-50">
|
||||
{t('admin.plugins.sig.retrustConfirm')}
|
||||
</button>
|
||||
@@ -1640,16 +1990,16 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
|
||||
t: T; onApprove: () => void; onLater: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onLater}>
|
||||
<div 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="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 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. */}
|
||||
@@ -1678,9 +2028,9 @@ 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">
|
||||
<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 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 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1700,16 +2050,16 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
|
||||
...data.versionMismatch.map(d => ({ id: d.id, constraint: d.wanted, installed: d.installed })),
|
||||
]
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div 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="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 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">
|
||||
@@ -1720,7 +2070,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
|
||||
: t('admin.plugins.dep.requires', { version: r.constraint })}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => onDownload(r.id, r.constraint)} disabled={busy}
|
||||
<button type="button" onClick={() => onDownload(r.id, r.constraint)} disabled={busy}
|
||||
className="inline-flex items-center gap-1.5 text-[11.5px] font-semibold px-3 py-1.5 rounded-full bg-accent text-accent-text hover:bg-accent-hover transition-colors disabled:opacity-50 shrink-0">
|
||||
<Download size={13} /> {r.installed ? t('admin.plugins.dep.update') : t('admin.plugins.dep.download')}
|
||||
</button>
|
||||
@@ -1730,8 +2080,8 @@ 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">
|
||||
<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 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>
|
||||
</div>
|
||||
@@ -1757,7 +2107,7 @@ function SecurityInfo({ t }: { t: T }) {
|
||||
<ShieldCheck size={14} className="text-content-faint shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-content-muted">{t('admin.plugins.reviewedMeaning')}</p>
|
||||
</div>
|
||||
<button onClick={() => setOpen(o => !o)}
|
||||
<button type="button" onClick={() => setOpen(o => !o)}
|
||||
className="w-full px-4 sm:px-6 py-2.5 border-t border-edge-secondary flex items-center justify-between gap-2 text-xs font-medium text-content-secondary hover:text-content hover:bg-surface-tertiary transition-colors">
|
||||
<span className="flex items-center gap-2"><Lock size={13} className="shrink-0" /> <span className="text-left">{t('admin.plugins.security.title')}</span></span>
|
||||
<ChevronDown size={15} className={`shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
|
||||
@@ -191,7 +191,7 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={loadBackups}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
@@ -208,7 +208,7 @@ export default function BackupPanel() {
|
||||
className="hidden"
|
||||
onChange={handleUploadRestore}
|
||||
/>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
|
||||
@@ -222,7 +222,7 @@ export default function BackupPanel() {
|
||||
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating}
|
||||
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
|
||||
@@ -247,7 +247,7 @@ export default function BackupPanel() {
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" />
|
||||
<p className="text-sm">{t('backup.empty')}</p>
|
||||
<button onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
|
||||
<button type="button" onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
|
||||
{t('backup.createFirst')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -274,14 +274,14 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t('backup.download')}
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => handleRestore(backup.filename)}
|
||||
disabled={restoringFile === backup.filename}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60"
|
||||
@@ -292,7 +292,7 @@ export default function BackupPanel() {
|
||||
}
|
||||
{t('backup.restore')}
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => handleDelete(backup.filename)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||
>
|
||||
@@ -318,11 +318,11 @@ export default function BackupPanel() {
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Enable toggle */}
|
||||
<label className="flex items-center justify-between gap-4 cursor-pointer">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
||||
<div className="min-w-0 text-sm font-medium text-gray-900">
|
||||
{t('backup.auto.enable')}
|
||||
<p className="text-xs font-normal text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
@@ -339,7 +339,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{INTERVAL_OPTIONS.map(opt => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('interval', opt.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
@@ -360,7 +360,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.hour)}
|
||||
onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))}
|
||||
onChange={v => handleAutoSettingsChange('hour', Number.parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={HOURS.map(h => {
|
||||
let label: string
|
||||
@@ -386,7 +386,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAYS_OF_WEEK.map(opt => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
@@ -408,7 +408,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.day_of_month)}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', Number.parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||
/>
|
||||
@@ -421,7 +421,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{KEEP_OPTIONS.map(opt => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
@@ -440,7 +440,7 @@ export default function BackupPanel() {
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end pt-2 border-t border-gray-100">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleSaveAutoSettings}
|
||||
disabled={autoSettingsSaving || !autoSettingsDirty}
|
||||
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
|
||||
@@ -458,11 +458,15 @@ export default function BackupPanel() {
|
||||
{/* Restore Warning Modal */}
|
||||
{restoreConfirm && (
|
||||
<div
|
||||
// Dismiss-on-backdrop is a mouse shortcut for the Cancel button below;
|
||||
// the backdrop itself carries no semantics of its own.
|
||||
role="presentation"
|
||||
className="bg-[rgba(0,0,0,0.5)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||
@@ -497,14 +501,14 @@ export default function BackupPanel() {
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={executeRestore}
|
||||
className="bg-[#dc2626] text-white"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
|
||||
@@ -27,17 +27,17 @@ describe('CategoryManager', () => {
|
||||
|
||||
it('FE-COMP-CAT-002: shows Categories title', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Categories');
|
||||
expect(await screen.findByText('Categories')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-003: shows empty state when no categories', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('No categories yet');
|
||||
expect(await screen.findByText('No categories yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-004: shows New Category button', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('New Category');
|
||||
expect(await screen.findByText('New Category')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-005: clicking New Category shows form', async () => {
|
||||
@@ -129,7 +129,7 @@ describe('CategoryManager', () => {
|
||||
|
||||
it('FE-COMP-CAT-010: shows subtitle text', async () => {
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Manage categories for places');
|
||||
expect(await screen.findByText('Manage categories for places')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-011: category count is shown', async () => {
|
||||
|
||||
@@ -194,7 +194,7 @@ export default function CategoryManager() {
|
||||
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
|
||||
</div>
|
||||
<button onClick={handleStartCreate}
|
||||
<button type="button" onClick={handleStartCreate}
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('categories.new')}</span>
|
||||
@@ -235,11 +235,11 @@ export default function CategoryManager() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => handleStartEdit(cat)}
|
||||
<button type="button" onClick={() => handleStartEdit(cat)}
|
||||
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(cat.id)}
|
||||
<button type="button" onClick={() => handleDelete(cat.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-025
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-027
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -63,6 +63,11 @@ function hasResetLink(label: string): boolean {
|
||||
return !!el && within(el).queryByRole('button') !== null;
|
||||
}
|
||||
|
||||
/** The CARTO key input sits under its label inside the field's own wrapper. */
|
||||
function cartoInput(): HTMLInputElement {
|
||||
return within(screen.getByText('Shared CARTO key').closest('div') as HTMLElement).getByRole('textbox');
|
||||
}
|
||||
|
||||
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
|
||||
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
|
||||
await user.click(screen.getByRole('button', { name: trigger }));
|
||||
@@ -419,4 +424,32 @@ describe('DefaultUserSettingsTab', () => {
|
||||
expect(await screen.findByText(/Request failed with status code 503/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Reset to built-in default')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-026: the shared CARTO key is stored on blur and cleared by its reset link', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ carto_api_key: 'ck.old' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Shared CARTO key');
|
||||
|
||||
const input = cartoInput();
|
||||
expect(input).toHaveValue('ck.old');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ck.new');
|
||||
fireEvent.blur(input);
|
||||
await waitFor(() => expect(puts).toEqual([{ carto_api_key: 'ck.new' }]));
|
||||
|
||||
// Clicking the reset link also blurs the field again, so only the last PUT is checked
|
||||
await user.click(resetLink('Shared CARTO key'));
|
||||
await waitFor(() => expect(puts[puts.length - 1]).toEqual({ carto_api_key: null }));
|
||||
await waitFor(() => expect(cartoInput()).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-027: a managed instance hides the shared CARTO key field', async () => {
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
|
||||
stubDefaults({ carto_api_key: 'ck.hoster' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.queryByText('Shared CARTO key')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -23,6 +23,12 @@ import { useAuthStore } from '../../store/authStore'
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||
// The app default, and a vector style rather than a {z}/{x}/{y} template: no
|
||||
// key, no registration, no request limits.
|
||||
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron' },
|
||||
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright' },
|
||||
// CARTO watermarks keyless tiles since 26.08.2026 and issues keys by mail, so
|
||||
// these two need one; without it the map falls back to the default (#2054).
|
||||
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
@@ -36,6 +42,7 @@ type Defaults = {
|
||||
default_currency?: string
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -86,7 +93,7 @@ function OptionButton({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -111,6 +118,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -119,6 +127,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setDefaults(data)
|
||||
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setCartoKey(data.carto_api_key || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
@@ -140,6 +149,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'carto_api_key') setCartoKey('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -154,7 +164,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
||||
isSet(field) ? (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => reset(field)}
|
||||
className="text-xs ml-2 text-content-faint underline"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
@@ -185,7 +195,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
transport_mode: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
created_at: Date(),
|
||||
created_at: String(new Date()),
|
||||
}], [])
|
||||
|
||||
if (!loaded) {
|
||||
@@ -335,6 +345,26 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
|
||||
{/* The key comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.cartoKey')}
|
||||
<ResetButton field="carto_api_key" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
|
||||
onBlur={() => save({ carto_api_key: cartoKey })}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
@@ -348,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,
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('DevNotificationsPanel', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await screen.findByText('Sent: simple-me');
|
||||
expect(await screen.findByText('Sent: simple-me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-007: all buttons disabled while a send is in-flight', async () => {
|
||||
@@ -101,8 +101,8 @@ describe('DevNotificationsPanel', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
// Fire the click but do not await — handler never resolves so sending stays true
|
||||
void user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
// The request handler never resolves, so sending stays true after the click settles
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
@@ -120,7 +120,7 @@ describe('DevNotificationsPanel', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await screen.findByText('No channel configured');
|
||||
expect(await screen.findByText('No channel configured')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-008b: a failure without an error field falls back to the generic text', async () => {
|
||||
@@ -133,7 +133,7 @@ describe('DevNotificationsPanel', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await screen.findByText('Failed');
|
||||
expect(await screen.findByText('Failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
}: {
|
||||
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
|
||||
}) => (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={onClick}
|
||||
disabled={sending !== null}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full border-edge bg-surface-card"
|
||||
|
||||
@@ -231,7 +231,7 @@ describe('GitHubPanel', () => {
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.7.0');
|
||||
await user.click(screen.getByText('Show details'));
|
||||
await screen.findByText('This is a plain paragraph without any markdown syntax.');
|
||||
expect(await screen.findByText('This is a plain paragraph without any markdown syntax.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-014: markdown link with safe href renders as anchor', async () => {
|
||||
|
||||
@@ -461,7 +461,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-content-muted transition-colors"
|
||||
>
|
||||
@@ -484,7 +484,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="pt-2 text-center">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-surface-secondary px-4 py-2 text-xs font-medium text-content-muted transition-colors"
|
||||
|
||||
@@ -18,7 +18,7 @@ beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
/** Template rows carry [chevron, edit, delete]; category headers [add item, edit, delete]. */
|
||||
/** Template rows carry [chevron, name, edit, delete]; category headers [add item, edit, delete]. */
|
||||
function rowButtons(name: string, selector: string): HTMLElement[] {
|
||||
const row = screen.getByText(name).closest(selector) as HTMLElement;
|
||||
return within(row).getAllByRole('button');
|
||||
@@ -250,7 +250,7 @@ describe('PackingTemplateManager', () => {
|
||||
await user.click(screen.getByText('Add category'));
|
||||
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
|
||||
await user.type(catInput, 'Electronics{Enter}');
|
||||
await screen.findByText('Electronics');
|
||||
expect(await screen.findByText('Electronics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-012: adding an item to a category', async () => {
|
||||
@@ -280,7 +280,7 @@ describe('PackingTemplateManager', () => {
|
||||
await user.type(itemInput, 'Sandals');
|
||||
// Submit via Enter key (the input's onKeyDown handler triggers handleAddItem)
|
||||
await user.type(itemInput, '{Enter}');
|
||||
await screen.findByText('Sandals');
|
||||
expect(await screen.findByText('Sandals')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-013: renaming a category inline updates its name', async () => {
|
||||
@@ -312,7 +312,7 @@ describe('PackingTemplateManager', () => {
|
||||
const catInput = screen.getByDisplayValue('Clothing');
|
||||
await user.clear(catInput);
|
||||
await user.type(catInput, 'Shoes{Enter}');
|
||||
await screen.findByText('Shoes');
|
||||
expect(await screen.findByText('Shoes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-014: deleting a category removes it and its items', async () => {
|
||||
@@ -379,7 +379,7 @@ describe('PackingTemplateManager', () => {
|
||||
const input = screen.getByDisplayValue('T-shirt');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Tank Top{Enter}');
|
||||
await screen.findByText('Tank Top');
|
||||
expect(await screen.findByText('Tank Top')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-016: deleting an item removes it from the list', async () => {
|
||||
@@ -588,11 +588,11 @@ describe('PackingTemplateManager', () => {
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'Clothing');
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
await user.click(templateButtons('Beach Trip')[3]);
|
||||
expect(await screen.findByText('Failed to delete template')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clothing')).toBeInTheDocument();
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
await user.click(templateButtons('Beach Trip')[3]);
|
||||
await screen.findByText('Template deleted');
|
||||
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
|
||||
@@ -611,14 +611,14 @@ describe('PackingTemplateManager', () => {
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[1]);
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
await user.clear(screen.getByDisplayValue('Beach Trip'));
|
||||
await user.type(screen.getByRole('textbox'), '{Enter}');
|
||||
await waitFor(() => expect(screen.getByText('Beach Trip')).toBeInTheDocument());
|
||||
expect(puts).toBe(0);
|
||||
|
||||
// Blurring the field commits the pending name — here the request fails
|
||||
await user.click(templateButtons('Beach Trip')[1]);
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
const input = screen.getByDisplayValue('Beach Trip');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Winter');
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function PackingTemplateManager() {
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
<button type="button" onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
|
||||
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
|
||||
</button>
|
||||
@@ -171,8 +171,8 @@ export default function PackingTemplateManager() {
|
||||
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
|
||||
placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
|
||||
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
|
||||
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
|
||||
<button type="button" onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
|
||||
<button type="button" onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function PackingTemplateManager() {
|
||||
<div key={tmpl.id}>
|
||||
{/* Template row */}
|
||||
<div className="px-5 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors">
|
||||
<button onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
|
||||
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
</button>
|
||||
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||
@@ -197,14 +197,14 @@ export default function PackingTemplateManager() {
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
|
||||
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
|
||||
) : (
|
||||
<span onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</span>
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-left bg-transparent border-0 p-0 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</button>
|
||||
)}
|
||||
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
|
||||
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||
</span>
|
||||
<button onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||
<button type="button" onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||
className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}><Edit2 size={14} /></button>
|
||||
<button onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||
<button type="button" onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||
className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
|
||||
@@ -228,11 +228,11 @@ export default function PackingTemplateManager() {
|
||||
<span className="flex-1 text-xs font-bold text-slate-500 uppercase tracking-wider">{cat.name}</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400">{catItems.length}</span>
|
||||
<button onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||
<button type="button" onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Plus size={13} /></button>
|
||||
<button onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||||
<button type="button" onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Edit2 size={13} /></button>
|
||||
<button onClick={() => handleDeleteCategory(cat.id)}
|
||||
<button type="button" onClick={() => handleDeleteCategory(cat.id)}
|
||||
className={`${btnIcon} text-slate-400 hover:text-red-500`}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
|
||||
@@ -246,15 +246,15 @@ export default function PackingTemplateManager() {
|
||||
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
|
||||
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||
<button onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
|
||||
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
|
||||
<button type="button" onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
|
||||
<button type="button" onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
|
||||
<button onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||||
<button type="button" onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||||
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
|
||||
<button onClick={() => handleDeleteItem(item.id)}
|
||||
<button type="button" onClick={() => handleDeleteItem(item.id)}
|
||||
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all"><Trash2 size={12} /></button>
|
||||
</>
|
||||
)}
|
||||
@@ -268,9 +268,9 @@ export default function PackingTemplateManager() {
|
||||
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
|
||||
placeholder={t('admin.packingTemplates.itemName')}
|
||||
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||
<button onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||||
<button type="button" onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||||
className="p-1.5 rounded-lg bg-slate-900 text-white disabled:bg-slate-300 hover:bg-slate-700 transition-colors"><Plus size={13} /></button>
|
||||
<button onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||||
<button type="button" onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||||
className="p-1 text-slate-400 hover:text-slate-600"><X size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
@@ -287,11 +287,11 @@ export default function PackingTemplateManager() {
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
|
||||
placeholder={t('admin.packingTemplates.categoryName')}
|
||||
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
|
||||
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
|
||||
<button onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
|
||||
<button type="button" onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
|
||||
<button type="button" onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setAddingCategory(true)}
|
||||
<button type="button" onClick={() => setAddingCategory(true)}
|
||||
className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
|
||||
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
|
||||
</button>
|
||||
|
||||
@@ -269,6 +269,6 @@ describe('PermissionsPanel', () => {
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
await screen.findByText('Error');
|
||||
expect(await screen.findByText('Error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function PermissionsPanel(): React.ReactElement {
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
title={t('perm.resetDefaults')}
|
||||
@@ -114,7 +114,7 @@ export default function PermissionsPanel(): React.ReactElement {
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
|
||||
|
||||
@@ -605,7 +605,12 @@ describe('AdminStoragePanel', () => {
|
||||
await screen.findByText('Storage configuration saved');
|
||||
// files is default-sourced (uploads-local) in baseState() — stripping restores "no override".
|
||||
expect((putBody as StorageConfig).categories.files).toBeUndefined();
|
||||
expect(migrationBody).toEqual({ category: 'files', to: 'off-box' });
|
||||
// Waited for, not read straight after the toast. That toast comes from the
|
||||
// PUT, while the migration POST is fired by a later effect reacting to the
|
||||
// refreshed admin.state, so the two are not the same tick. Reading it
|
||||
// immediately only held while that effect happened to flush first, which
|
||||
// under load it does not.
|
||||
await waitFor(() => expect(migrationBody).toEqual({ category: 'files', to: 'off-box' }));
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -676,7 +681,8 @@ describe('AdminStoragePanel', () => {
|
||||
await screen.findByText('Storage configuration saved');
|
||||
// The POST carries the mirror's raw wire name, not 'backups-local' —
|
||||
// posting the bare primary would silently drop replication.
|
||||
expect(migrationBody).toEqual({ category: 'files', to: 'mirror' });
|
||||
// Waited for, for the same reason as FE-ADMIN-STOR-030 above.
|
||||
await waitFor(() => expect(migrationBody).toEqual({ category: 'files', to: 'mirror' }));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-037: a queued migration waits out a running backfill (the server 409s on either while the other runs)', async () => {
|
||||
|
||||
@@ -126,8 +126,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
useEffect(() => {
|
||||
if (migrationQueue.length === 0 || !admin.state) return
|
||||
if (migrationStartInFlight.current) return
|
||||
if (admin.state.migrations.some((m) => m.status === 'running')) return
|
||||
if (admin.state.backfills.some((b) => b.status === 'running')) return
|
||||
if (admin.storageBusy()) return
|
||||
const [next, ...rest] = migrationQueue
|
||||
migrationStartInFlight.current = true
|
||||
setMigrationQueue(rest)
|
||||
@@ -338,7 +337,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) })
|
||||
: t('storage.usage.never')}
|
||||
{' '}
|
||||
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={handleRefreshStats}>
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={handleRefreshStats}>
|
||||
{state.usage ? t('storage.usage.refresh') : t('storage.usage.compute')}
|
||||
</button>
|
||||
</p>
|
||||
@@ -364,7 +363,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
{t(`storage.source.${row.source}`)}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() =>
|
||||
@@ -376,12 +375,12 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
{row.source !== 'env' && (
|
||||
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
|
||||
{t('storage.actions.edit')}
|
||||
</button>
|
||||
)}
|
||||
{row.source === 'settings' && (
|
||||
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
|
||||
{t('storage.actions.remove')}
|
||||
</button>
|
||||
)}
|
||||
@@ -433,7 +432,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
failed: String(backfill.failed),
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleCancelBackfill(row)}
|
||||
@@ -464,7 +463,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
</p>
|
||||
)}
|
||||
{syncPrompt !== row.name && (
|
||||
<button
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
@@ -480,14 +479,14 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
<div className="mt-2 rounded-lg border p-2 border-edge-secondary">
|
||||
<p className="text-xs text-content">{t('storage.sync.prompt')}</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.now')}
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => setSyncPrompt(null)}
|
||||
@@ -512,10 +511,10 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
{t('storage.type.mirror')}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
|
||||
{t('storage.actions.remove')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -552,7 +551,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
total: String(m.total),
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void handleCancelMigration(m)}
|
||||
@@ -601,7 +600,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
initial: null,
|
||||
@@ -675,7 +674,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
</Section>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={save}
|
||||
disabled={!admin.dirty || admin.saving}
|
||||
style={{
|
||||
@@ -715,21 +714,21 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
</p>
|
||||
))}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void moveAndSave()}
|
||||
>
|
||||
{t('storage.migrate.move')}
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void routeOnlySave()}
|
||||
>
|
||||
{t('storage.migrate.routeOnly')}
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => setMigratePromptOpen(false)}
|
||||
@@ -746,7 +745,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
past; "save again" can never clear it, so the banner offers the
|
||||
one action that can — see useStorageAdmin.discardDraft. */}
|
||||
{admin.saveConflict && (
|
||||
<button
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void admin.discardDraft()}
|
||||
|
||||
@@ -236,7 +236,7 @@ export default function BackendForm({
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={apply}
|
||||
disabled={!canApply}
|
||||
style={{
|
||||
@@ -248,7 +248,7 @@ export default function BackendForm({
|
||||
>
|
||||
{t('storage.form.apply')}
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { StorageAdminState } from '@trek/shared';
|
||||
import { server } from '../../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { useStorageAdmin, type StorageAdmin } from './useStorageAdmin';
|
||||
|
||||
function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminState {
|
||||
return {
|
||||
backends: [
|
||||
{ name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files'] },
|
||||
],
|
||||
categories: { files: { backend: 'uploads-local', source: 'default' } } as StorageAdminState['categories'],
|
||||
health: { replicaFailures: [] },
|
||||
seedFilePresent: false,
|
||||
usage: null,
|
||||
backfills: [],
|
||||
migrations: [],
|
||||
version: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function runningState(): StorageAdminState {
|
||||
return baseState({
|
||||
migrations: [
|
||||
{ category: 'files', from: 'uploads-local', to: 'off-box', status: 'running', done: 1, total: 3, copied: 1, skipped: 0, failed: 0, startedAt: 1 },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes the hook to the test without a panel in the way — what the panels'
|
||||
* queue effect depends on is the hook's own timing, not any rendered output.
|
||||
*/
|
||||
function Probe({ onReady }: { onReady: (admin: StorageAdmin) => void }): React.ReactElement {
|
||||
const admin = useStorageAdmin('generic', 'conflict');
|
||||
// After every render, not during one: the test wants whatever the latest
|
||||
// render produced, and a render must stay free of side effects.
|
||||
useEffect(() => { onReady(admin); });
|
||||
return <div data-testid="ready">{admin.state ? 'loaded' : 'loading'}</div>;
|
||||
}
|
||||
|
||||
describe('useStorageAdmin', () => {
|
||||
async function mount(): Promise<() => StorageAdmin> {
|
||||
let latest: StorageAdmin | null = null;
|
||||
render(<Probe onReady={(a) => { latest = a; }} />);
|
||||
await screen.findByText('loaded');
|
||||
return () => latest!;
|
||||
}
|
||||
|
||||
it('FE-ADMIN-STOR-048: startMigration resolves only once storageBusy() already reports the started migration', async () => {
|
||||
let posted = false;
|
||||
server.use(
|
||||
http.get('/api/admin/storage', () => HttpResponse.json(posted ? runningState() : baseState())),
|
||||
http.post('/api/admin/storage/migrations', () => {
|
||||
posted = true;
|
||||
return HttpResponse.json({ started: true });
|
||||
}),
|
||||
);
|
||||
const admin = await mount();
|
||||
expect(admin().storageBusy()).toBe(false);
|
||||
|
||||
// The queue effect releases its single-flight lock the moment this
|
||||
// resolves, so the world it then decides on must already be post-POST.
|
||||
expect(await admin().startMigration('files', 'off-box')).toBeNull();
|
||||
expect(admin().storageBusy()).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-049: storageBusy() is current at response time, before the render that shows it', async () => {
|
||||
server.use(http.get('/api/admin/storage', () => HttpResponse.json(runningState())));
|
||||
const admin = await mount();
|
||||
const before = admin();
|
||||
|
||||
// No await on a render: refreshState resolving is the only synchronisation
|
||||
// point, and the ref must already carry the new world at that instant —
|
||||
// that is the whole point of it, since a deferred commit can run an effect
|
||||
// whose captured `state` is several responses behind.
|
||||
await before.refreshState();
|
||||
expect(before.storageBusy()).toBe(true);
|
||||
await waitFor(() => expect(admin().state!.migrations).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-050: a failed refresh leaves storageBusy() on the last world that was actually applied', async () => {
|
||||
server.use(http.get('/api/admin/storage', () => HttpResponse.json(runningState())));
|
||||
const admin = await mount();
|
||||
expect(admin().storageBusy()).toBe(true);
|
||||
server.use(http.get('/api/admin/storage', () => HttpResponse.error()));
|
||||
|
||||
// A transient GET failure must not read as "the server went idle" — that
|
||||
// is the one answer that would let a second migration start.
|
||||
await expect(admin().refreshState()).rejects.toThrow();
|
||||
expect(admin().storageBusy()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,20 @@ function isConflictError(err: unknown): boolean {
|
||||
|
||||
export interface StorageAdmin {
|
||||
state: StorageAdminState | null
|
||||
/**
|
||||
* Whether the server is already busy with a storage job: a migration or a
|
||||
* backfill, since its one-job-at-a-time rule spans both. Answered off the
|
||||
* last response rather than off `state`, so it is current the instant that
|
||||
* response lands rather than on the commit that renders it.
|
||||
*
|
||||
* That difference is the whole point. An effect re-run by a DEFERRED commit
|
||||
* still closes over the `state` of the render it belongs to, which under load
|
||||
* is several responses old by the time it runs, and the queue effect deciding
|
||||
* on that closure POSTed a second migration on top of a running one. Anything
|
||||
* deciding whether to fire a request asks this; anything rendering reads
|
||||
* `state`. Never called during render.
|
||||
*/
|
||||
storageBusy: () => boolean
|
||||
/** The settings-owned document (the PUT body), with local edits layered in. Carries the version the draft was built at. */
|
||||
draft: StorageConfigPut | null
|
||||
dirty: boolean
|
||||
@@ -85,6 +99,15 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
|
||||
savingRef.current = saving
|
||||
}, [saving])
|
||||
|
||||
// Written by every state application, at response time rather than at commit
|
||||
// time, and read back through storageBusy(). See the interface doc.
|
||||
const stateRef = useRef<StorageAdminState | null>(null)
|
||||
const storageBusy = useCallback((): boolean => {
|
||||
const live = stateRef.current
|
||||
if (!live) return false
|
||||
return live.migrations.some((m) => m.status === 'running') || live.backfills.some((b) => b.status === 'running')
|
||||
}, [])
|
||||
|
||||
// Bumped by every deliberate state application (initial load, save
|
||||
// success). A refreshState() call captures the seq before its GET and
|
||||
// drops the response if the seq moved on — i.e. a save applied its own
|
||||
@@ -93,6 +116,7 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
|
||||
|
||||
const applyState = useCallback((next: StorageAdminState) => {
|
||||
stateSeq.current += 1
|
||||
stateRef.current = next
|
||||
setState(next)
|
||||
setDraftState(settingsDocumentOf(next))
|
||||
setDirty(false)
|
||||
@@ -145,6 +169,7 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
|
||||
const seq = stateSeq.current
|
||||
const next = await adminApi.getStorage()
|
||||
if (stateSeq.current !== seq || savingRef.current) return
|
||||
stateRef.current = next
|
||||
setState(next)
|
||||
if (!dirtyRef.current) setDraftState(settingsDocumentOf(next))
|
||||
}, [])
|
||||
@@ -317,7 +342,14 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
|
||||
const refreshStats = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
const usage = await adminApi.refreshStorageStats()
|
||||
setState((prev) => (prev ? { ...prev, usage } : prev))
|
||||
// Off stateRef rather than a functional updater, so the ref and the
|
||||
// rendered state stay one world: every writer here sets both.
|
||||
const prev = stateRef.current
|
||||
if (prev) {
|
||||
const next = { ...prev, usage }
|
||||
stateRef.current = next
|
||||
setState(next)
|
||||
}
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
@@ -326,6 +358,7 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
|
||||
|
||||
return {
|
||||
state,
|
||||
storageBusy,
|
||||
draft,
|
||||
dirty,
|
||||
loading,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-W4BGT-001 to FE-W4BGT-020
|
||||
// FE-W4BGT-001 to FE-W4BGT-022
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { screen, act, waitFor } from '@testing-library/react'
|
||||
import { render, fireEvent } from '../../../tests/helpers/render'
|
||||
@@ -185,6 +185,37 @@ describe('BackgroundTasksWidget — rehydrate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BackgroundTasksWidget — poll backstop', () => {
|
||||
it('FE-W4BGT-021: a job the server drops mid-poll ends as an error instead of spinning on', async () => {
|
||||
vi.useFakeTimers()
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
|
||||
vi.mocked(reservationsApi.importJobStatus)
|
||||
.mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never)
|
||||
.mockRejectedValue({ response: { status: 404 } })
|
||||
|
||||
render(<BackgroundTasksWidget />)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
|
||||
|
||||
expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('error')
|
||||
// The file parsed fine — blaming it would send the user looking for a problem
|
||||
// that isn't there.
|
||||
expect(useBackgroundTasksStore.getState().tasks[0].error).toBe('Unknown error')
|
||||
})
|
||||
|
||||
it('FE-W4BGT-022: a transient poll failure leaves the job running', async () => {
|
||||
vi.useFakeTimers()
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
|
||||
vi.mocked(reservationsApi.importJobStatus)
|
||||
.mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never)
|
||||
.mockRejectedValue({ response: { status: 500 } })
|
||||
|
||||
render(<BackgroundTasksWidget />)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
|
||||
|
||||
expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('running')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BackgroundTasksWidget — AI retry', () => {
|
||||
const withFiles = () => task({
|
||||
items: [], sourceFiles: [new File(['%PDF'], 'voucher.pdf', { type: 'application/pdf' })],
|
||||
|
||||
@@ -111,11 +111,16 @@ export default function BackgroundTasksWidget() {
|
||||
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
|
||||
else setProgress(task.id, task.tripId, s.done, s.total)
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch((err: { response?: { status?: number } }) => {
|
||||
// The server 404s a job once it has expired (or after a restart). Nothing is
|
||||
// ever coming, so end the card instead of spinning and re-polling forever.
|
||||
// Not the parse-failure message — the file was fine, the job is just gone.
|
||||
if (err?.response?.status === 404) setError(task.id, task.tripId, t('common.unknownError'))
|
||||
})
|
||||
}
|
||||
}, 5000)
|
||||
return () => clearInterval(iv)
|
||||
}, [tasks, setProgress, setDone, setError])
|
||||
}, [tasks, setProgress, setDone, setError, t])
|
||||
|
||||
if (tasks.length === 0) return null
|
||||
|
||||
@@ -158,7 +163,7 @@ export default function BackgroundTasksWidget() {
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
|
||||
) : task.items.length > 0 ? (
|
||||
<div>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => review(task)}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
@@ -187,7 +192,7 @@ export default function BackgroundTasksWidget() {
|
||||
)}
|
||||
</div>
|
||||
{aiParsing && task.mode !== 'force-ai' && task.sourceFiles && task.sourceFiles.length > 0 && (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => retryWithAi(task)}
|
||||
disabled={retrying === task.id}
|
||||
className="bg-surface-tertiary text-content"
|
||||
@@ -206,7 +211,7 @@ export default function BackgroundTasksWidget() {
|
||||
</div>
|
||||
|
||||
{task.status !== 'running' && (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => dismiss(task.id)}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
|
||||
|
||||
@@ -48,12 +48,12 @@ export function hexLighten(hex: string, amount: number): string {
|
||||
const m = hex.replace('#', '').match(/.{2}/g)
|
||||
if (!m || m.length !== 3) return hex
|
||||
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
|
||||
const [r, g, b] = m.map(x => parseInt(x, 16))
|
||||
const [r, g, b] = m.map(x => Number.parseInt(x, 16))
|
||||
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
export const fmtNum = (v: number | null | undefined, locale: string, cur: string) => {
|
||||
if (v == null || isNaN(v)) return '-'
|
||||
if (v == null || Number.isNaN(v)) return '-'
|
||||
const d = currencyDecimals(cur)
|
||||
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
|
||||
}
|
||||
@@ -75,7 +75,8 @@ export function splitColorFor(userId: number, order: number) {
|
||||
}
|
||||
|
||||
export function colorForUserId(userId: number) {
|
||||
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
|
||||
const id = Number.isFinite(userId) ? Math.trunc(userId) : 0
|
||||
return SPLIT_COLORS[(id - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('No budget created yet');
|
||||
expect(await screen.findByText('No budget created yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-002: shows empty state text body', async () => {
|
||||
@@ -40,7 +40,7 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText(/Create categories and entries/i);
|
||||
expect(await screen.findByText(/Create categories and entries/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-003: shows category input in empty state when user can edit', async () => {
|
||||
@@ -48,7 +48,7 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByPlaceholderText('Enter category name...');
|
||||
expect(await screen.findByPlaceholderText('Enter category name...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-004: renders budget items from store after load', async () => {
|
||||
@@ -57,7 +57,7 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Hotel Paris');
|
||||
expect(await screen.findByText('Hotel Paris')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-005: renders category section header', async () => {
|
||||
@@ -87,7 +87,7 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Budget');
|
||||
expect(await screen.findByText('Budget')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-008: shows CSV export button', async () => {
|
||||
@@ -96,7 +96,7 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('CSV');
|
||||
expect(await screen.findByText('CSV')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-009: add item row visible in table', async () => {
|
||||
@@ -105,7 +105,7 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByPlaceholderText('New Entry');
|
||||
expect(await screen.findByPlaceholderText('New Entry')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-010: adding new item via form calls POST and shows item', async () => {
|
||||
@@ -124,7 +124,7 @@ describe('BudgetPanel', () => {
|
||||
await user.type(nameInput, 'Restaurant Dinner');
|
||||
const addBtn = screen.getByTitle('Add Reservation');
|
||||
await user.click(addBtn);
|
||||
await screen.findByText('Restaurant Dinner');
|
||||
expect(await screen.findByText('Restaurant Dinner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-011: delete button present for items when user can edit', async () => {
|
||||
@@ -161,7 +161,7 @@ describe('BudgetPanel', () => {
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Hotel A');
|
||||
await screen.findByText('Hotel B');
|
||||
expect(await screen.findByText('Hotel B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-014: items from different categories render separate sections', async () => {
|
||||
@@ -183,7 +183,7 @@ describe('BudgetPanel', () => {
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
// Component renders even in empty state
|
||||
await screen.findByText('No budget created yet');
|
||||
expect(await screen.findByText('No budget created yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-016: trip currency EUR is shown in header for item rows', async () => {
|
||||
@@ -193,7 +193,7 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Misc');
|
||||
expect(await screen.findByText('Misc')).toBeInTheDocument();
|
||||
// Row exists - EUR formatting would appear in values
|
||||
});
|
||||
|
||||
@@ -233,7 +233,7 @@ describe('BudgetPanel', () => {
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
const nameInput = await screen.findByPlaceholderText('New Entry');
|
||||
await user.type(nameInput, 'Pizza{Enter}');
|
||||
await screen.findByText('Pizza');
|
||||
expect(await screen.findByText('Pizza')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-020: component renders without crashing with empty tripMembers', async () => {
|
||||
@@ -241,7 +241,7 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} tripMembers={[]} />);
|
||||
await screen.findByText('No budget created yet');
|
||||
expect(await screen.findByText('No budget created yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-021: inline edit name cell — clicking a name cell makes it editable', async () => {
|
||||
@@ -342,7 +342,7 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByPlaceholderText('Enter category name...');
|
||||
expect(await screen.findByPlaceholderText('Enter category name...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-028: creating a new category via input calls POST and adds a section', async () => {
|
||||
@@ -356,7 +356,7 @@ describe('BudgetPanel', () => {
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
const input = await screen.findByPlaceholderText('Enter category name...');
|
||||
await user.type(input, 'Souvenirs{Enter}');
|
||||
await screen.findByText('Souvenirs');
|
||||
expect(await screen.findByText('Souvenirs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-029: settlement section renders flows with usernames', async () => {
|
||||
@@ -371,7 +371,7 @@ describe('BudgetPanel', () => {
|
||||
{ user_id: 2, username: 'bob', balance: 10, avatar_url: null },
|
||||
],
|
||||
flows: [
|
||||
{ from: { username: 'alice', avatar_url: null }, to: { username: 'bob', avatar_url: null }, amount: 10 },
|
||||
{ from: { user_id: 1, username: 'alice', avatar_url: null }, to: { user_id: 2, username: 'bob', avatar_url: null }, amount: 10 },
|
||||
],
|
||||
})
|
||||
)
|
||||
@@ -387,7 +387,7 @@ describe('BudgetPanel', () => {
|
||||
await user.click(settlementBtn);
|
||||
// alice and bob should appear in balances section
|
||||
await screen.findByText('alice');
|
||||
await screen.findByText('bob');
|
||||
expect(await screen.findByText('bob')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-030: per-person summary renders usernames', async () => {
|
||||
@@ -408,7 +408,7 @@ describe('BudgetPanel', () => {
|
||||
];
|
||||
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
|
||||
await screen.findByText('Shared Dinner');
|
||||
await screen.findByText('testuser');
|
||||
expect(await screen.findByText('testuser')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-032: grand total row shows sum across all categories', async () => {
|
||||
@@ -451,7 +451,7 @@ describe('BudgetPanel', () => {
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Train');
|
||||
// expense_date is rendered as plain text in read-only mode
|
||||
await screen.findByText('2025-06-15');
|
||||
expect(await screen.findByText('2025-06-15')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-035: settlement section with avatar renders user avatar image', async () => {
|
||||
@@ -465,7 +465,7 @@ describe('BudgetPanel', () => {
|
||||
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: 30 },
|
||||
],
|
||||
flows: [{ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { username: 'bob', avatar_url: null }, amount: 30 }]
|
||||
flows: [{ from: { user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { user_id: 2, username: 'bob', avatar_url: null }, amount: 30 }]
|
||||
})
|
||||
),
|
||||
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })),
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||
placeholder={t('budget.emptyPlaceholder')}
|
||||
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
<button type="button" onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
@@ -87,7 +87,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
placeholder={t('budget.categoryName')}
|
||||
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
<button type="button" onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
title={t('budget.addCategory')}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||
@@ -101,7 +101,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
|
||||
<button type="button" onClick={handleExportCsv} title={t('budget.exportCsv')}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim()) return
|
||||
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
|
||||
onAdd({ name: name.trim(), total_price: Number.parseFloat(String(price).replace(',', '.')) || 0, persons: Number.parseInt(persons) || null, days: Number.parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
|
||||
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
|
||||
setTimeout(() => nameRef.current?.focus(), 50)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
|
||||
<button type="button" onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
|
||||
style={{ background: name.trim() ? 'var(--text-primary)' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: 'var(--bg-primary)',
|
||||
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<Plus size={14} />
|
||||
|
||||
@@ -586,7 +586,10 @@ describe('BudgetCategoryTable — member chips', () => {
|
||||
it('FE-W4BCT-052: picking a member from either chip dropdown sets the item members', () => {
|
||||
const { setBudgetItemMembers, container } = withMembers()
|
||||
// The mobile stack under the name and the persons column both carry an editor.
|
||||
const editors = container.querySelectorAll('td button')
|
||||
// Assigned chips are buttons of their own, so the editors are the two that
|
||||
// carry the picker icon.
|
||||
const editors = Array.from(container.querySelectorAll('td button'))
|
||||
.filter(b => b.querySelector('.lucide-pencil, .lucide-users'))
|
||||
|
||||
fireEvent.click(editors[0])
|
||||
fireEvent.click(screen.getByText('bob'))
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
|
||||
<>
|
||||
<span style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{cat}</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
<button type="button" onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||
<Pencil size={10} />
|
||||
@@ -123,7 +123,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||
<button type="button" onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||
<Trash2 size={13} />
|
||||
@@ -225,11 +225,11 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
) : (
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? Number.parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? Number.parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
|
||||
@@ -246,7 +246,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
|
||||
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
|
||||
<button type="button" onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
|
||||
<Trash2 size={14} />
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
|
||||
const save = () => {
|
||||
setEditing(false)
|
||||
let v: string | number | null = editValue
|
||||
if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
|
||||
if (type === 'number') { const p = Number.parseFloat(String(editValue).replace(',', '.')); v = Number.isNaN(p) ? null : p }
|
||||
if (v !== value) onSave(v)
|
||||
}
|
||||
|
||||
@@ -45,14 +45,27 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
|
||||
? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
: (value || '')
|
||||
|
||||
// Editable cells are real buttons so they can be tabbed to and pressed. A
|
||||
// read-only cell renders no control at all rather than a disabled one: it is
|
||||
// plain text, and a disabled button would be announced as an unavailable
|
||||
// control that never becomes available. Same split the member chips use.
|
||||
const cellStyle: React.CSSProperties = {
|
||||
padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
|
||||
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
width: '100%', textAlign: 'left', ...style,
|
||||
}
|
||||
const content = display || placeholder || '-'
|
||||
|
||||
if (readOnly) return <div style={{ ...cellStyle, cursor: 'default' }}>{content}</div>
|
||||
|
||||
return (
|
||||
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
|
||||
style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
|
||||
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', ...style }}
|
||||
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
|
||||
{display || placeholder || '-'}
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
|
||||
style={{ ...cellStyle, cursor: 'pointer' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ function setup(props: Partial<Parameters<typeof BudgetMemberChips>[0]> = {}) {
|
||||
return { onSetMembers, onTogglePaid, ...utils }
|
||||
}
|
||||
|
||||
/**
|
||||
* The member picker trigger. A chip that can be clicked is a real button too,
|
||||
* so it is the icon that tells the trigger apart from the chips before it.
|
||||
*/
|
||||
function picker(): HTMLElement {
|
||||
const trigger = screen.getAllByRole('button').find(b => b.querySelector('.lucide-pencil, .lucide-users'))
|
||||
if (!trigger) throw new Error('no picker trigger rendered')
|
||||
return trigger
|
||||
}
|
||||
|
||||
describe('ChipWithTooltip', () => {
|
||||
it('FE-W4BMC-001: falls back to the uppercased initial', () => {
|
||||
const { container } = render(<ChipWithTooltip label="ada" avatarUrl={null} />)
|
||||
@@ -76,7 +86,7 @@ describe('BudgetMemberChips', () => {
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
expect(screen.getByText('B')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
expect(picker()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4BMC-007: uses the people icon while nobody is assigned and the pencil afterwards', () => {
|
||||
@@ -114,9 +124,9 @@ describe('BudgetMemberChips', () => {
|
||||
|
||||
it('FE-W4BMC-011: the picker lists every trip member and marks the assigned ones', () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(picker())
|
||||
|
||||
const rows = screen.getAllByRole('button').slice(1)
|
||||
const rows = screen.getAllByRole('button').slice(2)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toHaveTextContent('ada')
|
||||
expect(rows[0].querySelector('.lucide-check')).not.toBeNull()
|
||||
@@ -127,52 +137,52 @@ describe('BudgetMemberChips', () => {
|
||||
|
||||
it('FE-W4BMC-012: picking an unassigned member adds them', () => {
|
||||
const { onSetMembers } = setup()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(picker())
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[2])
|
||||
fireEvent.click(screen.getAllByRole('button')[3])
|
||||
|
||||
expect(onSetMembers).toHaveBeenCalledWith([1, 2])
|
||||
})
|
||||
|
||||
it('FE-W4BMC-013: picking an assigned member removes them', () => {
|
||||
const { onSetMembers } = setup()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(picker())
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[1])
|
||||
fireEvent.click(screen.getAllByRole('button')[2])
|
||||
|
||||
expect(onSetMembers).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('FE-W4BMC-014: a mousedown outside closes the picker, inside keeps it', () => {
|
||||
setup()
|
||||
const trigger = screen.getByRole('button')
|
||||
const trigger = picker()
|
||||
fireEvent.click(trigger)
|
||||
|
||||
fireEvent.mouseDown(screen.getAllByRole('button')[1])
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
fireEvent.mouseDown(screen.getAllByRole('button')[2])
|
||||
expect(screen.getAllByRole('button')).toHaveLength(4)
|
||||
|
||||
fireEvent.mouseDown(trigger)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(4)
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-W4BMC-015: the trigger toggles the picker closed again', () => {
|
||||
setup()
|
||||
const trigger = screen.getByRole('button')
|
||||
const trigger = picker()
|
||||
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(4)
|
||||
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-W4BMC-016: the non-compact variant renders larger chips', () => {
|
||||
setup({ compact: false })
|
||||
|
||||
expect(screen.getByText('A')).toHaveStyle({ width: '30px' })
|
||||
expect((screen.getByRole('button') as HTMLElement).style.width).toBe('28px')
|
||||
expect(picker().style.width).toBe('28px')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ interface ChipWithTooltipProps {
|
||||
export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const ref = useRef<HTMLElement | null>(null)
|
||||
|
||||
const onEnter = () => {
|
||||
if (ref.current) {
|
||||
@@ -35,22 +35,34 @@ export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }:
|
||||
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
|
||||
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
|
||||
|
||||
// Identical for both shells — a clickable chip is a real button so it can be
|
||||
// tabbed to and pressed, a decorative one stays a plain div.
|
||||
const chipStyle = {
|
||||
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
|
||||
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
padding: 0, fontFamily: 'inherit',
|
||||
}
|
||||
const inner = avatarUrl
|
||||
? <img src={avatarUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: label?.[0]?.toUpperCase()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
|
||||
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}>
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: label?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
{onClick ? (
|
||||
<button type="button" ref={el => { ref.current = el }} aria-label={label}
|
||||
onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||
onClick={onClick} style={chipStyle}>
|
||||
{inner}
|
||||
</button>
|
||||
) : (
|
||||
<div ref={el => { ref.current = el }} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||
style={chipStyle}>
|
||||
{inner}
|
||||
</div>
|
||||
)}
|
||||
{hover && createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
@@ -131,7 +143,7 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
|
||||
/>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
<button type="button" ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -149,7 +161,7 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
|
||||
{tripMembers.map(tm => {
|
||||
const isActive = memberIds.includes(tm.id)
|
||||
return (
|
||||
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
|
||||
<button type="button" key={tm.id} onClick={() => toggleMember(tm.id)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
|
||||
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-primary)', textAlign: 'left',
|
||||
@@ -163,7 +175,7 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
|
||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{tm.avatar_url
|
||||
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
? <img src={tm.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: tm.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function RingAvatar({ userId, username, avatarUrl, size = 34, inn
|
||||
overflow: 'hidden',
|
||||
fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor,
|
||||
}}>
|
||||
{avatarUrl ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
|
||||
{avatarUrl ? <img src={avatarUrl} alt={username || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
{/* Settlement dropdown inside the total card */}
|
||||
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
|
||||
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||
<button onClick={() => setSettlementOpen(v => !v)} style={{
|
||||
<button type="button" onClick={() => setSettlementOpen(v => !v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
|
||||
color: theme.sub, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, letterSpacing: 0.5,
|
||||
@@ -83,7 +83,12 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
|
||||
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
{t('budget.settlement')}
|
||||
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
|
||||
{/* Decoration inside the settlement toggle: the hint hangs off
|
||||
hover, and the click only keeps the toggle from firing. A
|
||||
role of its own would give the toggle a second name and a
|
||||
nested control, so it declares itself presentational. */}
|
||||
<span style={{ display: 'flex', cursor: 'help' }}
|
||||
role="presentation"
|
||||
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
|
||||
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
|
||||
@@ -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)
|
||||
@@ -22,7 +36,7 @@ export function splitCents(amount: number, n: number): number[] {
|
||||
|
||||
/** Sum the amounts of the selected payers. */
|
||||
export function payerSum(amounts: Record<number, string>, ids: Set<number>): number {
|
||||
return [...ids].reduce((a, id) => a + (parseFloat(amounts[id]) || 0), 0)
|
||||
return [...ids].reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0)
|
||||
}
|
||||
|
||||
/** True when the payer amounts add up to the expense total, to the cent. */
|
||||
@@ -45,7 +59,7 @@ export function rebalancePayers(
|
||||
if (free.length === 0) return amounts
|
||||
const pinnedSum = all
|
||||
.filter(id => pinned.has(id))
|
||||
.reduce((a, id) => a + (parseFloat(amounts[id]) || 0), 0)
|
||||
.reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0)
|
||||
const shares = splitCents(total - pinnedSum, free.length)
|
||||
const next = { ...amounts }
|
||||
free.forEach((id, i) => { next[id] = shares[i] ? shares[i].toFixed(2) : '' })
|
||||
@@ -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)
|
||||
@@ -153,7 +174,7 @@ export function calculateTicketShares(items: TicketItem[]): { shares: Record<num
|
||||
const everyone = [...new Set(items.flatMap(i => [...i.participants]))].sort((a, b) => a - b)
|
||||
|
||||
for (const item of items) {
|
||||
const priceNum = parseFloat(item.price) || 0
|
||||
const priceNum = Number.parseFloat(item.price) || 0
|
||||
const priceCents = Math.round(priceNum * 100)
|
||||
totalCents += priceCents
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { usePermissionsStore } from '../../store/permissionsStore'
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
|
||||
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import CostsPanel, { ExpenseModal } from './CostsPanel'
|
||||
import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers'
|
||||
@@ -518,8 +518,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())
|
||||
@@ -594,7 +595,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
|
||||
expect(screen.getByDisplayValue('100,00')).toBeDisabled()
|
||||
|
||||
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
|
||||
expect(screen.getByText('Individual shares')).toBeInTheDocument()
|
||||
expect(screen.getByText(/75\.00/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/25\.00/)).toBeInTheDocument()
|
||||
|
||||
@@ -705,8 +706,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()
|
||||
})
|
||||
})
|
||||
@@ -916,6 +918,50 @@ describe('CostsPanel — settle up', () => {
|
||||
delete window.__addToast
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-059: a settle-up that fails midway still reloads the transfers that went through', async () => {
|
||||
const addToast = vi.fn()
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast
|
||||
let posted = 0
|
||||
let reads = 0
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => {
|
||||
reads += 1
|
||||
return HttpResponse.json({ balances: [], flows: posted > 0 ? [flows[1]] : flows, settlements: [] })
|
||||
}),
|
||||
http.post('/api/trips/1/budget/settlements', () => {
|
||||
posted += 1
|
||||
return posted === 1 ? HttpResponse.json({ settlement: { id: 1 } }) : HttpResponse.json({ error: 'no' }, { status: 500 })
|
||||
}),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
const settleAll = await screen.findByRole('button', { name: 'Settle up' })
|
||||
await waitFor(() => expect(settleAll).not.toBeDisabled())
|
||||
const readsBefore = reads
|
||||
fireEvent.click(settleAll)
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
||||
// The failed second transfer must not swallow the refresh: bob's 45 € was
|
||||
// recorded, and leaving it listed invites a second, doubled settle-up.
|
||||
await waitFor(() => expect(reads).toBeGreaterThan(readsBefore))
|
||||
expect(posted).toBe(2)
|
||||
delete window.__addToast
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-060: a settlement read that fails says so instead of claiming everyone is square', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ error: 'no' }, { status: 500 })),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Balances')
|
||||
// Settle up and Balances both hang off the one failed request.
|
||||
await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(2))
|
||||
expect(screen.queryByText("Everyone's square")).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-013: the "you owe" card lists who I still have to pay', async () => {
|
||||
mount([], { flows: [{ from: { user_id: 1, username: 'alice' }, to: { user_id: 2, username: 'bob' }, amount: 20 }] })
|
||||
|
||||
@@ -1256,8 +1302,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 }))
|
||||
@@ -1386,7 +1433,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())
|
||||
@@ -1394,6 +1442,38 @@ describe('CostsPanel — expense modal', () => {
|
||||
expect(onSaved).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The mobile sheet already dates a new expense by the traveller's own clock;
|
||||
// the desktop modal filed it under the UTC day, so the same expense entered
|
||||
// late in Tokyo or early in Los Angeles landed on a different day per surface.
|
||||
it('FE-W5COSTS-065: a new expense is dated by the local calendar day, not the UTC one', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
// A wall-clock time on whichever side of midnight puts the runner's UTC date
|
||||
// on a different day. On UTC itself the two spellings agree and there is
|
||||
// nothing here to catch.
|
||||
const behindUtc = new Date(2026, 7, 12).getTimezoneOffset() > 0
|
||||
vi.setSystemTime(new Date(2026, 7, 12, behindUtc ? 23 : 1, 30, 0))
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: dinner() })
|
||||
}))
|
||||
render(
|
||||
<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
|
||||
prefill={{ name: 'Ramen', category: 'food', amount: 18 }}
|
||||
onClose={() => {}} onSaved={vi.fn()} />
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Add expense' }))
|
||||
|
||||
try {
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.expense_date).toBe('2026-08-12')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-038b: a prefill from a place links the expense to that place (#1298)', async () => {
|
||||
const user = userEvent.setup()
|
||||
let posted: Record<string, unknown> | null = null
|
||||
@@ -1447,6 +1527,26 @@ describe('CostsPanel — remaining paths', () => {
|
||||
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-061: the CSV export neutralises names a spreadsheet would run as a formula', async () => {
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR', title: 'Rome' }) })
|
||||
let exported: Blob | null = null
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(b => { exported = b as Blob; return 'blob:mock' })
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
mount([
|
||||
expense({ id: 204, name: '=HYPERLINK("http://evil","click")', category: 'food', total_price: 12, expense_date: '2025-06-15', note: '@SUM(A1:A9)' }),
|
||||
expense({ id: 205, name: '-5 refund', category: 'misc', total_price: 5, expense_date: '2025-06-16', note: null }),
|
||||
])
|
||||
|
||||
await screen.findByText('-5 refund')
|
||||
fireEvent.click(screen.getByTitle('Export CSV'))
|
||||
|
||||
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
|
||||
expect(lines[1]).toBe('06/15/2025;"\'=HYPERLINK(""http://evil"",""click"")";Food & drink;12.00;EUR;12.00;\'@SUM(A1:A9)')
|
||||
expect(lines[2]).toBe('06/16/2025;\'-5 refund;Other;5.00;EUR;5.00;')
|
||||
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-043: deleting an expense drops it from the ledger', async () => {
|
||||
let deleted = false
|
||||
server.use(http.delete('/api/trips/1/budget/101', () => { deleted = true; return HttpResponse.json({ success: true }) }))
|
||||
@@ -1544,7 +1644,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)
|
||||
})
|
||||
|
||||
@@ -1615,7 +1716,7 @@ describe('CostsPanel — remaining paths', () => {
|
||||
await user.click(bob)
|
||||
await user.click(bob)
|
||||
// 10.99 across two people leaves the odd cent with the lower user id.
|
||||
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
|
||||
expect(screen.getByText('Individual shares')).toBeInTheDocument()
|
||||
expect(screen.getByText('€5.50')).toBeInTheDocument()
|
||||
expect(screen.getByText('€5.49')).toBeInTheDocument()
|
||||
})
|
||||
@@ -1741,6 +1842,21 @@ describe('CostsPanel — mobile extras', () => {
|
||||
expect(await screen.findByPlaceholderText('0.00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-062: the mobile search box keeps the caret across keystrokes', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([dinner(), taxi()])
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
const box = screen.getByPlaceholderText('Search expenses…')
|
||||
await user.type(box, 'din')
|
||||
|
||||
// The whole word has to land: if the body is remounted per keystroke the input
|
||||
// loses focus and everything after the first character goes nowhere.
|
||||
expect((box as HTMLInputElement).value).toBe('din')
|
||||
expect(document.activeElement).toBe(box)
|
||||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-054: an unknown currency degrades to a plain amount on mobile too', async () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: '' } })
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'XX' }) })
|
||||
@@ -1751,6 +1867,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({})
|
||||
@@ -1774,3 +2057,58 @@ describe('CostsPanel — split maths', () => {
|
||||
expect(calculateTicketShares(items)).toEqual({ shares: { 1: 7.5, 2: 7.5 }, total: 15 })
|
||||
})
|
||||
})
|
||||
|
||||
// The reworked expense modal shipped with English written straight into the JSX,
|
||||
// so the receipt panel and the split summary stayed English on a German phone
|
||||
// while everything around them translated.
|
||||
describe('CostsPanel — expense modal in another language', () => {
|
||||
beforeEach(() => {
|
||||
seedAlice()
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'de', default_currency: 'EUR' }) })
|
||||
clearExchangeRateCache()
|
||||
})
|
||||
afterEach(clearExchangeRateCache)
|
||||
|
||||
const openModal = () => render(
|
||||
<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
|
||||
onClose={() => {}} onSaved={vi.fn()} />
|
||||
)
|
||||
|
||||
it('FE-W5COSTS-063: the receipt panel is translated', async () => {
|
||||
const user = userEvent.setup()
|
||||
openModal()
|
||||
|
||||
// The locale bundle is fetched, so the first paint is still English.
|
||||
await user.click(await screen.findByRole('button', { name: 'Beleg' }))
|
||||
await user.click(screen.getByRole('button', { name: /Artikel hinzufügen/ }))
|
||||
|
||||
expect(screen.getByPlaceholderText('Artikelname')).toBeInTheDocument()
|
||||
expect(screen.getByText('Aufteilen auf:')).toBeInTheDocument()
|
||||
expect(screen.getByText('Anteil pro Person')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Individual shares')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-064: the split summary and the excluded marker are translated', async () => {
|
||||
const user = userEvent.setup()
|
||||
openModal()
|
||||
|
||||
await user.type(await screen.findByPlaceholderText('0,00'), '90')
|
||||
await user.click(screen.getByRole('button', { name: 'Individuell' }))
|
||||
|
||||
// Nothing entered yet, so the whole 90 is still unaccounted for.
|
||||
expect(screen.getByText('Summe der Anteile: €0.00 von €90.00 (es fehlen €90.00)')).toBeInTheDocument()
|
||||
|
||||
const shares = screen.getAllByPlaceholderText('45,00')
|
||||
await user.type(shares[0], '90')
|
||||
expect(screen.getByText('Aufteilung passt zur Summe')).toBeInTheDocument()
|
||||
|
||||
await user.type(shares[1], '10')
|
||||
expect(screen.getByText('Summe der Anteile: €100.00 von €90.00 (€10.00 zu viel)')).toBeInTheDocument()
|
||||
|
||||
// Back to the equal split: a member taken out of it is marked, not dropped.
|
||||
await user.click(screen.getByRole('button', { name: 'Gleichmäßig' }))
|
||||
await user.click(screen.getByRole('button', { name: /bob/i }))
|
||||
expect(screen.getByText('Nicht dabei')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Excluded')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { Fragment, useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
@@ -10,13 +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'
|
||||
@@ -74,6 +75,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const { convert } = useExchangeRates(base)
|
||||
const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency])
|
||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||
// A failed settlement read leaves `settlement` null, which the empty views would
|
||||
// otherwise present as "everyone is square", a balance claim we cannot make.
|
||||
const [settlementError, setSettlementError] = useState(false)
|
||||
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [catFilter, setCatFilter] = useState('') // '' = all categories
|
||||
@@ -99,7 +103,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale])
|
||||
|
||||
const loadSettlement = useCallback(() => {
|
||||
budgetApi.settlement(tripId, base).then(setSettlement).catch(() => {})
|
||||
budgetApi.settlement(tripId, base)
|
||||
.then(s => { setSettlement(s); setSettlementError(false) })
|
||||
.catch(() => setSettlementError(true))
|
||||
}, [tripId, base])
|
||||
|
||||
useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId])
|
||||
@@ -128,8 +134,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
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
|
||||
// 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 totals = useMemo(() => {
|
||||
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
|
||||
@@ -219,8 +226,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
if (!flows.length) return
|
||||
try {
|
||||
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base })
|
||||
loadSettlement()
|
||||
} catch { toast.error(t('common.unknownError')) }
|
||||
// Refresh even when one transfer failed: the ones created before it are real,
|
||||
// and leaving them in the flow list invites a second, doubled settle-up.
|
||||
finally { loadSettlement() }
|
||||
}
|
||||
|
||||
const dateMeta = useMemo(() => {
|
||||
@@ -241,7 +250,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
// Costs rework (#1500). One row per expense, oldest first.
|
||||
const handleExportCsv = () => {
|
||||
const sep = ';'
|
||||
const esc = (v: unknown) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
|
||||
// A cell starting with =, +, -, @, TAB or CR is evaluated as a formula by Excel
|
||||
// and Sheets, and the name/note columns are free text any trip member can write.
|
||||
const esc = (v: unknown) => {
|
||||
let s = String(v ?? '')
|
||||
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s
|
||||
return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s
|
||||
}
|
||||
const fmtDate = (iso: string) => { if (!iso) return ''; try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } catch { return iso } }
|
||||
|
||||
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', 'Amount (' + base + ')', 'Note']
|
||||
@@ -310,7 +325,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
|
||||
return (
|
||||
<div className="costs-root" style={{ minHeight: '100%', background: 'var(--c-bg)', padding: isMobile ? '6px 14px 28px' : '40px 24px 48px' }}>
|
||||
{isMobile ? <MobileBody /> : (
|
||||
{isMobile ? MobileBody() : (
|
||||
<div style={{ maxWidth: '100%', margin: '0 auto' }}>
|
||||
{/* ── Header bar ── */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, marginBottom: 28, flexWrap: 'wrap' }}>
|
||||
@@ -334,12 +349,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={settleAll} disabled={!(settlement?.flows || []).length}
|
||||
<button type="button" onClick={settleAll} disabled={!(settlement?.flows || []).length}
|
||||
className="bg-surface-card border border-edge text-content disabled:opacity-40"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 16px', borderRadius: 12, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Check size={16} /> {t('costs.settleUp')}
|
||||
</button>
|
||||
<button onClick={() => { setEditing(null); setModalOpen(true) }}
|
||||
<button type="button" onClick={() => { setEditing(null); setModalOpen(true) }}
|
||||
className="bg-[var(--text-primary)] text-[var(--bg-primary)]"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 18px', borderRadius: 12, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={16} /> {t('costs.addExpense')}
|
||||
@@ -387,14 +402,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{filterControls}
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
|
||||
{(['all', 'mine', 'owed'] as const).map(f => (
|
||||
<button key={f} onClick={() => setFilter(f)}
|
||||
<button type="button" key={f} onClick={() => setFilter(f)}
|
||||
className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||
style={{ padding: '6px 11px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('costs.filter.' + f)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
|
||||
<button type="button" onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
|
||||
className="bg-surface-input border border-edge text-content-muted disabled:opacity-40"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 34, height: 34, borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0 }}>
|
||||
<Download size={15} />
|
||||
@@ -422,8 +437,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{g.entries.map(en => en.kind === 'expense'
|
||||
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
|
||||
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}
|
||||
? <Fragment key={'e' + en.e.id}>{ExpenseRow({ e: en.e })}</Fragment>
|
||||
: <Fragment key={'s' + en.s.id}>{SettlementRow({ s: en.s })}</Fragment>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -437,26 +452,26 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
|
||||
{canEdit && (
|
||||
<button onClick={() => setAddingPayment(true)}
|
||||
<button type="button" onClick={() => setAddingPayment(true)}
|
||||
className="text-content-muted bg-surface-secondary border border-edge"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={13} /> {t('costs.addPayment')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<SettleFlows />
|
||||
{SettleFlows()}
|
||||
</div>
|
||||
|
||||
{/* balances */}
|
||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
|
||||
<BalancesList balances={settlement?.balances || []} />
|
||||
{BalancesList({ balances: settlement?.balances || [] })}
|
||||
</div>
|
||||
|
||||
{/* by category */}
|
||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
|
||||
<CategoryBreakdown />
|
||||
{CategoryBreakdown()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -520,8 +535,15 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</div>
|
||||
)
|
||||
|
||||
// Settle-up and Balances both come from the one settlement request, so they
|
||||
// share the notice that says the numbers are missing rather than zero.
|
||||
function loadFailed() {
|
||||
return <div className="text-content-muted" style={{ textAlign: 'center', padding: '14px 8px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('common.unknownError')}</div>
|
||||
}
|
||||
|
||||
// ── shared settle-flow list ──────────────────────────────────────────────
|
||||
function SettleFlows() {
|
||||
if (settlementError) return loadFailed()
|
||||
const flows = settlement?.flows || []
|
||||
if (flows.length === 0) return (
|
||||
<div style={{ textAlign: 'center', padding: '14px 8px' }}>
|
||||
@@ -539,7 +561,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700 }}>{fmt(f.amount)}</span>
|
||||
{canEdit && <button onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
|
||||
{canEdit && <button type="button" onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -548,6 +570,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
}
|
||||
|
||||
// ── mobile layout (Budget1Mobile.html): single flat column, total card on top ──
|
||||
//
|
||||
// Called as a plain function — `{MobileBody()}`, not `<MobileBody />` — and the
|
||||
// same goes for ExpenseRow, SettleFlows, BalancesList and CategoryBreakdown.
|
||||
// A component declared inside this one is a fresh component type on every
|
||||
// render, so React tears the old subtree down and mounts a new one instead of
|
||||
// updating it; the search box then loses focus after every keystroke. Calling
|
||||
// them keeps the markup inline where it always was. Do not "tidy" these back
|
||||
// into elements.
|
||||
function MobileBody() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}>
|
||||
@@ -560,7 +590,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<span>{t('costs.youPaid')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myPaid)}</b></span>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<button type="button" onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={17} /> {t('costs.addExpense')}
|
||||
</button>
|
||||
)}
|
||||
@@ -599,17 +629,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(19px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
|
||||
{canEdit && (
|
||||
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
|
||||
<button type="button" onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
|
||||
)}
|
||||
</div>
|
||||
<SettleFlows />
|
||||
{SettleFlows()}
|
||||
</div>
|
||||
|
||||
{/* Expenses */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(19px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.02em' }}>{t('costs.expenses')}</div>
|
||||
<button onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
|
||||
<button type="button" onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
|
||||
className="bg-surface-card border border-edge text-content-muted disabled:opacity-40"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 34, height: 34, borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0 }}>
|
||||
<Download size={15} />
|
||||
@@ -621,7 +651,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</div>
|
||||
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2 }}>
|
||||
{(['all', 'mine', 'owed'] as const).map(f => (
|
||||
<button key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
|
||||
<button type="button" key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
@@ -637,8 +667,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{!dayFilter && <div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
|
||||
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
|
||||
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
|
||||
? <Fragment key={'e' + en.e.id}>{ExpenseRow({ e: en.e })}</Fragment>
|
||||
: <Fragment key={'s' + en.s.id}>{SettlementRow({ s: en.s })}</Fragment>)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -647,13 +677,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{/* Balances */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
|
||||
<BalancesList balances={settlement?.balances || []} />
|
||||
{BalancesList({ balances: settlement?.balances || [] })}
|
||||
</div>
|
||||
|
||||
{/* By category */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
|
||||
<CategoryBreakdown />
|
||||
{CategoryBreakdown()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -676,17 +706,19 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
}
|
||||
return (
|
||||
<div className="exp-actions bg-surface-card border border-edge" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
|
||||
<button title={t('common.edit')} onClick={onEdit} className="text-content-muted transition-colors" style={btn}><Pencil size={13} /></button>
|
||||
<button title={deleteLabel} onClick={onDelete} className="exp-action-danger transition-colors" style={{ ...btn, color: '#dc2626' }}><Trash2 size={13} /></button>
|
||||
<button type="button" title={t('common.edit')} onClick={onEdit} className="text-content-muted transition-colors" style={btn}><Pencil size={13} /></button>
|
||||
<button type="button" title={deleteLabel} onClick={onDelete} className="exp-action-danger transition-colors" style={{ ...btn, color: '#dc2626' }}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Called, not rendered — see MobileBody: an element here would remount the
|
||||
// list on every keystroke in the search box.
|
||||
function ExpenseRow({ e }: { e: BudgetItem }) {
|
||||
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)
|
||||
@@ -775,7 +807,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && <RowActions onEdit={() => { setEditing(e); setModalOpen(true) }} onDelete={() => handleDelete(e.id)} deleteLabel={t('common.delete')} />}
|
||||
{canEdit && RowActions({ onEdit: () => { setEditing(e); setModalOpen(true) }, onDelete: () => handleDelete(e.id), deleteLabel: t('common.delete') })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -808,8 +840,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="exp-actions bg-surface-card border border-edge" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
|
||||
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="text-content-muted transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0 }}><Pencil size={13} /></button>
|
||||
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="exp-action-danger transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0, color: '#dc2626' }}><RotateCcw size={13} /></button>
|
||||
<button type="button" title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="text-content-muted transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0 }}><Pencil size={13} /></button>
|
||||
<button type="button" title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="exp-action-danger transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0, color: '#dc2626' }}><RotateCcw size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -817,6 +849,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
}
|
||||
|
||||
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
|
||||
if (settlementError) return loadFailed()
|
||||
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
|
||||
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
|
||||
return (
|
||||
@@ -846,9 +879,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.
|
||||
@@ -856,7 +892,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 }} />
|
||||
@@ -934,11 +970,13 @@ 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)
|
||||
|
||||
const amt = parseFloat(amount) || 0
|
||||
const amt = Number.parseFloat(amount) || 0
|
||||
const valid = amt > 0 && fromId !== toId
|
||||
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
|
||||
|
||||
@@ -959,8 +997,8 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
|
||||
<Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md"
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
|
||||
<button type="button" onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button type="button" onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
|
||||
</div>
|
||||
}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
@@ -1017,11 +1055,15 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const [name, setName] = useState(editing?.name || prefill?.name || '')
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||
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>>(() =>
|
||||
@@ -1032,8 +1074,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]
|
||||
@@ -1044,7 +1087,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
|
||||
@@ -1069,7 +1112,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1084,9 +1127,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
return calculateTicketShares(ticketItems)
|
||||
}, [ticketItems])
|
||||
|
||||
const totalNum = isTicketMode ? ticketInfo.total : (parseFloat(total) || 0)
|
||||
const splitSum = [...participants].reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
|
||||
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)
|
||||
@@ -1098,18 +1145,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
|
||||
const enteredSum = [...participants]
|
||||
.filter(id => customAmounts[id])
|
||||
.reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
|
||||
const remaining = Math.max(0, totalNum - enteredSum)
|
||||
.reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
// 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 && (parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
|
||||
|
||||
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) => {
|
||||
@@ -1164,7 +1216,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 }))
|
||||
}
|
||||
}
|
||||
@@ -1187,7 +1239,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))
|
||||
}
|
||||
}
|
||||
@@ -1231,8 +1283,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
// dropping the payer would store the entry with a total of 0.
|
||||
const payerList = multiPayer
|
||||
? [...payerIds]
|
||||
.map(id => ({ user_id: id, amount: parseFloat(payerAmounts[id]) || 0 }))
|
||||
.filter(p => p.amount > 0)
|
||||
.map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 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
|
||||
@@ -1243,7 +1295,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const memberList = memberIds.map(id => ({
|
||||
user_id: id,
|
||||
amount: splitMode === 'custom'
|
||||
? (parseFloat(customAmounts[id]) || 0)
|
||||
? (Number.parseFloat(customAmounts[id]) || 0)
|
||||
: splitMode === 'ticket'
|
||||
? (ticketInfo.shares[id] || 0)
|
||||
: null
|
||||
@@ -1283,8 +1335,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size={isMobile ? '2xl' : '5xl'}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
|
||||
<button type="button" onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button type="button" onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
|
||||
</div>
|
||||
}>
|
||||
{/* Two columns on desktop: what the expense was on the left, how it is
|
||||
@@ -1302,8 +1354,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>
|
||||
@@ -1321,7 +1374,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>
|
||||
@@ -1338,7 +1391,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
{COST_CATEGORY_LIST.map(c => {
|
||||
const Icon = c.Icon; const on = cat === c.key
|
||||
return (
|
||||
<button key={c.key} onClick={() => setCat(c.key)}
|
||||
<button type="button" key={c.key} onClick={() => setCat(c.key)}
|
||||
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 11px 6px 7px', borderRadius: 999, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
|
||||
<span style={{ width: 20, height: 20, borderRadius: 6, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={12} /></span>
|
||||
@@ -1390,7 +1443,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"
|
||||
@@ -1445,7 +1498,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 130px auto', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Item name"
|
||||
placeholder={t('costs.ticketItemName')}
|
||||
value={item.name}
|
||||
onChange={e => handleUpdateItemName(item.id, e.target.value)}
|
||||
className="bg-surface-input border border-edge text-content"
|
||||
@@ -1468,7 +1521,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', marginRight: 4 }}>Splitting:</span>
|
||||
<span className="text-content-faint" style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', marginRight: 4 }}>{t('costs.ticketSplitting')}</span>
|
||||
{people.map((p, pIdx) => {
|
||||
const active = item.participants.has(p.id)
|
||||
return (
|
||||
@@ -1492,12 +1545,12 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={handleAddEmptyItem} className="border border-dashed border-edge text-content-muted" style={{ padding: '8px 12px', borderRadius: 10, background: 'none', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||||
<Plus size={14} /> Add item
|
||||
<Plus size={14} /> {t('costs.ticketAddItem')}
|
||||
</button>
|
||||
|
||||
{ticketItems.length > 0 && (
|
||||
<div className="bg-surface-secondary border border-edge" style={{ padding: 12, borderRadius: 10 }}>
|
||||
<div className="text-content" style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Individual Shares Summary</div>
|
||||
<div className="text-content" style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>{t('costs.ticketShares')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{people.map(p => {
|
||||
const share = ticketInfo.shares[p.id] || 0
|
||||
@@ -1532,7 +1585,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
{sym(currency)}{(equalShares[p.id] || 0).toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-content-faint" style={{ fontSize: 12, textAlign: 'right', paddingRight: 10 }}>Excluded</span>
|
||||
<span className="text-content-faint" style={{ fontSize: 12, textAlign: 'right', paddingRight: 10 }}>{t('costs.excluded')}</span>
|
||||
)
|
||||
) : (
|
||||
on ? (
|
||||
@@ -1557,9 +1610,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
|
||||
{customBalanced
|
||||
? 'Split matches total'
|
||||
: `Sum of splits: ${sym(currency)}${splitSum.toFixed(2)} of ${sym(currency)}${totalNum.toFixed(2)} (${(totalNum - splitSum) > 0 ? 'under by' : 'over by'} ${sym(currency)}${Math.abs(totalNum - splitSum).toFixed(2)})`}
|
||||
{customBalanced
|
||||
? t('costs.splitBalanced')
|
||||
: 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),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('CollabChat', () => {
|
||||
|
||||
it('FE-COMP-CHAT-002: shows empty state when no messages', async () => {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Start the conversation');
|
||||
expect(await screen.findByText('Start the conversation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-003: shows message input placeholder', async () => {
|
||||
@@ -85,7 +85,7 @@ describe('CollabChat', () => {
|
||||
)
|
||||
);
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Hello world!');
|
||||
expect(await screen.findByText('Hello world!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-006: typing in input updates text field', async () => {
|
||||
@@ -599,7 +599,7 @@ describe('CollabChat', () => {
|
||||
await screen.findByText('New 100');
|
||||
const loadMoreBtn = screen.getByRole('button', { name: /load/i });
|
||||
await user.click(loadMoreBtn);
|
||||
await screen.findByText('Older message');
|
||||
expect(await screen.findByText('Older message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-033: clicking delete on own message marks it as deleted', async () => {
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||
<strong>{replyTo.username}</strong>: {(replyTo.text || '').slice(0, 60)}
|
||||
</span>
|
||||
<button onClick={() => setReplyTo(null)} style={{
|
||||
<button type="button" onClick={() => setReplyTo(null)} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)',
|
||||
display: 'flex', flexShrink: 0,
|
||||
}}>
|
||||
@@ -51,7 +51,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||
{/* Emoji button */}
|
||||
{canEdit && (
|
||||
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
||||
<button type="button" ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -80,7 +80,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
|
||||
{/* Send */}
|
||||
{canEdit && (
|
||||
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
<button type="button" onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
|
||||
@@ -46,7 +46,7 @@ export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: Emoj
|
||||
{/* Category tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
|
||||
{Object.keys(EMOJI_CATEGORIES).map(c => (
|
||||
<button key={c} onClick={() => setCat(c)} style={{
|
||||
<button type="button" key={c} onClick={() => setCat(c)} style={{
|
||||
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: cat === c ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-primary)', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit',
|
||||
@@ -58,7 +58,7 @@ export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: Emoj
|
||||
{/* Emoji grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
|
||||
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
|
||||
<button key={i} onClick={() => onSelect(emoji)} style={{
|
||||
<button type="button" key={i} onClick={() => onSelect(emoji)} style={{
|
||||
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
|
||||
padding: 2, transition: 'transform 0.1s',
|
||||
|
||||
@@ -2,7 +2,12 @@ import { useState, useEffect } from 'react'
|
||||
import { collabApi } from '../../api/client'
|
||||
|
||||
/* ── Link Preview ── */
|
||||
const previewCache = {}
|
||||
// linkPreview is trip-scoped on the server, so the cache key has to be too.
|
||||
// Capped as well: a long chat session would otherwise keep every preview it ever
|
||||
// rendered for as long as the tab lives.
|
||||
const previewCache = new Map()
|
||||
const cacheKey = (tripId: number, url: string): string => `${tripId} ${url}`
|
||||
const MAX_CACHED_PREVIEWS = 200
|
||||
|
||||
interface LinkPreviewProps {
|
||||
url: string
|
||||
@@ -12,17 +17,23 @@ interface LinkPreviewProps {
|
||||
}
|
||||
|
||||
export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
|
||||
const [data, setData] = useState(previewCache[url] || null)
|
||||
const [loading, setLoading] = useState(!previewCache[url])
|
||||
const [data, setData] = useState(previewCache.get(cacheKey(tripId, url)) || null)
|
||||
const [loading, setLoading] = useState(!previewCache.has(cacheKey(tripId, url)))
|
||||
|
||||
useEffect(() => {
|
||||
if (previewCache[url]) return
|
||||
const key = cacheKey(tripId, url)
|
||||
const cached = previewCache.get(key)
|
||||
if (cached) { setData(cached); setLoading(false); return }
|
||||
let current = true
|
||||
collabApi.linkPreview(tripId, url).then(d => {
|
||||
previewCache[url] = d
|
||||
if (previewCache.size >= MAX_CACHED_PREVIEWS) previewCache.delete(previewCache.keys().next().value)
|
||||
previewCache.set(key, d)
|
||||
if (!current) return
|
||||
setData(d)
|
||||
setLoading(false)
|
||||
if (d?.title || d?.description || d?.image) onLoad?.()
|
||||
}).catch(() => setLoading(false))
|
||||
}).catch(() => { if (current) setLoading(false) })
|
||||
return () => { current = false }
|
||||
}, [url, tripId])
|
||||
|
||||
if (loading || !data || (!data.title && !data.description && !data.image)) return null
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ChatMessages(props: any) {
|
||||
}}>
|
||||
{hasMore && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
|
||||
<button onClick={handleLoadMore} disabled={loadingMore} style={{
|
||||
<button type="button" onClick={handleLoadMore} disabled={loadingMore} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
|
||||
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
|
||||
@@ -181,7 +181,7 @@ export function ChatMessages(props: any) {
|
||||
transition: 'opacity .1s',
|
||||
...(own ? { left: -6 } : { right: -6 }),
|
||||
}}>
|
||||
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
|
||||
<button type="button" onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
@@ -193,7 +193,7 @@ export function ChatMessages(props: any) {
|
||||
<Reply size={11} />
|
||||
</button>
|
||||
{own && canEdit && (
|
||||
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
|
||||
<button type="button" onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadg
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={ref} onClick={onReact}
|
||||
<button type="button" ref={ref} onClick={onReact}
|
||||
onMouseEnter={() => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
|
||||
@@ -31,7 +31,7 @@ export function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
|
||||
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
|
||||
}}>
|
||||
{QUICK_REACTIONS.map(emoji => (
|
||||
<button key={emoji} onClick={() => onReact(emoji)} style={{
|
||||
<button type="button" key={emoji} onClick={() => onReact(emoji)} style={{
|
||||
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
|
||||
padding: 3, transition: 'transform 0.1s, background 0.1s',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-W4CCS-001 to FE-W4CCS-016
|
||||
// FE-W4CCS-001 to FE-W4CCS-019
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
import type { ChatReaction } from './CollabChat.types'
|
||||
@@ -11,6 +11,7 @@ import { TwemojiImg } from './CollabChatTwemojiImg'
|
||||
import { ReactionBadge } from './CollabChatReactionBadge'
|
||||
import { UserAvatar } from './CollabNotesUserAvatar'
|
||||
import { WebsiteThumbnail } from './CollabNotesWebsiteThumbnail'
|
||||
import { LinkPreview } from './CollabChatLinkPreview'
|
||||
|
||||
function reaction(overrides: Partial<ChatReaction> = {}): ChatReaction {
|
||||
return { emoji: '👍', count: 1, users: [{ id: 1, username: 'ada' }], ...overrides } as unknown as ChatReaction
|
||||
@@ -147,13 +148,26 @@ describe('WebsiteThumbnail', () => {
|
||||
expect(screen.getByText('example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4CCS-014: falls back to a link label for an unparseable url and a failing preview', async () => {
|
||||
it('FE-W4CCS-014: keeps the domain chip when the preview request fails', async () => {
|
||||
linkPreview.mockRejectedValue(new Error('blocked'))
|
||||
render(<WebsiteThumbnail url="not a url" tripId={4} color="#000" />)
|
||||
render(<WebsiteThumbnail url="https://blocked.example/a" tripId={4} color="#000" />)
|
||||
|
||||
expect(screen.getByText('link')).toBeInTheDocument()
|
||||
expect(screen.getByText('blocked.example')).toBeInTheDocument()
|
||||
await waitFor(() => expect(linkPreview).toHaveBeenCalled())
|
||||
expect(screen.getByRole('link')).toHaveAttribute('title', 'not a url')
|
||||
expect(screen.getByRole('link')).toHaveAttribute('title', 'https://blocked.example/a')
|
||||
})
|
||||
|
||||
it('FE-W4CCS-017: a url that is not http(s) is shown but neither linked nor previewed', async () => {
|
||||
const { unmount } = render(<WebsiteThumbnail url="javascript:alert(1)" tripId={4} color="#000" />)
|
||||
|
||||
expect(screen.queryByRole('link')).toBeNull()
|
||||
expect(screen.getByTitle('javascript:alert(1)')).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
render(<WebsiteThumbnail url="not a url" tripId={4} color="#000" />)
|
||||
expect(screen.getByText('link')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link')).toBeNull()
|
||||
expect(linkPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W4CCS-015: caches per trip, because the preview endpoint is trip-scoped', async () => {
|
||||
@@ -185,3 +199,34 @@ describe('WebsiteThumbnail', () => {
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'https://cdn.example/b.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LinkPreview', () => {
|
||||
it('FE-W4CCS-018: caches per trip, because the preview endpoint is trip-scoped', async () => {
|
||||
const shared = 'https://example.com/preview'
|
||||
linkPreview.mockResolvedValue({ title: 'Trip 4', image: null })
|
||||
const first = render(<LinkPreview url={shared} tripId={4} own={false} onLoad={undefined} />)
|
||||
expect(await screen.findByText('Trip 4')).toBeInTheDocument()
|
||||
first.unmount()
|
||||
|
||||
linkPreview.mockResolvedValue({ title: 'Trip 9', image: null })
|
||||
render(<LinkPreview url={shared} tripId={9} own={false} onLoad={undefined} />)
|
||||
|
||||
expect(await screen.findByText('Trip 9')).toBeInTheDocument()
|
||||
expect(linkPreview).toHaveBeenCalledTimes(2)
|
||||
expect(linkPreview).toHaveBeenLastCalledWith(9, shared)
|
||||
})
|
||||
|
||||
it('FE-W4CCS-019: a preview that resolves after the url changed is not shown', async () => {
|
||||
let resolveFirst: (v: { title: string; image: string | null }) => void = () => {}
|
||||
linkPreview.mockImplementationOnce(() => new Promise(res => { resolveFirst = res }))
|
||||
const { rerender } = render(<LinkPreview url="https://slow.example/a" tripId={4} own={false} onLoad={undefined} />)
|
||||
|
||||
linkPreview.mockResolvedValue({ title: 'Second', image: null })
|
||||
rerender(<LinkPreview url="https://fast.example/b" tripId={4} own={false} onLoad={undefined} />)
|
||||
expect(await screen.findByText('Second')).toBeInTheDocument()
|
||||
|
||||
resolveFirst({ title: 'First', image: null })
|
||||
await waitFor(() => expect(screen.queryByText('First')).toBeNull())
|
||||
expect(screen.getByText('Second')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('CollabNotes', () => {
|
||||
|
||||
it('FE-COMP-NOTES-002: shows empty state when no notes', async () => {
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
expect(await screen.findByText('No notes yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-003: shows New Note button', async () => {
|
||||
@@ -70,7 +70,7 @@ describe('CollabNotes', () => {
|
||||
)
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Packing Tips');
|
||||
expect(await screen.findByText('Packing Tips')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-005: clicking New Note opens modal', async () => {
|
||||
@@ -79,7 +79,7 @@ describe('CollabNotes', () => {
|
||||
await screen.findByText('No notes yet');
|
||||
await user.click(screen.getByText('New Note'));
|
||||
// Modal opens with a title input — placeholder is "Note title" (no ellipsis)
|
||||
await screen.findByPlaceholderText('Note title');
|
||||
expect(await screen.findByPlaceholderText('Note title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-006: note title is shown in the grid', async () => {
|
||||
@@ -96,7 +96,7 @@ describe('CollabNotes', () => {
|
||||
)
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('My Checklist');
|
||||
expect(await screen.findByText('My Checklist')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-007: multiple notes all render', async () => {
|
||||
@@ -117,8 +117,11 @@ describe('CollabNotes', () => {
|
||||
|
||||
it('FE-COMP-NOTES-008: Notes title heading is shown', async () => {
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
// The loading panel renders its own copy of the heading and swaps the whole
|
||||
// node out once the fetch settles, so wait for the loaded state first.
|
||||
await screen.findByText('No notes yet');
|
||||
// collab.notes.title = "Notes"
|
||||
await screen.findByText('Notes');
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-009: create note calls POST API', async () => {
|
||||
@@ -236,7 +239,7 @@ describe('CollabNotes', () => {
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Editable Note');
|
||||
await user.click(screen.getByTitle('Edit'));
|
||||
await screen.findByDisplayValue('Editable Note');
|
||||
expect(await screen.findByDisplayValue('Editable Note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-016: category filter hides notes from other categories', async () => {
|
||||
@@ -271,6 +274,7 @@ describe('CollabNotes', () => {
|
||||
const listener = calls[calls.length - 1][0];
|
||||
act(() => {
|
||||
listener({
|
||||
tripId: 1,
|
||||
type: 'collab:note:created',
|
||||
note: {
|
||||
id: 50, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null,
|
||||
@@ -279,7 +283,7 @@ describe('CollabNotes', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
await screen.findByText('Live Note');
|
||||
expect(await screen.findByText('Live Note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-018: WebSocket collab:note:deleted event removes note', async () => {
|
||||
@@ -301,7 +305,7 @@ describe('CollabNotes', () => {
|
||||
const calls = (addListener as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const listener = calls[calls.length - 1][0];
|
||||
act(() => {
|
||||
listener({ type: 'collab:note:deleted', noteId: 7 });
|
||||
listener({ tripId: 1, type: 'collab:note:deleted', noteId: 7 });
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByText('WS Delete')).not.toBeInTheDocument());
|
||||
});
|
||||
@@ -402,7 +406,7 @@ describe('CollabNotes', () => {
|
||||
await screen.findByText('No notes yet');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
// The modal header renders "Category Settings" or similar
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
expect(await screen.findByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-025: CategorySettingsModal shows no categories message when empty', async () => {
|
||||
@@ -410,7 +414,7 @@ describe('CollabNotes', () => {
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await screen.findByText('No categories yet');
|
||||
expect(await screen.findByText('No categories yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-026: CategorySettingsModal add new category', async () => {
|
||||
@@ -425,7 +429,7 @@ describe('CollabNotes', () => {
|
||||
const addBtn = newCatInput.nextElementSibling as HTMLElement;
|
||||
await user.click(addBtn);
|
||||
// "Transport" category appears in the modal
|
||||
await screen.findByText('Transport');
|
||||
expect(await screen.findByText('Transport')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-027: CategorySettingsModal close button dismisses it', async () => {
|
||||
@@ -463,6 +467,7 @@ describe('CollabNotes', () => {
|
||||
const listener = calls[calls.length - 1][0];
|
||||
act(() => {
|
||||
listener({
|
||||
tripId: 1,
|
||||
type: 'collab:note:updated',
|
||||
note: {
|
||||
id: 5, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null,
|
||||
@@ -672,7 +677,7 @@ describe('CollabNotes', () => {
|
||||
// Click the PDF badge to open FilePreviewPortal
|
||||
await user.click(screen.getByText('PDF'));
|
||||
// FilePreviewPortal renders the file name in the header
|
||||
await screen.findByText('document.pdf');
|
||||
expect(await screen.findByText('document.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTES-037: note with website shows website thumbnail component', async () => {
|
||||
@@ -853,7 +858,7 @@ describe('CollabNotes', () => {
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
// Find the "Transport" category name span and click to edit
|
||||
const categoryNameSpan = screen.getAllByText('Transport').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename');
|
||||
const categoryNameSpan = screen.getAllByText('Transport').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename');
|
||||
if (categoryNameSpan) {
|
||||
await user.click(categoryNameSpan);
|
||||
// Now an input with value "Transport" should appear
|
||||
@@ -975,7 +980,7 @@ describe('CollabNotes', () => {
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
|
||||
// Find and click the "OldCat" category name span to enter edit mode
|
||||
const oldCatSpan = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename');
|
||||
const oldCatSpan = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename');
|
||||
if (oldCatSpan) {
|
||||
await user.click(oldCatSpan);
|
||||
const editInput = screen.getByDisplayValue('OldCat');
|
||||
@@ -1322,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;
|
||||
@@ -1346,7 +1359,7 @@ describe('CollabNotes details', () => {
|
||||
it('FE-W5CNT-001: a corrupt category cache in localStorage is ignored', async () => {
|
||||
localStorage.setItem('collab-cats-1', '{not json');
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
expect(await screen.findByText('No notes yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-002: a category without a stored colour falls back to the first palette entry', async () => {
|
||||
@@ -1367,13 +1380,13 @@ describe('CollabNotes details', () => {
|
||||
it('FE-W5CNT-004: notes served as a bare array are rendered', async () => {
|
||||
serveNotes([buildNote({ title: 'Array note' })]);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Array note');
|
||||
expect(await screen.findByText('Array note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-005: an empty payload yields an empty list', async () => {
|
||||
serveNotes(null);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
expect(await screen.findByText('No notes yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-006: a failing load falls back to the empty state', async () => {
|
||||
@@ -1381,7 +1394,7 @@ describe('CollabNotes details', () => {
|
||||
http.get('/api/trips/1/collab/notes', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
expect(await screen.findByText('No notes yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-007: a WebSocket create for a note already in the list does not duplicate it', async () => {
|
||||
@@ -1390,7 +1403,7 @@ describe('CollabNotes details', () => {
|
||||
await screen.findByText('Already here');
|
||||
const handler = wsHandler();
|
||||
await act(async () => {
|
||||
handler({ type: 'collab:note:created', note: buildNote({ id: 4, title: 'Already here' }) });
|
||||
handler({ tripId: 1, type: 'collab:note:created', note: buildNote({ id: 4, title: 'Already here' }) });
|
||||
});
|
||||
expect(screen.getAllByText('Already here')).toHaveLength(1);
|
||||
});
|
||||
@@ -1403,7 +1416,7 @@ describe('CollabNotes details', () => {
|
||||
await screen.findByText('Second');
|
||||
const handler = wsHandler();
|
||||
await act(async () => {
|
||||
handler({ type: 'collab:note:updated', note: { id: 2, title: 'Second renamed' } });
|
||||
handler({ tripId: 1, type: 'collab:note:updated', note: { id: 2, title: 'Second renamed' } });
|
||||
});
|
||||
expect(await screen.findByText('Second renamed')).toBeInTheDocument();
|
||||
expect(screen.getByText('First')).toBeInTheDocument();
|
||||
@@ -1414,9 +1427,9 @@ describe('CollabNotes details', () => {
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Doomed');
|
||||
const handler = wsHandler();
|
||||
await act(async () => { handler({ type: 'collab:note:deleted' }); });
|
||||
await act(async () => { handler({ tripId: 1, type: 'collab:note:deleted' }); });
|
||||
expect(screen.getByText('Doomed')).toBeInTheDocument();
|
||||
await act(async () => { handler({ type: 'collab:note:deleted', id: 9 }); });
|
||||
await act(async () => { handler({ tripId: 1, type: 'collab:note:deleted', id: 9 }); });
|
||||
await waitFor(() => expect(screen.queryByText('Doomed')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -1432,7 +1445,7 @@ describe('CollabNotes details', () => {
|
||||
await user.click(screen.getByText('New Note'));
|
||||
await user.type(await screen.findByPlaceholderText('Note title'), 'Fresh note');
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
await screen.findByText('Fresh note');
|
||||
expect(await screen.findByText('Fresh note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-011: an empty create response leaves the list untouched', async () => {
|
||||
@@ -1762,10 +1775,13 @@ describe('CollabNotes details', () => {
|
||||
if (!img) throw new Error('image attachment not rendered yet');
|
||||
return img;
|
||||
});
|
||||
fireEvent.mouseEnter(image);
|
||||
expect(image.style.transform).toBe('scale(1.06)');
|
||||
fireEvent.mouseLeave(image);
|
||||
expect(image.style.transform).toBe('scale(1)');
|
||||
// The clickable thumbnail is a real button wrapped around the image, so the
|
||||
// hover transform lands on that button.
|
||||
const imageTile = image.closest('button') as HTMLElement;
|
||||
fireEvent.mouseEnter(imageTile);
|
||||
expect(imageTile.style.transform).toBe('scale(1.06)');
|
||||
fireEvent.mouseLeave(imageTile);
|
||||
expect(imageTile.style.transform).toBe('scale(1)');
|
||||
fireEvent.click(image);
|
||||
await waitFor(() => expect(screen.queryByText('Download itinerary.zip')).not.toBeInTheDocument());
|
||||
});
|
||||
@@ -1803,6 +1819,59 @@ describe('CollabNotes details', () => {
|
||||
expect(screen.getByText('Older note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-030: a WebSocket event for another trip is ignored', async () => {
|
||||
serveNotes({ notes: [] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
const handler = wsHandler();
|
||||
await act(async () => {
|
||||
handler({ tripId: 2, type: 'collab:note:created', note: buildNote({ id: 60, title: 'Other trip note' }) });
|
||||
});
|
||||
expect(screen.queryByText('Other trip note')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-031: a rejected rename still renames the rest and re-reads the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
const puts: string[] = [];
|
||||
serveNotesSequence([
|
||||
{ notes: [buildNote({ id: 1, title: 'First note', category: 'OldCat' }), buildNote({ id: 2, title: 'Second note', category: 'OldCat' })] },
|
||||
{ notes: [buildNote({ id: 1, title: 'First note', category: 'OldCat' }), buildNote({ id: 2, title: 'Second note', category: 'NewCat' })] },
|
||||
]);
|
||||
server.use(
|
||||
http.put('/api/trips/1/collab/notes/1', () => {
|
||||
puts.push('1');
|
||||
return new HttpResponse(null, { status: 500 });
|
||||
}),
|
||||
http.put('/api/trips/1/collab/notes/2', () => {
|
||||
puts.push('2');
|
||||
return HttpResponse.json({ note: buildNote({ id: 2, title: 'Second note', category: 'NewCat' }) });
|
||||
}),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('First note');
|
||||
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename')!;
|
||||
await user.click(oldCat);
|
||||
const editInput = screen.getByDisplayValue('OldCat');
|
||||
await user.clear(editInput);
|
||||
await user.type(editInput, 'NewCat');
|
||||
await user.keyboard('{Enter}');
|
||||
await user.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
|
||||
// The rejected note must not stop the second one, but the modal stays open —
|
||||
// half a rename is not a saved rename.
|
||||
await waitFor(() => expect(puts).toEqual(['1', '2']));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
expect(screen.getByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument();
|
||||
// Re-read: the note the server refused is still shown under its old category.
|
||||
const header = screen.getByText('Manage Categories', { selector: 'h3' }).parentElement!;
|
||||
await user.click(within(header).getByRole('button'));
|
||||
await waitFor(() => expect(screen.getAllByText('NewCat').length).toBeGreaterThan(0));
|
||||
expect(screen.getAllByText('OldCat').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-W5CNT-029: a failing delete reports an error and keeps the note in the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 30, title: 'Stubborn note' })] });
|
||||
@@ -1819,4 +1888,63 @@ describe('CollabNotes details', () => {
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
expect(screen.getByText('Stubborn note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-032: a rejected category rename keeps the settings modal open and reports once', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({
|
||||
notes: [
|
||||
buildNote({ id: 1, title: 'First note', category: 'OldCat' }),
|
||||
buildNote({ id: 2, title: 'Second note', category: 'OldCat' }),
|
||||
],
|
||||
});
|
||||
server.use(
|
||||
http.put('/api/trips/1/collab/notes/:id', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('First note');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
await screen.findByText('Manage Categories', { selector: 'h3' });
|
||||
|
||||
const oldCat = screen.getAllByText('OldCat').find(el => el.tagName === 'BUTTON' && el.title === 'Click to rename')!;
|
||||
await user.click(oldCat);
|
||||
const editInput = screen.getByDisplayValue('OldCat');
|
||||
await user.clear(editInput);
|
||||
await user.type(editInput, 'NewCat');
|
||||
await user.keyboard('{Enter}');
|
||||
await screen.findByText('NewCat');
|
||||
await user.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
// Nothing was renamed, so the modal must not act like it was saved…
|
||||
expect(screen.getByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument();
|
||||
// …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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { collabApi } from '../../api/client'
|
||||
@@ -89,6 +90,9 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
if (!tripId) return
|
||||
|
||||
const handler = (msg) => {
|
||||
// The panel is not remounted on a trip change, so an event still in flight
|
||||
// from the trip we just left must not land in this list.
|
||||
if (String(msg?.tripId) !== String(tripId)) return
|
||||
if (msg.type === 'collab:note:created' && msg.note) {
|
||||
setNotes(prev => {
|
||||
if (prev.some(n => n.id === msg.note.id)) return prev
|
||||
@@ -145,12 +149,13 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleUpdateNote = useCallback(async (noteId, data) => {
|
||||
const handleUpdateNote = useCallback(async (noteId, data, opts: { silent?: boolean } = {}) => {
|
||||
let result
|
||||
try {
|
||||
result = await collabApi.updateNote(tripId, noteId, data)
|
||||
} catch (err) {
|
||||
toast.error(t('common.error'))
|
||||
// A batch of writes reports once for the whole run instead of once per note.
|
||||
if (!opts.silent) toast.error(t('common.error'))
|
||||
throw err
|
||||
}
|
||||
const updated = result?.note || result
|
||||
@@ -161,16 +166,28 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
// A colour or a rename is N single-note writes; if one of them is rejected the
|
||||
// rest still have to run, and the list has to be re-read so it stops showing a
|
||||
// change the server never took. Reporting the failure is the caller's job.
|
||||
const resyncNotes = useCallback(async () => {
|
||||
try {
|
||||
const fresh = await collabApi.getNotes(tripId)
|
||||
setNotes(fresh?.notes || fresh || [])
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
|
||||
const saveCategoryColors = useCallback(async (newMap) => {
|
||||
let failed = 0
|
||||
// Update notes with changed colors
|
||||
for (const [cat, color] of Object.entries(newMap)) {
|
||||
const notesInCat = notes.filter(n => n.category === cat)
|
||||
if (notesInCat.length > 0 && categoryColors[cat] !== color) {
|
||||
for (const n of notesInCat) {
|
||||
await handleUpdateNote(n.id, { color })
|
||||
try { await handleUpdateNote(n.id, { color }) } catch { failed++ }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failed > 0) await resyncNotes()
|
||||
// Save all categories (including empty ones) to localStorage
|
||||
const emptyCats = {}
|
||||
for (const [cat, color] of Object.entries(newMap)) {
|
||||
@@ -179,7 +196,23 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
}
|
||||
}
|
||||
saveEmptyCategories(emptyCats)
|
||||
}, [categoryColors, notes, handleUpdateNote])
|
||||
}, [categoryColors, notes, handleUpdateNote, resyncNotes])
|
||||
|
||||
const renameCategory = useCallback(async (oldName, newName) => {
|
||||
// Update all notes with this category in DB
|
||||
const toUpdate = notes.filter(n => n.category === oldName)
|
||||
let failed = 0
|
||||
for (const n of toUpdate) {
|
||||
try { await handleUpdateNote(n.id, { category: newName }, { silent: true }) } catch { failed++ }
|
||||
}
|
||||
// The rest still had to run, but a partial rename is not a saved rename: the
|
||||
// settings modal has to stay open on the rejection instead of closing on it.
|
||||
if (failed > 0) {
|
||||
await resyncNotes()
|
||||
toast.error(t('common.error'))
|
||||
throw new Error(`rename failed for ${failed} of ${toUpdate.length} notes`)
|
||||
}
|
||||
}, [notes, handleUpdateNote, resyncNotes, toast, t])
|
||||
|
||||
const handleEditSubmit = useCallback(async (data) => {
|
||||
if (!editingNote) return
|
||||
@@ -232,7 +265,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
notes, loading, showNewModal, setShowNewModal, editingNote, setEditingNote,
|
||||
viewingNote, setViewingNote, previewFile, setPreviewFile, showSettings, setShowSettings,
|
||||
activeCategory, setActiveCategory, categoryColors, getCategoryColor,
|
||||
handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit,
|
||||
handleCreateNote, handleUpdateNote, saveCategoryColors, renameCategory, handleEditSubmit,
|
||||
handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes,
|
||||
pendingDeleteNoteId, setPendingDeleteNoteId,
|
||||
}
|
||||
@@ -271,13 +304,13 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
|
||||
{t('collab.notes.title')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings')}
|
||||
{canEdit && <button type="button" onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings')}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Settings size={14} />
|
||||
</button>}
|
||||
{canEdit && <button onClick={() => setShowNewModal(true)}
|
||||
{canEdit && <button type="button" onClick={() => setShowNewModal(true)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||||
<Plus size={12} />
|
||||
{t('collab.notes.new')}
|
||||
@@ -290,7 +323,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
|
||||
function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t }: NotesState) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4, padding: '8px 12px 0', overflowX: 'auto', flexShrink: 0 }}>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => setActiveCategory(null)}
|
||||
style={{
|
||||
flexShrink: 0, borderRadius: 99, padding: '3px 10px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: FONT,
|
||||
@@ -303,7 +336,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
|
||||
{t('collab.notes.all')}
|
||||
</button>
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(prev => prev === cat ? null : cat)}
|
||||
style={{
|
||||
@@ -371,7 +404,8 @@ function ViewNoteModal(S: NotesState) {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 10000, padding: 16,
|
||||
}}
|
||||
onClick={() => setViewingNote(null)}
|
||||
role="presentation"
|
||||
onClick={e => { if (e.target === e.currentTarget) setViewingNote(null) }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -380,7 +414,6 @@ function ViewNoteModal(S: NotesState) {
|
||||
width: 'min(700px, calc(100vw - 32px))', maxHeight: '80vh',
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div style={{
|
||||
padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)',
|
||||
@@ -398,13 +431,13 @@ function ViewNoteModal(S: NotesState) {
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||
{canEdit && <button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
|
||||
{canEdit && <button type="button" onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
|
||||
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={16} />
|
||||
</button>}
|
||||
<button onClick={() => setViewingNote(null)}
|
||||
<button type="button" onClick={() => setViewingNote(null)}
|
||||
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
@@ -413,7 +446,7 @@ 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>
|
||||
{(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>
|
||||
@@ -430,17 +463,18 @@ function ViewNoteModal(S: NotesState) {
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
|
||||
) : (
|
||||
<div title={a.original_name} onClick={() => setPreviewFile(a)}
|
||||
<button type="button" title={a.original_name} onClick={() => setPreviewFile(a)}
|
||||
style={{
|
||||
width: 64, height: 64, borderRadius: 8, cursor: 'pointer',
|
||||
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
|
||||
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||
border: 'none', padding: 0, fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', textAlign: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', width: '100%' }}>{a.original_name}</span>
|
||||
</div>
|
||||
@@ -462,7 +496,7 @@ export default function CollabNotes(props: CollabNotesProps) {
|
||||
loading, tripId, t, categories, categoryColors, getCategoryColor, notes,
|
||||
viewingNote, showNewModal, editingNote, previewFile, showSettings,
|
||||
setShowNewModal, setEditingNote, setPreviewFile, setShowSettings,
|
||||
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote,
|
||||
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, renameCategory, handleUpdateNote,
|
||||
handleDeleteNote, pendingDeleteNoteId, setPendingDeleteNoteId,
|
||||
} = S
|
||||
|
||||
@@ -511,13 +545,7 @@ export default function CollabNotes(props: CollabNotesProps) {
|
||||
categories={categories}
|
||||
categoryColors={categoryColors}
|
||||
onSave={saveCategoryColors}
|
||||
onRenameCategory={async (oldName, newName) => {
|
||||
// Update all notes with this category in DB
|
||||
const toUpdate = notes.filter(n => n.category === oldName)
|
||||
for (const n of toUpdate) {
|
||||
await handleUpdateNote(n.id, { category: newName })
|
||||
}
|
||||
}}
|
||||
onRenameCategory={renameCategory}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
|
||||
export function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
|
||||
export function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLElement>; onMouseLeave?: React.MouseEventHandler<HTMLElement>; alt?: string }) {
|
||||
const [authSrc, setAuthSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||
}, [src])
|
||||
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
|
||||
if (!authSrc) return null
|
||||
// A clickable thumbnail gets a real button around the image so it is
|
||||
// reachable by keyboard. The caller's style stays on the outer box, so the
|
||||
// hover transform/transition still animates the element that carries them.
|
||||
if (onClick) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
|
||||
style={{ ...style, padding: 0, border: 'none', background: 'none', display: 'block' }}>
|
||||
<img src={authSrc} alt={alt} style={{ ...style, width: '100%', height: '100%', display: 'block' }} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return <img src={authSrc} alt={alt} style={style} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -80,26 +81,26 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
|
||||
display: 'flex', gap: 2,
|
||||
}}>
|
||||
{note.content && (
|
||||
<button onClick={() => onView?.(note)} title={t('collab.notes.expand') || 'Expand'}
|
||||
<button type="button" onClick={() => onView?.(note)} title={t('collab.notes.expand') || 'Expand'}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Maximize2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
{canEdit && <button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
||||
{canEdit && <button type="button" onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = color}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
|
||||
</button>}
|
||||
{canEdit && <button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||
{canEdit && <button type="button" onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={10} />
|
||||
</button>}
|
||||
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
|
||||
{canEdit && <button type="button" onClick={handleDelete} title={t('collab.notes.delete')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
@@ -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>
|
||||
@@ -171,17 +172,18 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }} />
|
||||
) : (
|
||||
<div key={a.id} title={a.original_name} onClick={() => onPreviewFile?.(a)}
|
||||
<button type="button" key={a.id} title={a.original_name} aria-label={a.original_name} onClick={() => onPreviewFile?.(a)}
|
||||
style={{
|
||||
width: 48, height: 48, borderRadius: 8, cursor: 'pointer',
|
||||
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
|
||||
border: 'none', padding: 0, fontFamily: 'inherit',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
|
||||
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}>
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{(note.attachments?.length || 0) > (note.website ? 1 : 2) && (
|
||||
|
||||
@@ -48,9 +48,14 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
// Apply renames to notes in DB
|
||||
for (const [oldName, newName] of Object.entries(renames)) {
|
||||
if (oldName !== newName) await onRenameCategory(oldName, newName)
|
||||
// Apply renames to notes in DB. A rejected rename keeps the modal open — closing
|
||||
// it would look like the rename went through; the caller has already reported it.
|
||||
try {
|
||||
for (const [oldName, newName] of Object.entries(renames)) {
|
||||
if (oldName !== newName) await onRenameCategory(oldName, newName)
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await onSave(localColors)
|
||||
onClose()
|
||||
@@ -64,17 +69,17 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
|
||||
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT,
|
||||
}} onClick={onClose}>
|
||||
}} role="presentation" onClick={onClose}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 420,
|
||||
maxHeight: '80vh', overflow: 'auto', border: '1px solid var(--border-faint)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
}} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
|
||||
{t('collab.notes.categorySettings') || 'Category Settings'}
|
||||
</h3>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}>
|
||||
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -91,7 +96,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
{/* Color swatches */}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{NOTE_COLORS.map(c => (
|
||||
<button key={c.value} onClick={() => handleColorChange(cat, c.value)} style={{
|
||||
<button type="button" key={c.value} onClick={() => handleColorChange(cat, c.value)} style={{
|
||||
width: 20, height: 20, borderRadius: 6, background: c.value, border: 'none', cursor: 'pointer', padding: 0,
|
||||
outline: (localColors[cat] || NOTE_COLORS[0].value) === c.value ? '2px solid var(--text-primary)' : '2px solid transparent',
|
||||
outlineOffset: 1, transition: 'transform 0.1s',
|
||||
@@ -102,7 +107,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
{/* Category name — editable */}
|
||||
<EditableCatName name={cat} onRename={(newName) => handleRenameCategory(cat, newName)} />
|
||||
{/* Delete */}
|
||||
<button onClick={() => handleRemoveCategory(cat)} style={{
|
||||
<button type="button" onClick={() => handleRemoveCategory(cat)} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 3, display: 'flex',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
@@ -121,7 +126,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
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',
|
||||
}} />
|
||||
<button onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
|
||||
<button type="button" onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
|
||||
background: newCatName.trim() ? 'var(--accent)' : 'var(--border-primary)', color: 'var(--accent-text)',
|
||||
border: 'none', borderRadius: 10, padding: '8px 14px', cursor: newCatName.trim() ? 'pointer' : 'default',
|
||||
display: 'flex', alignItems: 'center', flexShrink: 0,
|
||||
@@ -131,7 +136,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<button onClick={handleSave} style={{
|
||||
<button type="button" onClick={handleSave} style={{
|
||||
width: '100%', borderRadius: 99, padding: '9px 14px', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
|
||||
}}>
|
||||
|
||||
@@ -25,10 +25,10 @@ export function EditableCatName({ name, onRename }: EditableCatNameProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={() => { setValue(name); setEditing(true) }}
|
||||
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
|
||||
<button type="button" onClick={() => { setValue(name); setEditing(true) }}
|
||||
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0', background: 'none', border: 'none', textAlign: 'left', fontFamily: 'inherit' }}
|
||||
title="Click to rename">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,10 +28,10 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
const openInNewTab = () => openFile(rawUrl).catch(() => {})
|
||||
|
||||
return createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} role="presentation" onClick={onClose}>
|
||||
{isImage ? (
|
||||
/* Image lightbox — floating controls */
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
{authUrl
|
||||
? <img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||
: <Loader2 size={32} className="animate-spin text-[rgba(255,255,255,0.5)]" />
|
||||
@@ -39,30 +39,30 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
|
||||
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
|
||||
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Document viewer — card with header */
|
||||
<div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
|
||||
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
|
||||
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{(isPdf || isTxt) ? (
|
||||
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download</button>
|
||||
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download</button>
|
||||
</p>
|
||||
</object>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download {file.original_name}</button>
|
||||
<button type="button" onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 'calc(14px * var(--fs-scale-body, 1))', padding: 0 }}>Download {file.original_name}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -84,6 +84,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor
|
||||
}}
|
||||
>
|
||||
<form
|
||||
role="presentation"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
borderRadius: 16,
|
||||
|
||||
Binary file not shown.
@@ -170,7 +170,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
{tabs.map(tab => {
|
||||
const active = mobileTab === tab.id
|
||||
return (
|
||||
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
<button type="button" key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
|
||||
import { render, screen, waitFor, fireEvent, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { http, HttpResponse, delay } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
@@ -55,7 +55,7 @@ beforeEach(() => {
|
||||
describe('CollabPolls', () => {
|
||||
it('FE-COMP-POLLS-001: renders empty state when no polls exist', async () => {
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
expect(await screen.findByText(/no polls yet|collab\.polls\.empty/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-002: shows loading spinner initially', async () => {
|
||||
@@ -79,7 +79,7 @@ describe('CollabPolls', () => {
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Best destination?');
|
||||
expect(await screen.findByText('Best destination?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-004: renders poll options', async () => {
|
||||
@@ -108,7 +108,7 @@ describe('CollabPolls', () => {
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||
// Modal has a question placeholder input
|
||||
await screen.findByPlaceholderText(/what should we do/i);
|
||||
expect(await screen.findByPlaceholderText(/what should we do/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-007: create modal requires question and at least 2 options to enable submit', async () => {
|
||||
@@ -153,7 +153,7 @@ describe('CollabPolls', () => {
|
||||
await user.type(optionInputs[1], 'Japanese');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
|
||||
await screen.findByText('Where to eat?');
|
||||
expect(await screen.findByText('Where to eat?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-009: voting on an option calls POST vote API', async () => {
|
||||
@@ -188,7 +188,7 @@ describe('CollabPolls', () => {
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/closed/i);
|
||||
expect(await screen.findByText(/closed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-011: closed poll options are disabled (cannot vote)', async () => {
|
||||
@@ -234,9 +234,9 @@ describe('CollabPolls', () => {
|
||||
|
||||
// Get the WS listener that was registered
|
||||
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
listener({ type: 'collab:poll:created', poll: buildPoll({ id: 77, question: 'Live poll?' }) });
|
||||
listener({ tripId: 1, type: 'collab:poll:created', poll: buildPoll({ id: 77, question: 'Live poll?' }) });
|
||||
|
||||
await screen.findByText('Live poll?');
|
||||
expect(await screen.findByText('Live poll?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-014: WebSocket collab:poll:deleted event removes poll', async () => {
|
||||
@@ -249,7 +249,7 @@ describe('CollabPolls', () => {
|
||||
await screen.findByText('Best destination?');
|
||||
|
||||
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
listener({ type: 'collab:poll:deleted', pollId: 3 });
|
||||
listener({ tripId: 1, type: 'collab:poll:deleted', pollId: 3 });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
|
||||
@@ -337,7 +337,7 @@ describe('CollabPolls details', () => {
|
||||
it('FE-W5CPL-005: a poll served as a bare array is rendered', async () => {
|
||||
servePolls([buildPoll({ question: 'Array shaped?' })]);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Array shaped?');
|
||||
expect(await screen.findByText('Array shaped?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-006: options without a voters array count as zero votes', async () => {
|
||||
@@ -484,13 +484,13 @@ describe('CollabPolls details', () => {
|
||||
http.get('/api/trips/1/collab/polls', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
expect(await screen.findByText(/no polls yet|collab\.polls\.empty/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-028: a payload without a polls key yields an empty list', async () => {
|
||||
servePolls({});
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
expect(await screen.findByText(/no polls yet|collab\.polls\.empty/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-015: a failing close shows an error and leaves the poll open', async () => {
|
||||
@@ -658,8 +658,8 @@ describe('CollabPolls details', () => {
|
||||
const handler = wsHandler();
|
||||
await act(async () => {
|
||||
handler({});
|
||||
handler({ type: 'collab:poll:deleted' });
|
||||
handler({ type: 'collab:poll:created', poll: { id: 40, question: 'Best destination?' } });
|
||||
handler({ tripId: 1, type: 'collab:poll:deleted' });
|
||||
handler({ tripId: 1, type: 'collab:poll:created', poll: { id: 40, question: 'Best destination?' } });
|
||||
});
|
||||
expect(screen.getAllByText('Best destination?')).toHaveLength(1);
|
||||
});
|
||||
@@ -671,22 +671,52 @@ describe('CollabPolls details', () => {
|
||||
const handler = wsHandler();
|
||||
|
||||
await act(async () => {
|
||||
handler({ type: 'collab:poll:voted', poll: buildPoll({ id: 41, question: 'Voted live' }) });
|
||||
handler({ tripId: 1, type: 'collab:poll:voted', poll: buildPoll({ id: 41, question: 'Voted live' }) });
|
||||
});
|
||||
expect(await screen.findByText('Voted live')).toBeInTheDocument();
|
||||
expect(screen.getByText('Other poll')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
handler({ type: 'collab:poll:closed', poll: { id: 41 } });
|
||||
handler({ tripId: 1, type: 'collab:poll:closed', poll: { id: 41 } });
|
||||
});
|
||||
await waitFor(() => expect(screen.getAllByText('Closed')).toHaveLength(2));
|
||||
|
||||
await act(async () => {
|
||||
handler({ type: 'collab:poll:deleted', poll: { id: 42 } });
|
||||
handler({ tripId: 1, type: 'collab:poll:deleted', poll: { id: 42 } });
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByText('Other poll')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-W5CPL-027: a WebSocket event for another trip is ignored', async () => {
|
||||
servePolls({ polls: [] });
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab.polls.empty/i);
|
||||
const handler = wsHandler();
|
||||
await act(async () => {
|
||||
handler({ tripId: 2, type: 'collab:poll:created', poll: buildPoll({ id: 88, question: 'Other trip poll?' }) });
|
||||
});
|
||||
expect(screen.queryByText('Other trip poll?')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-028: a slow load for the trip we left does not overwrite the new one', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', async () => {
|
||||
await delay(80);
|
||||
return HttpResponse.json({ polls: [buildPoll({ id: 1, question: 'Trip one poll?' })] });
|
||||
}),
|
||||
http.get('/api/trips/2/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ id: 2, question: 'Trip two poll?' })] }),
|
||||
),
|
||||
);
|
||||
const { rerender } = render(<CollabPolls {...defaultProps} />);
|
||||
rerender(<CollabPolls tripId={2} currentUser={currentUser} />);
|
||||
|
||||
await screen.findByText('Trip two poll?');
|
||||
await act(async () => { await delay(150); });
|
||||
expect(screen.queryByText('Trip one poll?')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Trip two poll?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-026: a poll with a live deadline starts the countdown ticker', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
@@ -702,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()
|
||||
@@ -88,8 +120,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={onClose}>
|
||||
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onClick={e => e.stopPropagation()} onSubmit={handleSubmit}>
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onSubmit={handleSubmit}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3>
|
||||
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
|
||||
@@ -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>
|
||||
)}
|
||||
@@ -122,13 +155,13 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
|
||||
{/* Multi choice toggle */}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<div onClick={() => setMultiChoice(!multiChoice)} style={{
|
||||
width: 36, height: 20, borderRadius: 10, padding: 2, cursor: 'pointer',
|
||||
<button type="button" role="switch" aria-checked={multiChoice} onClick={() => setMultiChoice(!multiChoice)} style={{
|
||||
width: 36, height: 20, borderRadius: 10, padding: 2, cursor: 'pointer', border: 'none',
|
||||
background: multiChoice ? '#007AFF' : 'var(--border-primary)', transition: 'background 0.2s',
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}>
|
||||
<div style={{ width: 16, height: 16, borderRadius: '50%', background: '#fff', transition: 'transform 0.2s', transform: multiChoice ? 'translateX(16px)' : 'translateX(0)' }} />
|
||||
</div>
|
||||
</button>
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
|
||||
</label>
|
||||
|
||||
@@ -174,7 +207,7 @@ function VoterChip({ voter, offset }: VoterChipProps) {
|
||||
fontSize: 'calc(7px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
|
||||
border: '1.5px solid var(--bg-card)', marginLeft: offset ? -5 : 0, flexShrink: 0,
|
||||
}}>
|
||||
{voter.avatar_url ? <img src={voter.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()}
|
||||
{voter.avatar_url ? <img src={voter.avatar_url} alt={voter.username || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
{hover && createPortal(
|
||||
<div style={{
|
||||
@@ -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 && (
|
||||
@@ -250,14 +285,14 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{!isClosed && (
|
||||
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
||||
<button type="button" onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Lock size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
|
||||
<button type="button" onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
@@ -278,10 +313,10 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
return (
|
||||
// React dispatches no mouse events on a disabled control, so the
|
||||
// handlers below need no isClosed guard of their own.
|
||||
<button key={idx} onClick={() => onVote(poll.id, idx)}
|
||||
<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}
|
||||
@@ -359,15 +397,25 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
collabApi.getPolls(tripId).then(data => {
|
||||
setPolls(Array.isArray(data) ? data : data.polls || [])
|
||||
}).catch(() => {}).finally(() => setLoading(false))
|
||||
if (!cancelled) setPolls(Array.isArray(data) ? data : data.polls || [])
|
||||
}).catch(() => {
|
||||
if (!cancelled) setPolls([])
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [tripId])
|
||||
|
||||
// WebSocket
|
||||
useEffect(() => {
|
||||
const handler = (msg) => {
|
||||
if (!msg?.type) return
|
||||
// The panel is not remounted on a trip change, so an event still in flight
|
||||
// from the trip we just left must not land in this list.
|
||||
if (String(msg.tripId) !== String(tripId)) return
|
||||
if (msg.type === 'collab:poll:created' && msg.poll) {
|
||||
setPolls(prev => prev.some(p => p.id === msg.poll.id) ? prev : [msg.poll, ...prev])
|
||||
}
|
||||
@@ -384,7 +432,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [])
|
||||
}, [tripId])
|
||||
|
||||
const handleCreate = useCallback(async (data) => {
|
||||
try {
|
||||
@@ -455,7 +503,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
{t('collab.polls.title')}
|
||||
</h3>
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowForm(true)} style={{
|
||||
<button type="button" onClick={() => setShowForm(true)} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||
|
||||
@@ -3,21 +3,25 @@ import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import WhatsNextWidget from './WhatsNextWidget'
|
||||
import { afterEach, beforeEach, describe, it, expect } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// Dynamic date helpers
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
// Dynamic date helpers, in local calendar dates like the widget itself uses
|
||||
function localDate(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const today = localDate(new Date())
|
||||
|
||||
function getFutureDate(daysAhead: number): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + daysAhead)
|
||||
return d.toISOString().split('T')[0]
|
||||
return localDate(d)
|
||||
}
|
||||
|
||||
function getPastDate(daysBack: number): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - daysBack)
|
||||
return d.toISOString().split('T')[0]
|
||||
return localDate(d)
|
||||
}
|
||||
|
||||
const tomorrow = getFutureDate(1)
|
||||
@@ -258,6 +262,29 @@ describe('WhatsNextWidget', () => {
|
||||
expect(screen.getByText('Lunch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-016: keeps remaining events for the local day west of UTC after the UTC date rolls over', () => {
|
||||
const prevTz = process.env.TZ
|
||||
process.env.TZ = 'America/New_York'
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
try {
|
||||
// 20:00 in New York, already the next calendar day in UTC
|
||||
vi.setSystemTime(new Date('2026-08-24T00:00:00Z'))
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: '2026-08-23', title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(80, { name: 'Late Show', place_time: '21:00' })],
|
||||
},
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(screen.getByText('Late Show')).toBeInTheDocument()
|
||||
expect(screen.getByText(/today/i)).toBeInTheDocument()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
if (prevTz === undefined) delete process.env.TZ
|
||||
else process.env.TZ = prevTz
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-015: today past-time event is excluded', () => {
|
||||
// If it's not midnight, a past-time event today should not appear
|
||||
const now = new Date()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MapPin, Clock, Users, Sparkles } from 'lucide-react'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { localToday } from '../Planner/today'
|
||||
|
||||
function formatTime(timeStr, is12h) {
|
||||
if (!timeStr) return ''
|
||||
@@ -19,9 +20,10 @@ function formatTime(timeStr, is12h) {
|
||||
|
||||
function formatDayLabel(date, t, locale) {
|
||||
const now = new Date()
|
||||
const nowDate = now.toISOString().split('T')[0]
|
||||
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
|
||||
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0]
|
||||
// Day dates are plain calendar strings, so "today"/"tomorrow" have to be
|
||||
// compared against the local calendar day, not the UTC one.
|
||||
const nowDate = localToday(now)
|
||||
const tomorrowDate = localToday(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1))
|
||||
|
||||
if (date === nowDate) return t('collab.whatsNext.today') || 'Today'
|
||||
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
@@ -47,7 +49,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
|
||||
const upcoming = useMemo(() => {
|
||||
const now = new Date()
|
||||
const nowDate = now.toISOString().split('T')[0]
|
||||
const nowDate = localToday(now)
|
||||
const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||
const items = []
|
||||
|
||||
@@ -177,7 +179,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{p.avatar
|
||||
? <img src={avatarSrc(p.avatar)!} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
? <img src={avatarSrc(p.avatar)!} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: p.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -97,8 +97,8 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
const cleanName = name.trim()
|
||||
if (!cleanName) return
|
||||
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
|
||||
const latNum = lat.trim() ? Number(lat) : NaN
|
||||
const lngNum = lng.trim() ? Number(lng) : NaN
|
||||
const latNum = lat.trim() ? Number(lat) : Number.NaN
|
||||
const lngNum = lng.trim() ? Number(lng) : Number.NaN
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await collectionsApi.savePlace({
|
||||
@@ -135,7 +135,10 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
|
||||
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
||||
// Same pairs as before, written so no two quantifiers can claim the same
|
||||
// character. In the old form `\d+\.?\d*` and `\s*[,;\s]\s*` were both ambiguous,
|
||||
// which backtracks in O(n^4): a pasted 2 kB of digits and spaces froze the tab.
|
||||
const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/)
|
||||
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
|
||||
}
|
||||
const coordInputClass = 'w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent'
|
||||
|
||||
@@ -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) } }}
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from 'react'
|
||||
import { MapViewAuto } from '../Map/MapViewAuto'
|
||||
import type { CollectionPlace } from '@trek/shared'
|
||||
import { mappablePlaces } from '../../pages/collections/collectionsModel'
|
||||
import { useTileUrl } from '../../hooks/useTileUrl'
|
||||
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
|
||||
|
||||
interface CollectionMapProps {
|
||||
places: CollectionPlace[]
|
||||
@@ -20,9 +22,7 @@ interface CollectionMapProps {
|
||||
*/
|
||||
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark }: CollectionMapProps): React.ReactElement {
|
||||
const pts = mappablePlaces(places)
|
||||
const tileUrl = dark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON)
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
|
||||
@@ -152,15 +152,15 @@ export default function CollectionPlaceDetail({
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []); setAddress(place.address ?? ''); setLat(place.lat != null ? String(place.lat) : ''); setLng(place.lng != null ? String(place.lng) : '') }
|
||||
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
||||
const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/)
|
||||
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
|
||||
}
|
||||
const assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id))
|
||||
|
||||
const save = async () => {
|
||||
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
|
||||
const latNum = lat.trim() ? Number(lat) : NaN
|
||||
const lngNum = lng.trim() ? Number(lng) : NaN
|
||||
const latNum = lat.trim() ? Number(lat) : Number.NaN
|
||||
const lngNum = lng.trim() ? Number(lng) : Number.NaN
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds, address: address.trim() || null, lat: Number.isFinite(latNum) ? latNum : null, lng: Number.isFinite(lngNum) ? lngNum : null })
|
||||
@@ -176,7 +176,7 @@ export default function CollectionPlaceDetail({
|
||||
const CatIcon = getCategoryIcon(place.category?.icon)
|
||||
|
||||
return (
|
||||
<div className={`col-detail${anchorRect ? ' docked' : ''}`} style={dockStyle} onClick={e => e.stopPropagation()}>
|
||||
<div className={`col-detail${anchorRect ? ' docked' : ''}`} style={dockStyle} role="presentation" onClick={e => e.stopPropagation()}>
|
||||
<div className="col-detail-cover" style={banner ? undefined : { backgroundImage: entityGradient(place.id) }}>
|
||||
{banner && <img src={banner} alt="" />}
|
||||
<div className="col-detail-cover-scrim" />
|
||||
|
||||
@@ -172,6 +172,21 @@ describe('ListEditorModal', () => {
|
||||
expect(actions.createCollection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-026: a second Enter while the create is in flight does not make a second list', async () => {
|
||||
let release: (value: { id: number; owner_id: number; name: string }) => void = () => {}
|
||||
actions.createCollection.mockReturnValueOnce(new Promise(resolve => { release = resolve }))
|
||||
const { props } = setup()
|
||||
const nameInput = screen.getByPlaceholderText('e.g. Tokyo 2025')
|
||||
fireEvent.change(nameInput, { target: { value: 'Lisbon' } })
|
||||
fireEvent.keyDown(nameInput, { key: 'Enter' })
|
||||
await waitFor(() => expect(actions.createCollection).toHaveBeenCalledTimes(1))
|
||||
fireEvent.keyDown(nameInput, { key: 'Enter' })
|
||||
release({ id: 77, owner_id: 1, name: 'Lisbon' })
|
||||
await waitFor(() => expect(props.onClose).toHaveBeenCalled())
|
||||
expect(actions.createCollection).toHaveBeenCalledTimes(1)
|
||||
expect(props.onCreated).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-011: picking a swatch changes the saved colour', async () => {
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
|
||||
|
||||
@@ -122,7 +122,9 @@ export default function ListEditorModal({ target, onClose, onCreated, onRequestD
|
||||
|
||||
const save = async () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
// Enter fires while the create is still in flight, and `createdId` is only
|
||||
// set once it comes back — without this the second one creates a second list.
|
||||
if (!trimmed || saving) return
|
||||
// Normalise + keep only links with a url; drop blank rows.
|
||||
const cleanLinks = links
|
||||
.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) }))
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function StatusBadge({ status, onChange, showLabel = true, size =
|
||||
return <span className={className} title={label}>{content}</span>
|
||||
}
|
||||
|
||||
// Rendered as a role=button span (not a native <button>) on purpose: inside
|
||||
// Rendered as a role=button span (not a native <button type="button">) on purpose: inside
|
||||
// the .trek-dash scope the global `.trek-dash button` reset would strip the
|
||||
// pill's background/border/padding, and the pill also has to sit inside the
|
||||
// grid card's own clickable element without nesting one button in another.
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
|
||||
<div className="tool">
|
||||
<div className="tool-head">
|
||||
<div className="tool-title"><Bookmark size={14} /> {t('collections.widget.title')}</div>
|
||||
<button className="tool-action" aria-label={t('collections.widget.title')} onClick={onOpen}>
|
||||
<button type="button" className="tool-action" aria-label={t('collections.widget.title')} onClick={onOpen}>
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -47,7 +47,7 @@ export default function CollectionsWidget({ onOpen }: { onOpen: () => void }): R
|
||||
) : (
|
||||
<div className="col-badges">
|
||||
{lists.slice(0, 6).map(list => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={list.id}
|
||||
className="col-badge"
|
||||
style={{ ['--badge-color' as string]: list.color || '#6366f1' }}
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('FileManager', () => {
|
||||
await user.click(trashBtn);
|
||||
|
||||
// Trashed file should appear
|
||||
await screen.findByText('old.pdf');
|
||||
expect(await screen.findByText('old.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-007: restore button calls filesApi.restore', async () => {
|
||||
@@ -407,7 +407,7 @@ describe('FileManager', () => {
|
||||
const assignBtn = screen.getByTitle(/assign/i);
|
||||
await user.click(assignBtn);
|
||||
|
||||
await screen.findByText('Eiffel Tower');
|
||||
expect(await screen.findByText('Eiffel Tower')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-021: file description is shown when present', () => {
|
||||
@@ -445,7 +445,7 @@ describe('FileManager', () => {
|
||||
const assignBtn = screen.getByTitle(/assign/i);
|
||||
await user.click(assignBtn);
|
||||
|
||||
await screen.findByText('Hotel Paris');
|
||||
expect(await screen.findByText('Hotel Paris')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls filesApi.update', async () => {
|
||||
@@ -492,7 +492,7 @@ describe('FileManager', () => {
|
||||
|
||||
await user.click(screen.getByTitle(/assign/i));
|
||||
await screen.findByText('Notre Dame');
|
||||
await screen.findByText('Airbnb');
|
||||
expect(await screen.findByText('Airbnb')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-027: paste event uploads file when user can upload', async () => {
|
||||
@@ -543,7 +543,7 @@ describe('FileManager', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTitle(/assign/i));
|
||||
await screen.findByText('Arc de Triomphe');
|
||||
expect(await screen.findByText('Arc de Triomphe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-030: file with linked place shows source badge', async () => {
|
||||
@@ -554,7 +554,7 @@ describe('FileManager', () => {
|
||||
render(<FileManager {...defaultProps} files={[file]} places={[place]} />);
|
||||
|
||||
// Source badge text includes place name
|
||||
await screen.findByText(/Colosseum/);
|
||||
expect(await screen.findByText(/Colosseum/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls filesApi.update', async () => {
|
||||
|
||||
@@ -9,9 +9,9 @@ import { transportIcon } from './FileManager.helpers'
|
||||
export function AssignModal(S: FileManagerState) {
|
||||
const { files, assignFileId, setAssignFileId, t, days, assignments, places, reservations, tripId, handleAssign, refreshFiles } = S
|
||||
return createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => setAssignFileId(null)}>
|
||||
<div style={{
|
||||
<div role="presentation" style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||
width: 'min(600px, calc(100vw - 32px))', maxHeight: '70vh', overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
@@ -22,7 +22,7 @@ export function AssignModal(S: FileManagerState) {
|
||||
{files.find(f => f.id === assignFileId)?.original_name || ''}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setAssignFileId(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4, display: 'flex', flexShrink: 0 }}>
|
||||
<button type="button" onClick={() => setAssignFileId(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4, display: 'flex', flexShrink: 0 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -67,7 +67,7 @@ export function AssignModal(S: FileManagerState) {
|
||||
const placeBtn = (p: Place, idx: number) => {
|
||||
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
|
||||
return (
|
||||
<button key={`${p.id}-${idx}`} onClick={async () => {
|
||||
<button type="button" key={`${p.id}-${idx}`} onClick={async () => {
|
||||
if (isLinked) {
|
||||
if (file.place_id === p.id) {
|
||||
await handleAssign(file.id, { place_id: null })
|
||||
@@ -123,13 +123,13 @@ export function AssignModal(S: FileManagerState) {
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
{dayPlaces.map(placeBtn)}
|
||||
{dayPlaces.map((p, i) => placeBtn(p, i))}
|
||||
</div>
|
||||
))}
|
||||
{unassigned.length > 0 && (
|
||||
<div>
|
||||
{dayGroups.length > 0 && <div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</div>}
|
||||
{unassigned.map(placeBtn)}
|
||||
{unassigned.map((p, i) => placeBtn(p, i))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,7 +142,7 @@ export function AssignModal(S: FileManagerState) {
|
||||
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
||||
const Icon = TRANSPORT_TYPES.has(r.type) ? transportIcon(r.type) : Ticket
|
||||
return (
|
||||
<button key={r.id} onClick={async () => {
|
||||
<button type="button" key={r.id} onClick={async () => {
|
||||
if (isLinked) {
|
||||
if (file.reservation_id === r.id) {
|
||||
await handleAssign(file.id, { reservation_id: null })
|
||||
|
||||
@@ -24,7 +24,7 @@ export function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avata
|
||||
cursor: 'default',
|
||||
}}>
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
? <img src={avatarUrl} alt={name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: name?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user