mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77dddd5e8a | |||
| e60427f813 | |||
| 94736112cf |
@@ -15,6 +15,8 @@ uploads
|
||||
chart/
|
||||
docs/
|
||||
docker-compose.yml
|
||||
docker-compose.minio-test.yml
|
||||
minio-licence.key
|
||||
unraid-template.xml
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*.sh text eol=lf
|
||||
# Binary files — no line ending conversion
|
||||
*.png binary
|
||||
*.webp binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
# Commit and tag
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json server/package.json client/package.json shared/package.json nest-mcp/package.json charts/trek/Chart.yaml
|
||||
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
|
||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
|
||||
git push origin main --follow-tags
|
||||
|
||||
@@ -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
|
||||
|
||||
+356
-109
@@ -1,40 +1,112 @@
|
||||
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:
|
||||
- 'server/**'
|
||||
- 'client/**'
|
||||
- 'shared/**'
|
||||
- 'nest-mcp/**'
|
||||
- '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
|
||||
|
||||
@@ -52,67 +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
|
||||
|
||||
nest-mcp-package:
|
||||
name: nest-mcp Package
|
||||
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 nest-mcp
|
||||
|
||||
- name: Ensure @swc/core's Linux binary for unplugin-swc
|
||||
# Same lockfile quirk as server-tests: the Linux native binary is
|
||||
# omitted, and nest-mcp's vitest config uses the SWC transform too.
|
||||
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
|
||||
run: npm run build --workspace=nest-mcp
|
||||
|
||||
- name: Typecheck
|
||||
run: cd nest-mcp && npm run typecheck
|
||||
|
||||
- name: Lint
|
||||
run: cd nest-mcp && npm run lint:check
|
||||
|
||||
- name: Run tests
|
||||
run: cd nest-mcp && npm test
|
||||
|
||||
server-tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -125,31 +139,23 @@ 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: Build nest-mcp
|
||||
# Server typecheck/build resolve @trek/nest-mcp's types from its dist
|
||||
# (tests alias the package source, but tsc does not).
|
||||
run: npm run build --workspace=nest-mcp
|
||||
- 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
|
||||
|
||||
- name: Smoke production require chain
|
||||
# Vitest aliases @trek/nest-mcp to its source, so only this exercises
|
||||
# what production runs: nest-mcp's built dist resolving the MCP SDK's
|
||||
# subpath exports through the tsconfig-paths/register runtime hook.
|
||||
run: cd server && node --require tsconfig-paths/register -e "require('@trek/nest-mcp')"
|
||||
# Vitest aliases the MCP SDK deep imports, so only this exercises what
|
||||
# production runs: the built dist resolving the MCP SDK's subpath
|
||||
# exports through the tsconfig-paths/register runtime hook.
|
||||
run: cd server && node --require tsconfig-paths/register -e "require('./dist/nest-mcp')"
|
||||
|
||||
- name: Typecheck
|
||||
run: cd server && npm run typecheck
|
||||
@@ -157,29 +163,15 @@ 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
|
||||
|
||||
- name: Upload coverage
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: backend-coverage
|
||||
path: server/coverage/
|
||||
retention-days: 7
|
||||
|
||||
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
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -207,9 +199,37 @@ jobs:
|
||||
- name: Page pattern check
|
||||
run: cd client && npm run lint:pages
|
||||
|
||||
client-tests:
|
||||
name: Client Tests
|
||||
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
|
||||
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
|
||||
@@ -220,19 +240,246 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- 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: Write MinIO license
|
||||
run: printf '%s' "${{ secrets.MINIO_LICENSE_KEY }}" > minio-licence.key
|
||||
|
||||
- name: Start MinIO
|
||||
# `services:` can't pass the `server /data --license ...` command to
|
||||
# the AIStor image, so it starts via docker run. 127.0.0.1 endpoint is
|
||||
# load-bearing: aws-lite picks path-style only for IP/localhost hosts,
|
||||
# which is all stock MinIO/AIStor speaks.
|
||||
run: |
|
||||
docker run -d --name minio -p 127.0.0.1:9000:9000 \
|
||||
-v "$PWD/minio-licence.key:/minio.license:ro" \
|
||||
-e MINIO_ROOT_USER=trekci -e MINIO_ROOT_PASSWORD=trekci-secret \
|
||||
quay.io/minio/aistor/minio:RELEASE.2026-08-07T18-34-35Z server /data --license /minio.license
|
||||
|
||||
- name: Wait for MinIO
|
||||
# curl was removed from the AIStor image — poll from the runner instead.
|
||||
# 60 x 2s gives a wider margin on cold runners than the 30 x 1s the
|
||||
# pre-6fbff835 job used.
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
curl -sf http://127.0.0.1:9000/minio/health/live && exit 0
|
||||
sleep 2
|
||||
done
|
||||
echo "MinIO never became healthy" >&2
|
||||
docker logs minio >&2
|
||||
exit 1
|
||||
|
||||
- name: Run the S3 driver contract suite
|
||||
# No --coverage: the src/nest/storage/** 94/84/97/94 pin must keep
|
||||
# holding from hermetic tests alone. The suite's beforeAll creates
|
||||
# the trek-test bucket idempotently — no separate bucket-init step.
|
||||
env:
|
||||
TREK_TEST_S3_ENDPOINT: http://127.0.0.1:9000
|
||||
TREK_TEST_S3_BUCKET: trek-test
|
||||
TREK_TEST_S3_ACCESS_KEY_ID: trekci
|
||||
TREK_TEST_S3_SECRET_ACCESS_KEY: trekci-secret
|
||||
run: cd server && npx vitest run tests/unit/nest/storage/s3.driver.contract.test.ts
|
||||
|
||||
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
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- 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: Run tests with coverage
|
||||
run: cd server && npm run test:coverage
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: sonar-server-coverage
|
||||
path: server/coverage/
|
||||
retention-days: 1
|
||||
|
||||
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
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
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
|
||||
|
||||
@@ -65,5 +65,8 @@ coverage-*/
|
||||
.scannerwork
|
||||
test-data
|
||||
|
||||
# Live-MinIO/AIStor manual test license key — never commit, operators obtain their own free key
|
||||
minio-licence.key
|
||||
|
||||
.run
|
||||
.full-review
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -31,12 +31,9 @@ FROM node:24-alpine AS server-builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY nest-mcp/package.json ./nest-mcp/
|
||||
COPY server/package.json ./server/
|
||||
RUN npm ci --workspace=server --ignore-scripts
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY nest-mcp/ ./nest-mcp/
|
||||
RUN npm run build --workspace=nest-mcp
|
||||
COPY server/ ./server/
|
||||
RUN npm run build --workspace=server
|
||||
|
||||
@@ -47,7 +44,6 @@ WORKDIR /app
|
||||
# Workspace manifests only — source never enters this stage.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY nest-mcp/package.json ./nest-mcp/
|
||||
COPY server/package.json ./server/
|
||||
|
||||
# The trailing chown runs in this layer on purpose: it covers the manifests and
|
||||
@@ -94,10 +90,6 @@ COPY --chown=node:node server/scripts/migrate-encryption.ts ./server/scripts/mig
|
||||
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
|
||||
COPY --chown=node:node server/reset-admin.js ./server/reset-admin.js
|
||||
COPY --chown=node:node --from=shared-builder /app/shared/dist ./shared/dist
|
||||
# server dist requires @trek/nest-mcp at runtime through the workspace symlink;
|
||||
# its dist's MCP SDK subpath requires ride the same tsconfig-paths/register
|
||||
# hook the server already boots with.
|
||||
COPY --chown=node:node --from=server-builder /app/nest-mcp/dist ./nest-mcp/dist
|
||||
COPY --chown=node:node --from=client-builder /app/client/dist ./server/public
|
||||
COPY --chown=node:node --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
|
||||
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.svg" />
|
||||
<img src="docs/logo-trek-dark.svg" alt="TREK" height="96" />
|
||||
</picture>
|
||||
|
||||
<br />
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
|
||||
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
|
||||
<img src="docs/subtitle-dark.png" alt="your trip. your plan." height="28" />
|
||||
</picture>
|
||||
|
||||
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
||||
@@ -22,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>
|
||||
@@ -34,68 +37,68 @@ 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>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/liketrek/TREK-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
|
||||
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.webp" alt="TREK, a tour of the app" width="100%" />
|
||||
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner · day plan & route" width="49%" /></a>
|
||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
|
||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||
<a href="docs/screenshots/collections.png"><img src="docs/screenshots/collections.png" alt="Collections · saved place lists" width="49%" /></a>
|
||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## What you get
|
||||
|
||||
<picture>
|
||||
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
|
||||
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/tiles/grid-desktop-dark.svg" />
|
||||
<img src="docs/tiles/grid-desktop.svg" alt="Plan, track, share" width="100%" />
|
||||
</picture>
|
||||
|
||||
<details>
|
||||
<summary><b>See all features</b></summary>
|
||||
|
||||
<br />
|
||||
|
||||
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧭 Trip planning
|
||||
#### 🧭 Planning
|
||||
|
||||
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
|
||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||
- **Category filter** — show only matching pins on the map
|
||||
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
|
||||
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
|
||||
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
|
||||
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
|
||||
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
|
||||
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
|
||||
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
|
||||
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
|
||||
- **Public transport**: door-to-door itineraries over Transitous
|
||||
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
|
||||
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
|
||||
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧳 Travel management
|
||||
#### 🧳 Bookings and money
|
||||
|
||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
||||
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
|
||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||
- **PDF export** — full trip plan as PDF with cover page, images, notes
|
||||
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
|
||||
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
|
||||
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
|
||||
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
|
||||
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
|
||||
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
|
||||
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
|
||||
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
|
||||
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
|
||||
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
|
||||
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -104,64 +107,71 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
#### 👥 Collaboration
|
||||
|
||||
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
|
||||
- **Multi-user trips** — invite members with role-based access
|
||||
- **Invite links** — one-time or reusable links with expiry
|
||||
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **2FA** — TOTP + backup codes
|
||||
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
|
||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
|
||||
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
|
||||
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
|
||||
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
|
||||
- **Public share**: a read-only trip page anyone can open without an account
|
||||
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
|
||||
|
||||
#### 📔 Journal, Atlas and Vacay
|
||||
|
||||
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
|
||||
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
|
||||
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
|
||||
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 📱 Mobile & PWA
|
||||
#### 🧩 Plugins
|
||||
|
||||
- **Installable** — iOS and Android, straight from the browser, no App Store needed
|
||||
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
|
||||
- **Native feel** — fullscreen standalone, themed status bar, splash screen
|
||||
- **Touch optimised** — mobile-specific layouts with safe-area handling
|
||||
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
|
||||
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
|
||||
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
|
||||
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
|
||||
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
|
||||
|
||||
#### 🤖 AI and MCP
|
||||
|
||||
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
|
||||
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
|
||||
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
|
||||
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
|
||||
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
|
||||
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧩 Addons (admin-toggleable)
|
||||
#### 📱 Mobile and offline
|
||||
|
||||
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
|
||||
- **Documents** — file attachments on trips, places, and reservations
|
||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, approved school holiday overlays, carry-over tracking
|
||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
|
||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
|
||||
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
|
||||
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
|
||||
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
|
||||
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
|
||||
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🤖 AI / MCP
|
||||
#### ⚙️ Admin, accounts and security
|
||||
|
||||
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
|
||||
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
|
||||
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
|
||||
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
||||
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
|
||||
#### ⚙️ Admin & customisation
|
||||
|
||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
|
||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
|
||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
|
||||
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
|
||||
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
|
||||
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
|
||||
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
|
||||
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
|
||||
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
|
||||
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
|
||||
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook and ntfy, plus any channel a plugin registers
|
||||
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
|
||||
- **23 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca
|
||||
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -171,6 +181,14 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<br />
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/showcase-desktop-1.webp"><img src="docs/screenshots/showcase-desktop-1.webp" alt="Atlas · dashboard · trip planner" width="100%" /></a>
|
||||
<a href="docs/screenshots/showcase-mobile.webp"><img src="docs/screenshots/showcase-mobile.webp" alt="Mobile PWA · dashboard, day plan, map, costs" width="100%" /></a>
|
||||
<a href="docs/screenshots/showcase-desktop-2.webp"><img src="docs/screenshots/showcase-desktop-2.webp" alt="Collections · journey journal · costs" width="100%" /></a>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
## AI usage
|
||||
|
||||
We use LLM-assisted coding tools across parts of this codebase. Nothing ships that a
|
||||
@@ -195,7 +213,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
|
||||
<div align="center">
|
||||
|
||||
· <a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a> ·
|
||||
<a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -223,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 />
|
||||
|
||||
@@ -294,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.
|
||||
@@ -308,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>
|
||||
|
||||
@@ -417,69 +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** | | |
|
||||
| `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 />
|
||||
|
||||
## Data & Backups
|
||||
|
||||
- **Database** — SQLite, stored in `./data/travel.db`
|
||||
- **Uploads** — stored in `./uploads/`
|
||||
- **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
|
||||
|
||||
@@ -488,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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -29,8 +29,13 @@
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
"@fontsource/eb-garamond": "^5.3.0",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/lora": "^5.3.0",
|
||||
"@fontsource/museomoderno": "^5.3.0",
|
||||
"@fontsource/playfair-display": "^5.3.0",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Country silhouettes for TREK Studio, built from the boundaries the server
|
||||
* already bundles for Atlas.
|
||||
*
|
||||
* node scripts/build-country-shapes.mjs
|
||||
*
|
||||
* Writes `src/components/Studio/countryShapes.ts`. Run it when
|
||||
* `server/assets/atlas/admin0.geojson.gz` changes; the output is committed, so
|
||||
* a normal build never runs this.
|
||||
*
|
||||
* ── Why a second, tiny copy of the boundaries ─────────────────────────────
|
||||
*
|
||||
* Atlas's admin-0 file is 4.3MB gzipped and ~30MB parsed, sized for a slippy
|
||||
* map you can zoom into. A book page draws a country at 20mm across, where the
|
||||
* ten thousand points describing a fjord land inside one printer dot. Shipping
|
||||
* the map data to draw a stamp would cost more than the entire Studio bundle.
|
||||
*
|
||||
* So: project, drop the islands nobody can see at that size, simplify hard, and
|
||||
* normalise each country into its own 0..100 box. What comes out is a few
|
||||
* hundred bytes per country and prints identically, because at 20mm it *is*
|
||||
* identical.
|
||||
*
|
||||
* ── The three things that are easy to get wrong ───────────────────────────
|
||||
*
|
||||
* 1. **Project before simplifying.** Simplifying in degrees and projecting
|
||||
* afterwards removes the wrong points: a degree of longitude is 111km at the
|
||||
* equator and 30km at Reykjavík, so a fixed tolerance eats Iceland's coast
|
||||
* while leaving Kenya's untouched.
|
||||
* 2. **Countries that cross the antimeridian.** Russia and Fiji have parts at
|
||||
* +179 and -179. Taken literally their bounding box is the whole world and
|
||||
* the silhouette is a horizontal smear. Rings that span more than half the
|
||||
* globe get their negative longitudes lifted by 360 so the country is
|
||||
* contiguous again.
|
||||
* 3. **Islands.** Keeping every ring turns Norway into a dust cloud and Greece
|
||||
* into static. Rings below a fraction of the largest one are dropped — the
|
||||
* mainland is what makes a country recognisable at a glance.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import url from 'node:url'
|
||||
import zlib from 'node:zlib'
|
||||
|
||||
const HERE = path.dirname(url.fileURLToPath(import.meta.url))
|
||||
const SRC = path.join(HERE, '..', '..', 'server', 'assets', 'atlas', 'admin0.geojson.gz')
|
||||
const OUT = path.join(HERE, '..', 'src', 'components', 'Studio', 'countryShapes.ts')
|
||||
|
||||
/** Rings smaller than this share of the biggest one are not drawn. */
|
||||
const ISLAND_CUTOFF = 0.012
|
||||
/** Never draw more than this many rings for one country. */
|
||||
const MAX_RINGS = 14
|
||||
/** Point budget per country, across every ring it keeps. */
|
||||
const MAX_POINTS = 190
|
||||
|
||||
/** Web Mercator, unscaled. y is flipped so it grows downwards like SVG's. */
|
||||
function project([lng, lat]) {
|
||||
const clamped = Math.max(-85.05, Math.min(85.05, lat))
|
||||
const rad = (clamped * Math.PI) / 180
|
||||
return [lng, -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI]
|
||||
}
|
||||
|
||||
function ringArea(ring) {
|
||||
let a = 0
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
a += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1]
|
||||
}
|
||||
return Math.abs(a / 2)
|
||||
}
|
||||
|
||||
/** Perpendicular distance from p to the segment ab, squared. */
|
||||
function segDistSq(p, a, b) {
|
||||
let x = a[0], y = a[1]
|
||||
let dx = b[0] - x, dy = b[1] - y
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
const t = ((p[0] - x) * dx + (p[1] - y) * dy) / (dx * dx + dy * dy)
|
||||
if (t > 1) { x = b[0]; y = b[1] } else if (t > 0) { x += dx * t; y += dy * t }
|
||||
}
|
||||
dx = p[0] - x
|
||||
dy = p[1] - y
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
/** Douglas-Peucker, iterative so a long coastline cannot blow the stack. */
|
||||
function simplify(points, tolerance) {
|
||||
if (points.length < 3) return points
|
||||
const tolSq = tolerance * tolerance
|
||||
const keep = new Uint8Array(points.length)
|
||||
keep[0] = 1
|
||||
keep[points.length - 1] = 1
|
||||
const stack = [[0, points.length - 1]]
|
||||
|
||||
while (stack.length) {
|
||||
const [first, last] = stack.pop()
|
||||
let maxSq = 0
|
||||
let index = 0
|
||||
for (let i = first + 1; i < last; i++) {
|
||||
const d = segDistSq(points[i], points[first], points[last])
|
||||
if (d > maxSq) { maxSq = d; index = i }
|
||||
}
|
||||
if (maxSq > tolSq) {
|
||||
keep[index] = 1
|
||||
stack.push([first, index], [index, last])
|
||||
}
|
||||
}
|
||||
return points.filter((_, i) => keep[i])
|
||||
}
|
||||
|
||||
/** Every outer ring of a Polygon or MultiPolygon, holes discarded. */
|
||||
function outerRings(geometry) {
|
||||
if (!geometry) return []
|
||||
if (geometry.type === 'Polygon') return geometry.coordinates.slice(0, 1)
|
||||
if (geometry.type === 'MultiPolygon') return geometry.coordinates.map(poly => poly[0])
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift a ring that straddles the antimeridian into a continuous longitude
|
||||
* range. A ring whose points span more than half the globe is, in practice,
|
||||
* always a wrap rather than a genuinely hemisphere-wide landmass.
|
||||
*/
|
||||
function unwrap(ring) {
|
||||
let min = Infinity
|
||||
let max = -Infinity
|
||||
for (const [lng] of ring) { if (lng < min) min = lng; if (lng > max) max = lng }
|
||||
if (max - min <= 180) return ring
|
||||
return ring.map(([lng, lat]) => [lng < 0 ? lng + 360 : lng, lat])
|
||||
}
|
||||
|
||||
function build(feature) {
|
||||
const rings = outerRings(feature.geometry)
|
||||
.map(unwrap)
|
||||
.map(ring => ring.map(project))
|
||||
.map(ring => ({ ring, area: ringArea(ring) }))
|
||||
.sort((a, b) => b.area - a.area)
|
||||
|
||||
if (!rings.length) return null
|
||||
|
||||
const biggest = rings[0].area
|
||||
const kept = rings
|
||||
.filter(r => r.area >= biggest * ISLAND_CUTOFF)
|
||||
.slice(0, MAX_RINGS)
|
||||
.map(r => r.ring)
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||||
for (const ring of kept) {
|
||||
for (const [x, y] of ring) {
|
||||
if (x < minX) minX = x
|
||||
if (x > maxX) maxX = x
|
||||
if (y < minY) minY = y
|
||||
if (y > maxY) maxY = y
|
||||
}
|
||||
}
|
||||
const spanX = maxX - minX
|
||||
const spanY = maxY - minY
|
||||
if (!(spanX > 0) || !(spanY > 0)) return null
|
||||
|
||||
// The longer side becomes 100 and the shorter one keeps its proportion, so a
|
||||
// country never has to be squashed to fit the box it is drawn in.
|
||||
const scale = 100 / Math.max(spanX, spanY)
|
||||
const w = spanX * scale
|
||||
const h = spanY * scale
|
||||
const normalised = kept.map(ring =>
|
||||
ring.map(([x, y]) => [(x - minX) * scale, (y - minY) * scale]))
|
||||
|
||||
// Tolerance climbs until the whole country fits its point budget. Starting
|
||||
// from the diagonal keeps the loop short for a country of any size.
|
||||
let tolerance = 0.14
|
||||
let simplified = normalised.map(r => simplify(r, tolerance))
|
||||
let guard = 0
|
||||
while (simplified.reduce((n, r) => n + r.length, 0) > MAX_POINTS && guard++ < 40) {
|
||||
tolerance *= 1.35
|
||||
simplified = normalised.map(r => simplify(r, tolerance))
|
||||
}
|
||||
|
||||
const d = simplified
|
||||
.filter(ring => ring.length >= 3)
|
||||
.map(ring => `M${ring.map(([x, y]) => `${round(x)} ${round(y)}`).join('L')}Z`)
|
||||
.join('')
|
||||
|
||||
// The projected bounds, kept so the same path can be replayed in world
|
||||
// coordinates. A country drawn on its own wants its own box; the same country
|
||||
// on a route map has to sit where it actually is, next to its neighbours. One
|
||||
// normalised path plus these four numbers serves both, where storing two sets
|
||||
// of coordinates would be twice the bytes and one more thing to disagree.
|
||||
return d ? { d, w: round(w), h: round(h), b: [minX, minY, maxX, maxY].map(v => Math.round(v * 100) / 100) } : null
|
||||
}
|
||||
|
||||
const round = n => Math.round(n * 10) / 10
|
||||
|
||||
const raw = zlib.gunzipSync(fs.readFileSync(SRC))
|
||||
const collection = JSON.parse(raw.toString('utf8'))
|
||||
|
||||
const shapes = new Map()
|
||||
for (const feature of collection.features) {
|
||||
const code = String(feature.properties?.ISO_A2 || '').toUpperCase()
|
||||
if (code.length !== 2 || code === '-9') continue
|
||||
const built = build(feature)
|
||||
if (!built) continue
|
||||
// A code appearing twice means two features for one country; keep the one
|
||||
// with more outline, which is the mainland rather than a dependency.
|
||||
const prev = shapes.get(code)
|
||||
if (!prev || built.d.length > prev.d.length) shapes.set(code, built)
|
||||
}
|
||||
|
||||
const codes = [...shapes.keys()].sort()
|
||||
const body = codes
|
||||
.map(code => {
|
||||
const s = shapes.get(code)
|
||||
return ` ${code}: { d: '${s.d}', w: ${s.w}, h: ${s.h}, b: [${s.b.join(', ')}] },`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const file = `/**
|
||||
* GENERATED — do not edit. Run \`node scripts/build-country-shapes.mjs\`.
|
||||
*
|
||||
* Country silhouettes, simplified from the admin-0 boundaries the server
|
||||
* bundles for Atlas (geoBoundaries, CC BY 4.0). Each path is Web Mercator,
|
||||
* normalised so the longer side is 100 units; \`w\` and \`h\` give the shape's
|
||||
* own proportions inside that box so it can be drawn without being squashed.
|
||||
*
|
||||
* ${codes.length} countries, built for print at postage-stamp size — islands
|
||||
* below ${ISLAND_CUTOFF * 100}% of the mainland are dropped and each outline is
|
||||
* simplified to at most ${MAX_POINTS} points.
|
||||
*/
|
||||
|
||||
export interface CountryShape {
|
||||
/** Path on a 0..100 box, outer rings only. */
|
||||
d: string
|
||||
/** The shape's own width and height inside that box. */
|
||||
w: number
|
||||
h: number
|
||||
/** Projected bounds \`[minX, minY, maxX, maxY]\`, for drawing it in place. */
|
||||
b: [number, number, number, number]
|
||||
}
|
||||
|
||||
/**
|
||||
* Web Mercator, matching the projection the paths were built with. Longitude
|
||||
* passes through as degrees; latitude is flipped so y grows downwards like
|
||||
* SVG's does.
|
||||
*/
|
||||
export function projectMercator(lng: number, lat: number): { x: number; y: number } {
|
||||
const clamped = Math.max(-85.05, Math.min(85.05, lat))
|
||||
const rad = (clamped * Math.PI) / 180
|
||||
return { x: lng, y: -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI }
|
||||
}
|
||||
|
||||
/**
|
||||
* The same outline, placed in projected world coordinates.
|
||||
*
|
||||
* The stored path is normalised into its own box; multiplying by the box's span
|
||||
* and shifting by its origin puts it back where the country actually is, which
|
||||
* is what a map with more than one country on it needs.
|
||||
*/
|
||||
export function countryWorldPath(shape: CountryShape): string {
|
||||
const span = Math.max(shape.b[2] - shape.b[0], shape.b[3] - shape.b[1]) / 100
|
||||
let axis = 0
|
||||
return shape.d.replace(/-?\\d*\\.?\\d+/g, m => {
|
||||
const isX = axis++ % 2 === 0
|
||||
const v = Number(m) * span + (isX ? shape.b[0] : shape.b[1])
|
||||
return String(Math.round(v * 1000) / 1000)
|
||||
})
|
||||
}
|
||||
|
||||
export const COUNTRY_SHAPES: Record<string, CountryShape> = {
|
||||
${body}
|
||||
}
|
||||
|
||||
/** The silhouette for an ISO-3166-1 alpha-2 code, or null if we have none. */
|
||||
export function countryShape(code: string | null | undefined): CountryShape | null {
|
||||
if (!code) return null
|
||||
return COUNTRY_SHAPES[code.toUpperCase()] ?? null
|
||||
}
|
||||
`
|
||||
|
||||
fs.writeFileSync(OUT, file, 'utf8')
|
||||
const kb = Math.round(Buffer.byteLength(file) / 102.4) / 10
|
||||
console.log(`[country-shapes] ${codes.length} countries, ${kb} kB → ${path.relative(process.cwd(), OUT)}`)
|
||||
@@ -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()}`)
|
||||
}
|
||||
}
|
||||
|
||||
+50
-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 () => {
|
||||
@@ -387,6 +412,30 @@ describe('App — on-mount effects', () => {
|
||||
await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true))
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-018b: setManaged mirrors the config flag, and defaults to false when it is absent', async () => {
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () => HttpResponse.json({ managed: true }))
|
||||
)
|
||||
const setManaged = vi.fn()
|
||||
useAuthStore.setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
loadUser: vi.fn().mockResolvedValue(undefined),
|
||||
setManaged,
|
||||
})
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true))
|
||||
|
||||
// An older server that does not send the field must not leave the flag
|
||||
// stuck on from a previous install in the same browser profile.
|
||||
setManaged.mockClear()
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () => HttpResponse.json({}))
|
||||
)
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false))
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
|
||||
const loadSettings = vi.fn().mockResolvedValue(undefined)
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
|
||||
+25
-5
@@ -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'
|
||||
@@ -28,6 +29,7 @@ import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
// Notice action registrations (side-effect imports):
|
||||
import './pages/Trips/noticeActions.js'
|
||||
import { managedRoutes } from './managed'
|
||||
|
||||
// Every page below loads on demand. The entry chunk used to carry all twenty of
|
||||
// them eagerly, so opening /dashboard also paid for the planner, the journal, the
|
||||
@@ -47,6 +49,7 @@ const HelpPage = lazyWithRetry(() => import('./pages/HelpPage'))
|
||||
const AtlasPage = lazyWithRetry(() => import('./pages/AtlasPage'))
|
||||
const JourneyPage = lazyWithRetry(() => import('./pages/JourneyPage'))
|
||||
const JourneyDetailPage = lazyWithRetry(() => import('./pages/JourneyDetailPage'))
|
||||
const JourneyStudioPage = lazyWithRetry(() => import('./pages/JourneyStudioPage'))
|
||||
const CollectionsPage = lazyWithRetry(() => import('./pages/CollectionsPage'))
|
||||
const JourneyPublicPage = lazyWithRetry(() => import('./pages/JourneyPublicPage'))
|
||||
const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'))
|
||||
@@ -242,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 }
|
||||
@@ -278,7 +283,7 @@ function RouteFallback() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
@@ -295,7 +300,8 @@ export default function App() {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
||||
@@ -471,6 +477,15 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Empty in this repository, and read unconditionally so the public
|
||||
build walks the same path as any other. See client/src/managed. */}
|
||||
{managedRoutes.map(r => (
|
||||
<Route
|
||||
key={r.path}
|
||||
path={r.path}
|
||||
element={<ProtectedRoute adminRequired={r.adminOnly}>{r.element}</ProtectedRoute>}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
path="/vacay"
|
||||
element={
|
||||
@@ -502,7 +517,12 @@ export default function App() {
|
||||
<ViewportRoute phone={MJourneyDetailScreen} desktop={JourneyDetailPage} />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
>
|
||||
{/* Studio is nested so the journey stays mounted underneath it and
|
||||
shows through the panel's margin — it is opened on top of the
|
||||
journey, not navigated away to. */}
|
||||
<Route path="studio" element={<JourneyStudioPage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/collections"
|
||||
element={
|
||||
|
||||
@@ -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 }
|
||||
@@ -267,6 +267,14 @@ describe('client > endpoint wiring', () => {
|
||||
{ n: 'updateNotificationPreferences', r: () => adminApi.updateNotificationPreferences({ email: { trip_invite: true } }), e: 'PUT /api/admin/notification-preferences' },
|
||||
{ n: 'getDefaultUserSettings', r: () => adminApi.getDefaultUserSettings(), e: 'GET /api/admin/default-user-settings' },
|
||||
{ n: 'updateDefaultUserSettings', r: () => adminApi.updateDefaultUserSettings({ language: 'de' }), e: 'PUT /api/admin/default-user-settings' },
|
||||
{ n: 'getStorage', r: () => adminApi.getStorage(), e: 'GET /api/admin/storage' },
|
||||
{ n: 'updateStorage', r: () => adminApi.updateStorage({ backends: [], categories: {}, version: 0 }), e: 'PUT /api/admin/storage' },
|
||||
{ n: 'testStorageBackend', r: () => adminApi.testStorageBackend({ name: 'x', type: 'local', options: { root: '/data' } }), e: 'POST /api/admin/storage/test' },
|
||||
{ n: 'startStorageBackfill', r: () => adminApi.startStorageBackfill('m'), e: 'POST /api/admin/storage/backends/m/backfill' },
|
||||
{ n: 'cancelStorageBackfill', r: () => adminApi.cancelStorageBackfill('m'), e: 'DELETE /api/admin/storage/backends/m/backfill' },
|
||||
{ n: 'startStorageMigration', r: () => adminApi.startStorageMigration('files', 'dest'), e: 'POST /api/admin/storage/migrations' },
|
||||
{ n: 'cancelStorageMigration', r: () => adminApi.cancelStorageMigration('files'), e: 'DELETE /api/admin/storage/migrations/files' },
|
||||
{ n: 'refreshStorageStats', r: () => adminApi.refreshStorageStats(), e: 'POST /api/admin/storage/stats/refresh' },
|
||||
{ n: 'mcpTokens', r: () => adminApi.mcpTokens(), e: 'GET /api/admin/mcp-tokens' },
|
||||
{ n: 'deleteMcpToken', r: () => adminApi.deleteMcpToken(4), e: 'DELETE /api/admin/mcp-tokens/4' },
|
||||
{ n: 'oauthSessions', r: () => adminApi.oauthSessions(), e: 'GET /api/admin/oauth-sessions' },
|
||||
@@ -388,6 +396,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' },
|
||||
|
||||
+81
-16
@@ -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,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
type NotificationRespondRequest,
|
||||
type SettingUpsertRequest, type SettingsBulkRequest,
|
||||
type JourneyCreateRequest, type JourneyAddTripRequest, type JourneyTracksResponse,
|
||||
type JourneyStats, type BookRecord, type BookSaveRequest,
|
||||
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
|
||||
type JourneyShareLinkRequest,
|
||||
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
|
||||
@@ -46,9 +48,15 @@ import {
|
||||
type BookingImportPreviewResponse,
|
||||
type BookingImportConfirmResponse,
|
||||
type BookingImportMode,
|
||||
type StorageAdminState,
|
||||
type StorageBackend,
|
||||
type StorageConfigPut,
|
||||
type StorageTestResponse,
|
||||
type StorageUsage,
|
||||
} 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
|
||||
@@ -144,10 +152,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
|
||||
},
|
||||
@@ -526,7 +531,12 @@ export const adminApi = {
|
||||
apiClient.post('/admin/plugins/install', { id, ...opts }).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.
|
||||
@@ -636,6 +646,26 @@ export const adminApi = {
|
||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
||||
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
|
||||
getStorage: (): Promise<StorageAdminState> => apiClient.get('/admin/storage').then(r => r.data),
|
||||
updateStorage: (config: StorageConfigPut): Promise<StorageAdminState> =>
|
||||
apiClient.put('/admin/storage', config).then(r => r.data),
|
||||
// A probe is bounded by the target driver's own timeout (default 30s, ×2 with
|
||||
// one retry) and a mirror probes its targets sequentially — the instance-wide
|
||||
// 8s axios timeout would abort a legitimate slow probe, so this call carries
|
||||
// its own generous ceiling.
|
||||
testStorageBackend: (backend: StorageBackend): Promise<StorageTestResponse> =>
|
||||
apiClient.post('/admin/storage/test', { backend }, { timeout: 120_000 }).then(r => r.data),
|
||||
startStorageBackfill: (backend: string): Promise<{ started: true }> =>
|
||||
apiClient.post(`/admin/storage/backends/${encodeURIComponent(backend)}/backfill`).then(r => r.data),
|
||||
cancelStorageBackfill: (backend: string): Promise<{ cancelled: true }> =>
|
||||
apiClient.delete(`/admin/storage/backends/${encodeURIComponent(backend)}/backfill`).then(r => r.data),
|
||||
startStorageMigration: (category: string, to: string): Promise<{ started: true }> =>
|
||||
apiClient.post('/admin/storage/migrations', { category, to }).then(r => r.data),
|
||||
cancelStorageMigration: (category: string): Promise<{ cancelled: true }> =>
|
||||
apiClient.delete(`/admin/storage/migrations/${encodeURIComponent(category)}`).then(r => r.data),
|
||||
// A full scan of a large install can exceed the 8s instance timeout.
|
||||
refreshStorageStats: (): Promise<StorageUsage> =>
|
||||
apiClient.post('/admin/storage/stats/refresh', undefined, { timeout: 120_000 }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
@@ -889,6 +919,20 @@ export const journeyApi = {
|
||||
listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data),
|
||||
// GPX tracks of the trips this journey's entries came from (#1260).
|
||||
listTracks: (id: number): Promise<JourneyTracksResponse> => apiClient.get(`/journeys/${id}/tracks`).then(r => r.data),
|
||||
// What the journey adds up to — distance, days, countries, the route (#1973).
|
||||
// Read by TREK Studio, which freezes the answer into the book document.
|
||||
stats: (id: number): Promise<JourneyStats> => apiClient.get(`/journeys/${id}/stats`).then(r => r.data),
|
||||
|
||||
// The Studio book (#1973). `book` is null for a journey that has none yet —
|
||||
// Studio lays one out and the first save creates it.
|
||||
getBook: (id: number): Promise<{ book: BookRecord | null }> =>
|
||||
apiClient.get(`/journeys/${id}/book`).then(r => r.data),
|
||||
// A 409 carries the current record in its body, so a conflict can be shown
|
||||
// rather than only announced. See useBookStore.
|
||||
saveBook: (id: number, body: BookSaveRequest): Promise<BookRecord> =>
|
||||
apiClient.put(`/journeys/${id}/book`, body).then(r => r.data),
|
||||
deleteBook: (id: number): Promise<void> =>
|
||||
apiClient.delete(`/journeys/${id}/book`).then(r => r.data),
|
||||
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
|
||||
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
|
||||
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
|
||||
@@ -928,11 +972,37 @@ 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) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||
// Pictures and a description for a place that is being looked at but not yet
|
||||
// saved. Fans out to several providers server-side, so it takes a signal and
|
||||
// the caller is expected to abort it when the selection changes, and a longer
|
||||
@@ -1029,7 +1099,8 @@ export const healthApi = {
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
|
||||
// `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')),
|
||||
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')),
|
||||
}
|
||||
@@ -1103,13 +1174,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),
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-010
|
||||
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-011
|
||||
import { describe, it, expect } from 'vitest'
|
||||
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 } 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', () => {
|
||||
const expected = [
|
||||
@@ -34,8 +47,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)', () => {
|
||||
|
||||
@@ -9,6 +9,15 @@ let reconnectDelay = 1000
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const listeners = new Set<WebSocketListener>()
|
||||
const activeTrips = new Set<string>()
|
||||
|
||||
/**
|
||||
* Studio books this tab has open, so presence survives a reconnect.
|
||||
*
|
||||
* Same reasoning as `activeTrips`: the server forgets the room when the socket
|
||||
* dies, and without rejoining, everyone else keeps showing a pointer for
|
||||
* somebody who is no longer there.
|
||||
*/
|
||||
const activeBooks = new Set<string>()
|
||||
let shouldReconnect = false
|
||||
let refetchCallback: RefetchCallback | null = null
|
||||
let mySocketId: string | null = null
|
||||
@@ -110,6 +119,11 @@ async function connectInternal(_isReconnect = false): Promise<void> {
|
||||
|
||||
socket.onopen = () => {
|
||||
reconnectDelay = 1000
|
||||
activeBooks.forEach(journeyId => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'book:join', journeyId }))
|
||||
}
|
||||
})
|
||||
if (activeTrips.size > 0) {
|
||||
activeTrips.forEach(tripId => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
@@ -166,6 +180,7 @@ export function disconnect(): void {
|
||||
reconnectTimer = null
|
||||
}
|
||||
activeTrips.clear()
|
||||
activeBooks.clear()
|
||||
if (socket) {
|
||||
socket.onclose = null
|
||||
socket.close()
|
||||
@@ -187,6 +202,40 @@ export function leaveTrip(tripId: number | string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a Studio book — presence and pointers, for as long as the editor is up.
|
||||
*/
|
||||
export function joinBook(journeyId: number | string): void {
|
||||
activeBooks.add(String(journeyId))
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'book:join', journeyId: String(journeyId) }))
|
||||
}
|
||||
}
|
||||
|
||||
export function leaveBook(journeyId: number | string): void {
|
||||
activeBooks.delete(String(journeyId))
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'book:leave', journeyId: String(journeyId) }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this tab's pointer is, in millimetres on the spread.
|
||||
*
|
||||
* Dropped silently when the socket is not open. A pointer is worth nothing a
|
||||
* moment later, so there is nothing to queue and nothing to retry — the next
|
||||
* one is along in a tenth of a second.
|
||||
*/
|
||||
export function sendBookCursor(
|
||||
journeyId: number | string,
|
||||
spreadIndex: number,
|
||||
x: number | null,
|
||||
y: number | null,
|
||||
): void {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
||||
socket.send(JSON.stringify({ type: 'book:cursor', journeyId: String(journeyId), spreadIndex, x, y }))
|
||||
}
|
||||
|
||||
export function addListener(fn: WebSocketListener): void {
|
||||
listeners.add(fn)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,14 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'journey:entry:deleted',
|
||||
'journey:entries:reordered',
|
||||
'journey:contributor:changed',
|
||||
// Studio book — components/Studio/useBookStore (its own listener: a client
|
||||
// with nothing outstanding takes the new version, one with unsaved edits
|
||||
// deliberately does not and conflicts on its next save instead)
|
||||
'journey:book:saved',
|
||||
// Studio presence — components/Studio/useBookPresence (its own listener:
|
||||
// who has the book open, and where their pointers are)
|
||||
'journey:book:peers',
|
||||
'journey:book:cursor',
|
||||
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
|
||||
'import:progress',
|
||||
'import:done',
|
||||
|
||||
@@ -98,7 +98,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 () => {
|
||||
|
||||
@@ -196,7 +196,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
<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}
|
||||
<button type="button" onClick={onToggleBagTracking}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
@@ -222,7 +222,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
<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)}
|
||||
<button type="button" onClick={() => onToggleCollabFeature(feat.key)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
@@ -269,7 +269,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
<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
|
||||
<button type="button"
|
||||
onClick={provider.toggle}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
@@ -451,6 +451,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Model</div>
|
||||
<label className="block">
|
||||
<span className="sr-only">Model</span>
|
||||
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</label>
|
||||
|
||||
@@ -459,7 +460,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
<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">
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -470,7 +471,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
@@ -509,11 +510,11 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
)}
|
||||
</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'}`}>
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button 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">
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
@@ -526,7 +527,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
)}
|
||||
</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">
|
||||
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -589,7 +590,7 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
|
||||
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
{!hideToggle && (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -201,6 +201,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,
|
||||
}
|
||||
}
|
||||
@@ -1087,6 +1090,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({
|
||||
@@ -1405,7 +1637,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 +1655,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')
|
||||
|
||||
@@ -1862,3 +2094,43 @@ 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']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ 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,
|
||||
Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle,
|
||||
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette,
|
||||
} from 'lucide-react'
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
@@ -63,6 +63,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 +95,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.
|
||||
@@ -187,7 +201,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
|
||||
@@ -392,6 +406,12 @@ export default function AdminPluginsPanel() {
|
||||
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 +440,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)
|
||||
}
|
||||
@@ -551,7 +574,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 +676,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 +712,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 +799,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 +855,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 +902,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 +931,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,9 +945,12 @@ 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,
|
||||
@@ -903,11 +972,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,11 +994,11 @@ 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 role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEgressFor(null)}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
|
||||
<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">
|
||||
{!egressFor.supported ? (
|
||||
@@ -943,7 +1012,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 +1027,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"
|
||||
@@ -984,6 +1053,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 +1120,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 +1141,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 +1153,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,17 +1172,17 @@ 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, 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
|
||||
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
|
||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
|
||||
onErrors: () => void; onEgress: () => void; onUninstall: () => void
|
||||
}) {
|
||||
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
|
||||
@@ -1152,12 +1235,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 +1278,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 +1308,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 +1318,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>
|
||||
@@ -1227,6 +1330,10 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
|
||||
<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 +1376,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}
|
||||
@@ -1343,7 +1450,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 +1508,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 +1527,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">
|
||||
@@ -1503,7 +1610,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 +1622,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 +1677,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,8 +1760,8 @@ 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 role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldAlert size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
@@ -1600,12 +1784,12 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
|
||||
)}
|
||||
</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}
|
||||
<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,8 +1824,8 @@ 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 role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onLater}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldCheck size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
@@ -1679,8 +1863,8 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
|
||||
)}
|
||||
</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>
|
||||
<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,8 +1884,8 @@ 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 role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><Puzzle size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
@@ -1720,7 +1904,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>
|
||||
@@ -1731,7 +1915,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
|
||||
)}
|
||||
</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>
|
||||
<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 +1941,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
styleSettingKey,
|
||||
type GlMapProvider,
|
||||
} from '../Map/glProviders'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
@@ -35,6 +36,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
|
||||
@@ -85,7 +87,7 @@ function OptionButton({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -108,7 +110,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const [defaults, setDefaults] = useState<Defaults>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -117,6 +121,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))
|
||||
@@ -138,6 +143,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))
|
||||
@@ -152,7 +158,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' }}
|
||||
@@ -183,7 +189,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
transport_mode: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
created_at: Date(),
|
||||
created_at: String(new Date()),
|
||||
}], [])
|
||||
|
||||
if (!loaded) {
|
||||
@@ -333,6 +339,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, {
|
||||
@@ -379,7 +405,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
{/* The token 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. */}
|
||||
{mapProvider === 'mapbox-gl' && !managed && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxToken')}
|
||||
|
||||
@@ -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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,779 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Activity, FolderTree, HardDrive } from 'lucide-react'
|
||||
import {
|
||||
STORAGE_CATEGORIES,
|
||||
type StorageBackend,
|
||||
type StorageCategory,
|
||||
type StorageConfig,
|
||||
type StorageMigrationStatus,
|
||||
type StorageTestResponse,
|
||||
} from '@trek/shared'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { formatBytes } from '../../../utils/formatBytes'
|
||||
import { relativeTime } from '../../../utils/relativeTime'
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog'
|
||||
import CustomSelect from '../../shared/CustomSelect'
|
||||
import { useToast } from '../../shared/Toast'
|
||||
import Section from '../../Settings/Section'
|
||||
import BackendForm, { type BackendFormMirrorProps } from './BackendForm'
|
||||
import {
|
||||
CACHE_CATEGORIES,
|
||||
adoptedMirrorFor,
|
||||
computeMigrationCandidates,
|
||||
effectiveCategoryMap,
|
||||
foldBackends,
|
||||
mirrorProbeTargets,
|
||||
primaryNameOf,
|
||||
removeBackend,
|
||||
removeBackendAndMirrors,
|
||||
renameBackendRefs,
|
||||
replicaCandidates,
|
||||
replicaOfPrimaries,
|
||||
settingsDocumentOf,
|
||||
setMirrorTargets,
|
||||
stripCategories,
|
||||
upsertBackend,
|
||||
usageByBackend,
|
||||
type FoldedBackendRow,
|
||||
type MigrationCandidate,
|
||||
} from './storageModel'
|
||||
import { useStorageAdmin } from './useStorageAdmin'
|
||||
|
||||
/** Display-name mapper for joined category lists — the raw id renders only in the badge. */
|
||||
const categoryNames = (t: (key: string) => string, ids: readonly string[]): string =>
|
||||
ids.map((id) => t(`storage.category.${id}`)).join(', ')
|
||||
|
||||
function TestResult({ result, failedLabel, okLabel }: {
|
||||
result: StorageTestResponse | undefined
|
||||
failedLabel: string
|
||||
okLabel: string
|
||||
}): React.ReactElement | null {
|
||||
if (result === undefined) return null
|
||||
return (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
<p className="text-xs font-semibold text-content">{result.ok ? okLabel : failedLabel}</p>
|
||||
{result.targets.map((target) => (
|
||||
<p key={target.name} className="text-xs text-content-faint">
|
||||
{target.ok ? '✓' : '✗'} {target.name}
|
||||
{target.error ? ` — ${target.error}` : ''}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LINK_BUTTON_STYLE: React.CSSProperties = { background: 'none', border: 'none', cursor: 'pointer' }
|
||||
|
||||
export default function AdminStoragePanel(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const admin = useStorageAdmin(t('common.error'), t('storage.saveConflict'))
|
||||
const [editing, setEditing] = useState<{
|
||||
initial: StorageBackend | null
|
||||
originalName: string | null
|
||||
mirror: BackendFormMirrorProps
|
||||
} | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState<{ name: string; degenerate: boolean } | null>(null)
|
||||
const [syncPrompt, setSyncPrompt] = useState<string | null>(null)
|
||||
// Open/closed only — never a snapshot of candidates. Storing the candidate
|
||||
// array here would go stale the moment the operator edits a category while
|
||||
// the dialog is open; the render below and moveAndSave both recompute
|
||||
// computeMigrationCandidates(draft, state) fresh instead.
|
||||
const [migratePromptOpen, setMigratePromptOpen] = useState(false)
|
||||
const [migrationQueue, setMigrationQueue] = useState<MigrationCandidate[]>([])
|
||||
// Set by `save` right before it calls admin.save(): the pre-save mirror
|
||||
// target count per row name. Consumed (and cleared) by the effect below the
|
||||
// first time `admin.state` changes afterward — never touched otherwise, so
|
||||
// unrelated state changes (the backfill poll included) are no-ops here.
|
||||
const pendingPromptCheck = useRef<Map<string, number> | null>(null)
|
||||
// Synchronous single-flight lock for the queue effect below: `admin.state`
|
||||
// only reflects a just-started migration once startMigration's awaited
|
||||
// refreshState() resolves, so `setMigrationQueue(rest)` re-firing the
|
||||
// effect synchronously (still against the pre-POST `admin.state`) would
|
||||
// otherwise dequeue and POST the next candidate before the server has
|
||||
// confirmed the first — a ref (not state) so the guard is visible on that
|
||||
// very next synchronous re-render, not just after a state-driven one.
|
||||
const migrationStartInFlight = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingPromptCheck.current || !admin.state) return
|
||||
const before = pendingPromptCheck.current
|
||||
pendingPromptCheck.current = null
|
||||
const { rows: afterRows } = foldBackends(admin.state, settingsDocumentOf(admin.state))
|
||||
const grown = afterRows.find((r) => r.mirrorTargets.length > (before.get(r.name) ?? 0))
|
||||
if (grown) setSyncPrompt(grown.name)
|
||||
}, [admin.state])
|
||||
|
||||
// Queued category migrations run strictly sequentially: once a slot opens
|
||||
// (no migration currently running), dequeue the next candidate and start
|
||||
// it. Depends on admin.state so a poll landing a terminal status re-fires
|
||||
// this without any user interaction. The in-flight lock closes the race
|
||||
// where setMigrationQueue(rest) re-fires this effect synchronously while
|
||||
// admin.state still shows the pre-POST world — without it, the next
|
||||
// candidate could dequeue and POST before the first is server-confirmed.
|
||||
// Also waits out a running BACKFILL — the server's one-storage-job-at-a-time
|
||||
// rule spans backfills and migrations alike, so starting while one runs
|
||||
// would 409, and the queued candidate would be lost (no retry).
|
||||
//
|
||||
// A failed startMigration POST would otherwise strand `rest`: it was
|
||||
// already dequeued into local state before the POST, so on failure nothing
|
||||
// ever changes `migrationQueue` or `admin.state` again — the effect never
|
||||
// re-fires and the remaining candidates sit invisibly forever, silently
|
||||
// never migrated. On failure the whole remaining queue is explicitly
|
||||
// cleared (never retried — the operator re-triggers via Save), named in a
|
||||
// toast, and admin.state is refreshed so the panel reflects whatever the
|
||||
// failed attempt's category ended up as server-side.
|
||||
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
|
||||
const [next, ...rest] = migrationQueue
|
||||
migrationStartInFlight.current = true
|
||||
setMigrationQueue(rest)
|
||||
void admin.startMigration(next!.category, next!.toWire).then((error) => {
|
||||
migrationStartInFlight.current = false
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
if (rest.length > 0) {
|
||||
toast.error(t('storage.migrate.queueDropped', { categories: categoryNames(t, rest.map((c) => c.category)) }))
|
||||
setMigrationQueue([])
|
||||
}
|
||||
void admin.refreshState().catch(() => {})
|
||||
}
|
||||
})
|
||||
}, [migrationQueue, admin.state])
|
||||
|
||||
if (admin.loading) {
|
||||
return <p className="text-sm italic p-4 text-content-faint">{t('storage.loading')}</p>
|
||||
}
|
||||
if (!admin.state || !admin.draft) {
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border p-4 text-sm border-edge bg-surface-card text-content">
|
||||
{admin.loadError || t('common.error')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const { state, draft } = admin
|
||||
|
||||
// Pure/cheap — recomputed every render so the migrate-prompt dialog (below)
|
||||
// never shows a stale candidate set while it's open (fix: migration prompt
|
||||
// staleness). moveAndSave recomputes independently at confirm time rather
|
||||
// than reading this render-scoped value, since the click handler and the
|
||||
// render that produced it are not guaranteed to be the same one.
|
||||
const migrationCandidates = computeMigrationCandidates(draft, state)
|
||||
|
||||
// Duplicate pre-check needs EVERY wire name (hidden mirror names included);
|
||||
// mirror-target candidates are the visible primaries only.
|
||||
const backendNames = [...new Set([...state.backends.map((b) => b.name), ...draft.backends.map((b) => b.name)])]
|
||||
const { rows, degenerate } = foldBackends(state, draft)
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const usageSums = usageByBackend(state, draft)
|
||||
|
||||
const startEdit = (row: FoldedBackendRow) => {
|
||||
// Editing a built-in creates a settings override row bearing its name —
|
||||
// the first-class relocation path (merge-by-name).
|
||||
setEditing({
|
||||
initial: row.backend,
|
||||
originalName: row.name,
|
||||
mirror: {
|
||||
candidates: replicaCandidates(rows, row.name, row.mirrorTargets),
|
||||
initialTargets: row.mirrorTargets,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const commitBackend = (backend: StorageBackend, mirrorTargets?: string[]) => {
|
||||
const renamedFrom =
|
||||
editing?.originalName && editing.originalName !== backend.name ? editing.originalName : null
|
||||
// Widened to StorageConfig: these edit helpers are version-blind (they
|
||||
// return plain StorageConfig), and admin.setDraft re-attaches the
|
||||
// draft's own `version` regardless of what shape it's handed.
|
||||
let next: StorageConfig | null = draft
|
||||
if (renamedFrom) next = renameBackendRefs(removeBackend(next, renamedFrom), renamedFrom, backend.name)
|
||||
next = upsertBackend(next, backend)
|
||||
if (mirrorTargets !== undefined) next = setMirrorTargets(state, next, backend.name, mirrorTargets)
|
||||
admin.setDraft(next)
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
const removeMessage = (name: string, isDegenerate: boolean): string => {
|
||||
const row = rows.find((r) => r.name === name)
|
||||
const assigned = isDegenerate
|
||||
? degenerate.find((d) => d.backend.name === name)?.categories ?? []
|
||||
: row?.categories ?? []
|
||||
const usedAsReplicaBy = isDegenerate ? [] : replicaOfPrimaries(draft, name)
|
||||
return [
|
||||
t('storage.remove.body', { name }),
|
||||
assigned.length > 0 ? t('storage.remove.stillAssigned', { categories: categoryNames(t, assigned) }) : '',
|
||||
usedAsReplicaBy.length > 0 ? t('storage.remove.usedAsReplicaBy', { primaries: usedAsReplicaBy.join(', ') }) : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const setCategory = (category: StorageCategory, primaryName: string) => {
|
||||
// Picking a mirrored primary routes through its adopted mirror silently.
|
||||
const target = adoptedMirrorFor(draft, primaryName)?.name ?? primaryName
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
const stateEntry = state.categories[category]!
|
||||
const categories = { ...draft.categories }
|
||||
if (stateEntry.source === 'default' && target === stateEntry.backend) {
|
||||
delete categories[category] // back to the default → no longer settings-owned
|
||||
} else {
|
||||
categories[category] = target
|
||||
}
|
||||
admin.setDraft({ ...draft, categories })
|
||||
}
|
||||
|
||||
// Snapshot the LAST-CONFIRMED (pre-save `state`'s own fold, not the
|
||||
// in-progress `draft`) mirror target counts; the effect above compares
|
||||
// them against the fresh post-save state to detect newly-added
|
||||
// replicas. Folding `draft` here would already include the unsaved
|
||||
// edit and mask the very growth this is meant to detect.
|
||||
const snapshotMirrorTargets = (): Map<string, number> => {
|
||||
const { rows: beforeRows } = foldBackends(state, settingsDocumentOf(state))
|
||||
return new Map(beforeRows.map((r) => [r.name, r.mirrorTargets.length]))
|
||||
}
|
||||
|
||||
const doPlainSave = async () => {
|
||||
pendingPromptCheck.current = snapshotMirrorTargets()
|
||||
if (await admin.save()) toast.success(t('storage.saved'))
|
||||
else pendingPromptCheck.current = null
|
||||
}
|
||||
|
||||
// Recomputes the candidate set at confirm time — never trusts whatever was
|
||||
// true when the dialog opened. The operator can edit categories (or a
|
||||
// background poll can land a fresher usage scan) while the dialog is on
|
||||
// screen; the recomputed set is what actually drives both stripCategories
|
||||
// (what gets reverted from the PUT body) and the queue (what gets POSTed),
|
||||
// so the two can never diverge from what the dialog most recently showed.
|
||||
const moveAndSave = async () => {
|
||||
const candidates = computeMigrationCandidates(draft, state)
|
||||
pendingPromptCheck.current = snapshotMirrorTargets()
|
||||
const ok = await admin.save(stripCategories(draft, state, candidates.map((c) => c.category)))
|
||||
if (ok) {
|
||||
setMigrationQueue(candidates)
|
||||
toast.success(t('storage.saved'))
|
||||
} else {
|
||||
pendingPromptCheck.current = null
|
||||
}
|
||||
setMigratePromptOpen(false)
|
||||
}
|
||||
|
||||
const routeOnlySave = async () => {
|
||||
setMigratePromptOpen(false)
|
||||
await doPlainSave()
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (migrationCandidates.length > 0) {
|
||||
setMigratePromptOpen(true)
|
||||
return
|
||||
}
|
||||
await doPlainSave()
|
||||
}
|
||||
|
||||
const handleCancelMigration = async (m: StorageMigrationStatus) => {
|
||||
const error = await admin.cancelMigration(m.category)
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
const testResultFor = (key: string): StorageTestResponse | 'running' | undefined => admin.testResults[key]
|
||||
|
||||
const handleRefreshStats = async () => {
|
||||
const error = await admin.refreshStats()
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
const handleStartBackfill = async (row: FoldedBackendRow) => {
|
||||
if (!row.mirrorName) return
|
||||
setSyncPrompt((prev) => (prev === row.name ? null : prev))
|
||||
const error = await admin.startBackfill(row.mirrorName)
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
const handleCancelBackfill = async (row: FoldedBackendRow) => {
|
||||
if (!row.mirrorName) return
|
||||
const error = await admin.cancelBackfill(row.mirrorName)
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{state.configError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border p-3 mb-4 text-sm"
|
||||
style={{ borderColor: 'var(--warning)', background: 'var(--warning-soft)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{t('storage.configError.banner', { error: state.configError })}
|
||||
</div>
|
||||
)}
|
||||
<Section title={t('storage.health.title')} icon={Activity}>
|
||||
{state.health.replicaFailures.length === 0 ? (
|
||||
<p className="text-sm text-content-faint">{t('storage.health.allClear')}</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{state.health.replicaFailures.map((failure) => (
|
||||
<li key={`${failure.backend}-${failure.key}-${failure.at}`} className="text-sm text-content">
|
||||
{t('storage.health.failureLine', {
|
||||
op: failure.op,
|
||||
key: failure.key,
|
||||
backend: failure.backend,
|
||||
error: failure.error,
|
||||
})}
|
||||
<span className="text-content-faint"> · {relativeTime(failure.at, locale)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{state.seedFilePresent && <p className="text-xs mt-2 text-content-faint">{t('storage.health.seedFile')}</p>}
|
||||
</Section>
|
||||
|
||||
<Section title={t('storage.backends.title')} icon={HardDrive}>
|
||||
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>{t('storage.description')}</p>
|
||||
<p className="text-xs mb-2 text-content-faint">
|
||||
{state.usage
|
||||
? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) })
|
||||
: t('storage.usage.never')}
|
||||
{' '}
|
||||
<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>
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => {
|
||||
const resultKey = row.mirrorName ?? row.name
|
||||
const result = testResultFor(resultKey)
|
||||
// row.mirrorName only exists when foldBackends adopted a draft mirror
|
||||
// for this row, so the draft lookup below cannot miss.
|
||||
const testCandidate = row.mirrorName
|
||||
? draft.backends.find((b) => b.name === row.mirrorName)!
|
||||
: row.backend
|
||||
const rowUsage = usageSums?.[row.name]
|
||||
const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined
|
||||
return (
|
||||
<div key={row.name} data-testid={`storage-backend-${row.name}`} className="rounded-xl border p-4 border-edge-secondary">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-content">{row.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
|
||||
{t(`storage.type.${row.type}`)}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-faint">
|
||||
{t(`storage.source.${row.source}`)}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() =>
|
||||
row.mirrorName
|
||||
? admin.testMirror(resultKey, mirrorProbeTargets(draft, state, testCandidate))
|
||||
: admin.test(testCandidate)
|
||||
}
|
||||
>
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
{row.source !== 'env' && (
|
||||
<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 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>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{row.categories.length > 0
|
||||
? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) })
|
||||
: t('storage.backends.unused')}
|
||||
</p>
|
||||
{rowUsage && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })}
|
||||
{row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0
|
||||
? ` (${t('storage.usage.legacyNote')})`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.replicaOf.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.source === 'env' && <p className="text-xs mt-1 text-content-faint">{t('storage.backends.envReadOnly')}</p>}
|
||||
{result === 'running' ? (
|
||||
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{backfill?.status === 'running' ? (
|
||||
<>
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.running', { done: String(backfill.done), total: String(backfill.total) })}
|
||||
</p>
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.counts', {
|
||||
copied: String(backfill.copied),
|
||||
skipped: String(backfill.skipped),
|
||||
failed: String(backfill.failed),
|
||||
})}
|
||||
</p>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleCancelBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Terminal statuses render ALONGSIDE the button (not instead-of) —
|
||||
a completed/cancelled/errored backfill must stay re-runnable
|
||||
without a page reload. */}
|
||||
{backfill?.status === 'done' && (
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.done', {
|
||||
copied: String(backfill.copied),
|
||||
deleted: String(backfill.deleted),
|
||||
failed: String(backfill.failed),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{backfill?.status === 'cancelled' && (
|
||||
<p className="text-xs text-content-faint">{t('storage.sync.cancelled')}</p>
|
||||
)}
|
||||
{backfill?.status === 'error' && (
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.error', { error: backfill.error ?? '' })}
|
||||
</p>
|
||||
)}
|
||||
{syncPrompt !== row.name && (
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.now')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{syncPrompt === row.name && (
|
||||
<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 type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.now')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => setSyncPrompt(null)}
|
||||
>
|
||||
{t('storage.sync.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{degenerate.map(({ backend, reason }) => {
|
||||
const result = testResultFor(backend.name)
|
||||
const primary = backend.type === 'mirror' ? backend.options.primary : ''
|
||||
return (
|
||||
<div key={backend.name} data-testid={`storage-backend-${backend.name}`} className="rounded-xl border p-4 border-edge-secondary">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-content">{backend.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
|
||||
{t('storage.type.mirror')}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
<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>
|
||||
<p className="text-xs mt-1 text-content-faint">{t(`storage.mirror.degenerate.${reason}`, { primary })}</p>
|
||||
{result === 'running' ? (
|
||||
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{state.migrations.length > 0 && (
|
||||
<div className="space-y-3 mt-3">
|
||||
{state.migrations.map((m) => (
|
||||
<div
|
||||
key={m.category}
|
||||
data-testid={`storage-migration-${m.category}`}
|
||||
className="rounded-xl border p-4 border-edge-secondary"
|
||||
>
|
||||
<p className="text-sm font-semibold text-content">{t(`storage.category.${m.category}`)}</p>
|
||||
{m.status === 'running' ? (
|
||||
<>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.running', {
|
||||
category: t(`storage.category.${m.category}`),
|
||||
done: String(m.done),
|
||||
total: String(m.total),
|
||||
})}
|
||||
</p>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void handleCancelMigration(m)}
|
||||
>
|
||||
{t('storage.migrate.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : m.status === 'done' ? (
|
||||
<>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.done', { copied: String(m.copied), skipped: String(m.skipped) })}
|
||||
</p>
|
||||
{m.failed > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.doneFailures', { failed: String(m.failed) })}
|
||||
</p>
|
||||
)}
|
||||
{m.reclaimable && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.reclaimable', {
|
||||
objects: String(m.reclaimable.objects),
|
||||
size: formatBytes(m.reclaimable.bytes),
|
||||
from: m.from,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : m.status === 'failed' ? (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.failed', { error: m.error ?? '' })}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs mt-1 text-content-faint">{t('storage.migrate.cancelled')}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<BackendForm
|
||||
initial={editing.initial}
|
||||
backendNames={backendNames}
|
||||
mirror={editing.mirror}
|
||||
onCommit={commitBackend}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
) : (
|
||||
<button type="button"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
initial: null,
|
||||
originalName: null,
|
||||
mirror: { candidates: replicaCandidates(rows, null), initialTargets: [] },
|
||||
})
|
||||
}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('storage.backends.add')}
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t('storage.categories.title')} icon={FolderTree}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
|
||||
{STORAGE_CATEGORIES.map((category) => {
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
const stateEntry = state.categories[category]!
|
||||
const selectedPrimary = primaryNameOf(state, draft, effective[category])
|
||||
const changed = selectedPrimary !== primaryNameOf(state, draft, stateEntry.backend)
|
||||
const viaMirror = effective[category] !== selectedPrimary
|
||||
return (
|
||||
<div key={category} data-testid={`storage-category-${category}`}>
|
||||
<label className="block text-sm font-medium mb-1 text-content-secondary">
|
||||
{t(`storage.category.${category}`)}
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded border font-mono font-normal text-xs border-edge text-content-faint">
|
||||
{category}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs mb-1.5 text-content-faint">{t(`storage.categoryDesc.${category}`)}</p>
|
||||
{state.usage?.categories[category] && (
|
||||
<p className="text-xs mb-1.5 text-content-faint">
|
||||
{t('storage.usage.line', {
|
||||
objects: String(state.usage.categories[category]!.objects),
|
||||
size: formatBytes(state.usage.categories[category]!.bytes),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<CustomSelect
|
||||
value={selectedPrimary}
|
||||
onChange={(value) => setCategory(category, String(value))}
|
||||
options={rows.map((row) => ({
|
||||
value: row.name,
|
||||
label:
|
||||
stateEntry.source === 'default' && row.name === stateEntry.backend
|
||||
? `${row.name} (${t('storage.categories.default')})`
|
||||
: row.name,
|
||||
}))}
|
||||
size="sm"
|
||||
style={{ maxWidth: 320 }}
|
||||
/>
|
||||
{changed && (
|
||||
<p role="alert" className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.categories.reassignWarning')}
|
||||
</p>
|
||||
)}
|
||||
{viaMirror && CACHE_CATEGORIES.includes(category) && (
|
||||
<p role="note" className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.cacheWarning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button"
|
||||
onClick={save}
|
||||
disabled={!admin.dirty || admin.saving}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: 10, cursor: admin.dirty && !admin.saving ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)', color: 'var(--text-primary)',
|
||||
opacity: admin.dirty && !admin.saving ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{t('storage.save')}
|
||||
</button>
|
||||
{admin.dirty && <span className="text-xs text-content-faint">{t('storage.unsaved')}</span>}
|
||||
</div>
|
||||
{migrationQueue.length > 0 && (
|
||||
<p className="text-xs mt-2 text-content-faint">
|
||||
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
|
||||
</p>
|
||||
)}
|
||||
{migratePromptOpen && (
|
||||
<div className="rounded-lg border px-3 py-2 mt-2 border-edge bg-surface-secondary" role="alertdialog">
|
||||
<p className="text-sm text-content">{t('storage.migrate.promptTitle')}</p>
|
||||
{migrationCandidates.map((c) => (
|
||||
<p key={c.category} className="text-xs mt-1 text-content-secondary">
|
||||
{c.objects === null
|
||||
? t('storage.migrate.promptLineUnknown', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})
|
||||
: t('storage.migrate.promptLine', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
objects: String(c.objects),
|
||||
size: formatBytes(c.bytes ?? 0),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void moveAndSave()}
|
||||
>
|
||||
{t('storage.migrate.move')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void routeOnlySave()}
|
||||
>
|
||||
{t('storage.migrate.routeOnly')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => setMigratePromptOpen(false)}
|
||||
>
|
||||
{t('storage.migrate.promptCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{admin.saveError && (
|
||||
<div role="alert" className="text-sm mt-2 text-content">
|
||||
<p>{admin.saveError}</p>
|
||||
{/* A 409 leaves the draft pinned to a version the server has moved
|
||||
past; "save again" can never clear it, so the banner offers the
|
||||
one action that can — see useStorageAdmin.discardDraft. */}
|
||||
{admin.saveConflict && (
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void admin.discardDraft()}
|
||||
>
|
||||
{t('storage.discardAndReload')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmRemove !== null}
|
||||
onClose={() => setConfirmRemove(null)}
|
||||
onConfirm={() => {
|
||||
if (confirmRemove) {
|
||||
admin.setDraft(
|
||||
confirmRemove.degenerate
|
||||
? removeBackend(draft, confirmRemove.name)
|
||||
: removeBackendAndMirrors(state, draft, confirmRemove.name),
|
||||
)
|
||||
}
|
||||
setConfirmRemove(null)
|
||||
}}
|
||||
title={t('storage.remove.title')}
|
||||
message={confirmRemove ? removeMessage(confirmRemove.name, confirmRemove.degenerate) : ''}
|
||||
confirmLabel={t('storage.remove.title')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MASKED_SETTING_VALUE, type StorageBackend } from '@trek/shared';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import BackendForm from './BackendForm';
|
||||
|
||||
const NAMES = ['uploads-local', 'backups-local', 'off-box'];
|
||||
|
||||
type FormProps = Parameters<typeof BackendForm>[0];
|
||||
|
||||
function renderForm(overrides: Partial<FormProps> = {}) {
|
||||
const onCommit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<BackendForm
|
||||
initial={null}
|
||||
backendNames={NAMES}
|
||||
onCommit={onCommit}
|
||||
onCancel={onCancel}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return { onCommit, onCancel };
|
||||
}
|
||||
|
||||
const S3_INITIAL: StorageBackend = {
|
||||
name: 'off-box',
|
||||
type: 's3',
|
||||
options: {
|
||||
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak',
|
||||
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
|
||||
},
|
||||
};
|
||||
|
||||
describe('BackendForm', () => {
|
||||
it('FE-ADMIN-STORF-001: local renders the root path field from the registry', () => {
|
||||
renderForm();
|
||||
expect(screen.getByLabelText(/Root directory/)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Bucket/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-002: s3 renders every field by kind — password secret, number inputs, defaultValue placeholders', () => {
|
||||
renderForm({ initial: S3_INITIAL });
|
||||
expect(screen.getByLabelText(/Endpoint URL/)).toHaveAttribute('type', 'text');
|
||||
expect(screen.getByLabelText(/Secret access key/)).toHaveAttribute('type', 'password');
|
||||
expect(screen.getByLabelText(/Region/)).toHaveAttribute('placeholder', 'us-east-1');
|
||||
expect(screen.getByLabelText(/Retries/)).toHaveAttribute('type', 'number');
|
||||
expect(screen.getByLabelText(/Retries/)).toHaveAttribute('placeholder', '1');
|
||||
expect(screen.getByLabelText(/Timeout \(ms\)/)).toHaveAttribute('placeholder', '30000');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-004: the mask echoes back through commit untouched (no-op by contract)', () => {
|
||||
const { onCommit } = renderForm({ initial: S3_INITIAL });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(onCommit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'off-box',
|
||||
type: 's3',
|
||||
options: expect.objectContaining({ secretAccessKey: MASKED_SETTING_VALUE, retries: 1, timeoutMs: 30000 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-005: a typed plaintext secret keeps Apply offered (no encryption-key gate)', () => {
|
||||
renderForm({ initial: S3_INITIAL });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk-new' } });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-006: Apply stays disabled until name and every required field are filled', () => {
|
||||
const { onCommit } = renderForm();
|
||||
const apply = screen.getByRole('button', { name: 'Apply' });
|
||||
expect(apply).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'nas' } });
|
||||
expect(apply).toBeDisabled(); // root still empty
|
||||
fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/mnt/nas' } });
|
||||
expect(apply).toBeEnabled();
|
||||
fireEvent.click(apply);
|
||||
expect(onCommit).toHaveBeenCalledWith({ name: 'nas', type: 'local', options: { root: '/mnt/nas' } });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-007: a duplicate name on a NEW backend warns and blocks Apply', () => {
|
||||
renderForm();
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } });
|
||||
fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/x' } });
|
||||
expect(screen.getByText(/A backend named off-box already exists/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-008: empty optional fields are omitted so the shared schema defaults apply', () => {
|
||||
const { onCommit } = renderForm();
|
||||
// Switch to s3 via the type select (CustomSelect renders options into a portal).
|
||||
fireEvent.click(screen.getByText('Local'));
|
||||
fireEvent.click(screen.getByText('S3'));
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'fresh' } });
|
||||
fireEvent.change(screen.getByLabelText(/Endpoint URL/), { target: { value: 'http://127.0.0.1:9000' } });
|
||||
fireEvent.change(screen.getByLabelText(/Bucket/), { target: { value: 'trek' } });
|
||||
fireEvent.change(screen.getByLabelText(/Access key ID/), { target: { value: 'ak' } });
|
||||
fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
const committed = onCommit.mock.calls[0]![0] as StorageBackend;
|
||||
expect(committed.options).toEqual({
|
||||
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak', secretAccessKey: 'sk',
|
||||
});
|
||||
expect(committed.options).not.toHaveProperty('region');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-009: renaming onto another existing backend warns and blocks Apply', () => {
|
||||
renderForm({ initial: S3_INITIAL });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'uploads-local' } });
|
||||
expect(screen.getByText(/A backend named uploads-local already exists/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
// Keeping the original name stays allowed.
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-010: the type select no longer offers Mirror', () => {
|
||||
renderForm();
|
||||
fireEvent.click(screen.getByText('Local')); // open the type select
|
||||
expect(screen.queryByText('Mirror')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('S3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-011: the mirror composer renders candidates minus self and commits targets', () => {
|
||||
const { onCommit } = renderForm({
|
||||
initial: S3_INITIAL,
|
||||
mirror: { candidates: ['uploads-local', 'backups-local', 'off-box'], initialTargets: ['backups-local'] },
|
||||
});
|
||||
expect(screen.getByText('Mirror targets')).toBeInTheDocument();
|
||||
const boxes = screen.getAllByRole('checkbox');
|
||||
expect(boxes).toHaveLength(2); // off-box (self) excluded
|
||||
expect(screen.getByRole('checkbox', { name: 'backups-local' })).toBeChecked();
|
||||
expect(screen.getByRole('note').textContent).toContain('slows every upload');
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(onCommit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'off-box', type: 's3' }),
|
||||
['backups-local', 'uploads-local'],
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-012: unchecking every target commits an empty array (dissolve) and hides the latency note', () => {
|
||||
const { onCommit } = renderForm({
|
||||
initial: S3_INITIAL,
|
||||
mirror: { candidates: ['uploads-local'], initialTargets: ['uploads-local'] },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' }));
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(onCommit).toHaveBeenCalledWith(expect.objectContaining({ name: 'off-box' }), []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
STORAGE_BACKEND_TYPES,
|
||||
STORAGE_BACKEND_TYPE_IDS,
|
||||
type StorageBackend,
|
||||
type StorageBackendFieldDef,
|
||||
type StorageBackendTypeId,
|
||||
} from '@trek/shared'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import CustomSelect from '../../shared/CustomSelect'
|
||||
|
||||
type FieldValues = Record<string, string | string[]>
|
||||
|
||||
function valuesOf(backend: StorageBackend | null): FieldValues {
|
||||
if (!backend) return {}
|
||||
const values: FieldValues = {}
|
||||
for (const [key, value] of Object.entries(backend.options)) {
|
||||
values[key] = Array.isArray(value) ? value : String(value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
export interface BackendFormMirrorProps {
|
||||
/** Selectable replica targets (the form excludes its own name at render). */
|
||||
candidates: string[]
|
||||
initialTargets: string[]
|
||||
}
|
||||
|
||||
interface BackendFormProps {
|
||||
/** null = new backend */
|
||||
initial: StorageBackend | null
|
||||
/** Every defined backend name — mirror-target options and the duplicate pre-check. */
|
||||
backendNames: string[]
|
||||
/**
|
||||
* Present on non-mirror backends: renders the Mirror-targets composer
|
||||
* (replicas-on-primary — panel-supplied chrome, never a registry field).
|
||||
* When present, onCommit's second argument is always an array.
|
||||
*/
|
||||
mirror?: BackendFormMirrorProps
|
||||
onCommit: (backend: StorageBackend, mirrorTargets?: string[]) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const LABEL_CLASS = 'block text-sm font-medium mb-1.5 text-content-secondary'
|
||||
const INPUT_CLASS =
|
||||
'mt-1.5 w-full px-3 py-2 border rounded-lg text-sm border-edge bg-surface-card text-content'
|
||||
|
||||
/**
|
||||
* Renders whatever STORAGE_BACKEND_TYPES declares, by field kind. The raw
|
||||
* `mirror` type is hidden from the type select — mirrors are composed via the
|
||||
* Mirror-targets prop block and synthesized by the panels (the ref-kind
|
||||
* renderers below stay for future registry types).
|
||||
*/
|
||||
export default function BackendForm({
|
||||
initial,
|
||||
backendNames,
|
||||
mirror,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: BackendFormProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const [type, setType] = useState<StorageBackendTypeId>(initial?.type ?? 'local')
|
||||
const [name, setName] = useState(initial?.name ?? '')
|
||||
const [values, setValues] = useState<FieldValues>(() => valuesOf(initial))
|
||||
const [targets, setTargets] = useState<string[]>(mirror?.initialTargets ?? [])
|
||||
|
||||
const fields = STORAGE_BACKEND_TYPES[type].fields as readonly StorageBackendFieldDef[]
|
||||
const refOptions = backendNames.filter((candidate) => candidate !== name.trim())
|
||||
const setValue = (key: string, value: string | string[]) =>
|
||||
setValues((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
const filled = (field: StorageBackendFieldDef): boolean => {
|
||||
const value = values[field.key]
|
||||
if (field.kind === 'backend-ref-list') return Array.isArray(value) && value.length > 0
|
||||
return typeof value === 'string' && value.trim() !== ''
|
||||
}
|
||||
const duplicate = name.trim() !== (initial?.name ?? '') && backendNames.includes(name.trim())
|
||||
const canApply =
|
||||
name.trim() !== '' && !duplicate && fields.every((f) => !f.required || filled(f))
|
||||
|
||||
const apply = () => {
|
||||
const options: Record<string, unknown> = {}
|
||||
for (const field of fields) {
|
||||
const value = values[field.key]
|
||||
if (field.kind === 'backend-ref-list') {
|
||||
options[field.key] = Array.isArray(value) ? value : []
|
||||
continue
|
||||
}
|
||||
const text = typeof value === 'string' ? value : ''
|
||||
if (text === '' && !field.required) continue // omitted → the shared schema default applies
|
||||
options[field.key] = field.kind === 'number' ? Number(text) : text
|
||||
}
|
||||
// The options were built from the same field defs the schema is generated
|
||||
// from — this is the same sanctioned cast storageModel.asWireBackend makes.
|
||||
const payload = { name: name.trim(), type, options } as StorageBackend
|
||||
// Arity matters: the landed tests pin single-argument calls when no mirror
|
||||
// block is supplied (toHaveBeenCalledWith treats a trailing undefined as a
|
||||
// mismatch), so the second argument exists only when the composer does.
|
||||
if (mirror) onCommit(payload, targets)
|
||||
else onCommit(payload)
|
||||
}
|
||||
|
||||
const renderField = (field: StorageBackendFieldDef): React.ReactElement => {
|
||||
const value = values[field.key]
|
||||
if (field.kind === 'backend-ref') {
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
|
||||
<CustomSelect
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(next) => setValue(field.key, String(next))}
|
||||
options={refOptions.map((candidate) => ({ value: candidate, label: candidate }))}
|
||||
placeholder={t(field.labelKey)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (field.kind === 'backend-ref-list') {
|
||||
const selected = Array.isArray(value) ? value : []
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
|
||||
<div className="space-y-1">
|
||||
{refOptions.map((candidate) => (
|
||||
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(candidate)}
|
||||
onChange={(e) =>
|
||||
setValue(
|
||||
field.key,
|
||||
e.target.checked
|
||||
? [...selected, candidate]
|
||||
: selected.filter((existing) => existing !== candidate),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{candidate}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const inputType = field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'
|
||||
return (
|
||||
<label key={field.key} className={LABEL_CLASS}>
|
||||
{t(field.labelKey)}
|
||||
<input
|
||||
type={inputType}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => setValue(field.key, e.target.value)}
|
||||
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : ''}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
{field.helpKey && <span className="block text-xs mt-1 font-normal text-content-faint">{t(field.helpKey)}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border p-4 space-y-4 border-edge bg-surface-card">
|
||||
<p className="text-sm font-semibold text-content">
|
||||
{initial ? t('storage.form.editTitle') : t('storage.form.addTitle')}
|
||||
</p>
|
||||
|
||||
<label className={LABEL_CLASS}>
|
||||
{t('storage.form.name')}
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</label>
|
||||
{duplicate && (
|
||||
<p className="text-xs text-content-faint" role="alert">
|
||||
{t('storage.form.duplicateName', { name: name.trim() })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>{t('storage.form.type')}</span>
|
||||
<CustomSelect
|
||||
value={type}
|
||||
onChange={(next) => {
|
||||
setType(next as StorageBackendTypeId)
|
||||
setValues({}) // a different type has different fields
|
||||
}}
|
||||
options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({
|
||||
value: id,
|
||||
label: t(`storage.type.${id}`),
|
||||
}))}
|
||||
size="sm"
|
||||
disabled={initial !== null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fields.map(renderField)}
|
||||
|
||||
{mirror && (
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>{t('storage.mirror.targets')}</span>
|
||||
<p className="text-xs mb-1 text-content-faint">{t('storage.mirror.targetsHelp')}</p>
|
||||
<div className="space-y-1">
|
||||
{mirror.candidates
|
||||
.filter((candidate) => candidate !== name.trim())
|
||||
.map((candidate) => (
|
||||
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={targets.includes(candidate)}
|
||||
onChange={(e) =>
|
||||
setTargets(
|
||||
e.target.checked
|
||||
? [...targets, candidate]
|
||||
: targets.filter((existing) => existing !== candidate),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{candidate}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{targets.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint" role="note">
|
||||
{t('storage.mirror.latencyNote')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button"
|
||||
onClick={apply}
|
||||
disabled={!canApply}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: canApply ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)',
|
||||
color: 'var(--text-primary)', opacity: canApply ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{t('storage.form.apply')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('storage.form.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MASKED_SETTING_VALUE,
|
||||
STORAGE_CATEGORIES,
|
||||
type StorageAdminState,
|
||||
type StorageBackend,
|
||||
type StorageCategory,
|
||||
type StorageConfig,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
categoriesPointingAt,
|
||||
computeMigrationCandidates,
|
||||
foldBackends,
|
||||
mirrorProbeTargets,
|
||||
mirrorsReferencing,
|
||||
primaryNameOf,
|
||||
removeBackend,
|
||||
removeBackendAndMirrors,
|
||||
renameBackendRefs,
|
||||
replicaCandidates,
|
||||
replicaOfPrimaries,
|
||||
setMirrorTargets,
|
||||
settingsDocumentOf,
|
||||
stripCategories,
|
||||
upsertBackend,
|
||||
usageByBackend,
|
||||
} from './storageModel';
|
||||
|
||||
const STATE: StorageAdminState = {
|
||||
backends: [
|
||||
{ name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files'] },
|
||||
{ name: 'place-photos-local', type: 'local', source: 'env', options: { root: '/photos' }, categories: ['places'] },
|
||||
{
|
||||
name: 'off-box',
|
||||
type: 's3',
|
||||
source: 'settings',
|
||||
options: {
|
||||
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak',
|
||||
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
|
||||
},
|
||||
categories: ['covers'],
|
||||
},
|
||||
],
|
||||
categories: {
|
||||
files: { backend: 'uploads-local', source: 'default' },
|
||||
journey: { backend: 'uploads-local', source: 'default' },
|
||||
covers: { backend: 'off-box', source: 'settings' },
|
||||
avatars: { backend: 'uploads-local', source: 'default' },
|
||||
places: { backend: 'place-photos-local', source: 'default' },
|
||||
'photos-google': { backend: 'place-photos-local', source: 'default' },
|
||||
'photos-trek': { backend: 'uploads-local', source: 'default' },
|
||||
backups: { backend: 'backups-local', source: 'default' },
|
||||
},
|
||||
health: { replicaFailures: [] },
|
||||
seedFilePresent: false,
|
||||
usage: null,
|
||||
backfills: [],
|
||||
migrations: [],
|
||||
version: 5,
|
||||
configError: null,
|
||||
};
|
||||
|
||||
describe('settingsDocumentOf', () => {
|
||||
it('FE-ADMIN-STORM-001: carries ONLY settings-sourced backends and categories (the PUT contract)', () => {
|
||||
const doc = settingsDocumentOf(STATE);
|
||||
expect(doc.backends.map((b) => b.name)).toEqual(['off-box']);
|
||||
expect(doc.categories).toEqual({ covers: 'off-box' });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-001b: carries version from state (audit #7 — the PUT body\'s optimistic-concurrency check)', () => {
|
||||
expect(settingsDocumentOf(STATE).version).toBe(5);
|
||||
expect(settingsDocumentOf({ ...STATE, version: 12 }).version).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove pre-check helpers', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [
|
||||
{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } },
|
||||
{ name: 'mir', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas'] } },
|
||||
],
|
||||
categories: { covers: 'nas', backups: 'mir' },
|
||||
};
|
||||
|
||||
it('FE-ADMIN-STORM-002: lists categories pointing at a backend', () => {
|
||||
expect(categoriesPointingAt(draft, 'nas')).toEqual(['covers']);
|
||||
expect(categoriesPointingAt(draft, 'unused')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-003: lists mirrors referencing a backend (primary or replica)', () => {
|
||||
expect(mirrorsReferencing(draft, 'nas')).toEqual(['mir']);
|
||||
expect(mirrorsReferencing(draft, 'backups-local')).toEqual(['mir']);
|
||||
expect(mirrorsReferencing(draft, 'unused')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('draft edits', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } }],
|
||||
categories: {},
|
||||
};
|
||||
|
||||
it('FE-ADMIN-STORM-005: upsert replaces by name in place, appends new ones, never mutates', () => {
|
||||
const edited = upsertBackend(draft, { name: 'nas', type: 'local', options: { root: '/mnt/nas2' } });
|
||||
expect(edited.backends).toEqual([{ name: 'nas', type: 'local', options: { root: '/mnt/nas2' } }]);
|
||||
const added = upsertBackend(draft, { name: 'other', type: 'local', options: { root: '/o' } });
|
||||
expect(added.backends.map((b) => b.name)).toEqual(['nas', 'other']);
|
||||
expect(draft.backends).toHaveLength(1); // untouched
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-006: removeBackend filters by name', () => {
|
||||
expect(removeBackend(draft, 'nas').backends).toEqual([]);
|
||||
expect(removeBackend(draft, 'ghost').backends).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Hoisted to module scope (not just the mirror-fold describe below) so the
|
||||
// usageByBackend suite can reuse the same mirrored fixture the brief's test
|
||||
// snippet assumes is in scope.
|
||||
const MIRRORED_STATE: StorageAdminState = {
|
||||
...STATE,
|
||||
backends: [
|
||||
...STATE.backends,
|
||||
{
|
||||
name: 'mirror', type: 'mirror', source: 'settings',
|
||||
options: { primary: 'backups-local', replicas: ['off-box'] }, categories: ['backups'],
|
||||
},
|
||||
{ name: 'backups-local', type: 'local', source: 'built-in', options: { root: '/data/backups' }, categories: [] },
|
||||
],
|
||||
categories: { ...STATE.categories, backups: { backend: 'mirror', source: 'settings' } },
|
||||
};
|
||||
const mirroredDraft = (): StorageConfig => settingsDocumentOf(MIRRORED_STATE);
|
||||
|
||||
describe('mirror fold/synthesize (replicas-on-primary)', () => {
|
||||
it('FE-ADMIN-STORM-007: foldBackends hides the mirror, decorates primary and replica, unions categories', () => {
|
||||
const { rows, degenerate } = foldBackends(MIRRORED_STATE, mirroredDraft());
|
||||
expect(degenerate).toEqual([]);
|
||||
expect(rows.map((r) => r.name)).not.toContain('mirror');
|
||||
const primary = rows.find((r) => r.name === 'backups-local')!;
|
||||
expect(primary.mirrorTargets).toEqual(['off-box']);
|
||||
expect(primary.mirrorName).toBe('mirror');
|
||||
expect(primary.categories).toContain('backups'); // via the mirror
|
||||
const replica = rows.find((r) => r.name === 'off-box')!;
|
||||
expect(replica.replicaOf).toEqual(['backups-local']);
|
||||
expect(replica.categories).toContain('covers'); // its own direct assignment survives
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-008: setMirrorTargets synthesizes a mirror and reroutes ALL effective categories of the primary, defaults included', () => {
|
||||
const draft = settingsDocumentOf(STATE); // no mirror yet; backups is default → backups-local
|
||||
const next = setMirrorTargets(STATE, draft, 'backups-local', ['off-box']);
|
||||
const mirror = next.backends.find((b) => b.type === 'mirror')!;
|
||||
expect(mirror.name).toBe('backups-local-mirror');
|
||||
expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['off-box'] });
|
||||
expect(next.categories.backups).toBe('backups-local-mirror'); // default-sourced category rewritten
|
||||
expect(next.categories.covers).toBe('off-box'); // categories of OTHER backends untouched
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-009: setMirrorTargets adopts a foreign-named mirror in place', () => {
|
||||
const next = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']);
|
||||
const mirrors = next.backends.filter((b) => b.type === 'mirror');
|
||||
expect(mirrors).toHaveLength(1);
|
||||
expect(mirrors[0]!.name).toBe('mirror'); // adopted, not renamed
|
||||
expect(mirrors[0]!.options).toEqual({ primary: 'backups-local', replicas: ['off-box', 'uploads-local'] });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-010: empty targets dissolve the mirror and revert default-sourced categories', () => {
|
||||
// Build the synthesized state locally: default backups routed via a fresh mirror…
|
||||
const withMirror = setMirrorTargets(STATE, settingsDocumentOf(STATE), 'backups-local', ['off-box']);
|
||||
// …then dissolve it: the backups entry must DROP (state says its default is backups-local).
|
||||
const dissolved = setMirrorTargets(STATE, withMirror, 'backups-local', []);
|
||||
expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false);
|
||||
expect(dissolved.categories).not.toHaveProperty('backups');
|
||||
// Against MIRRORED_STATE (backups already settings-sourced at the mirror), dissolve re-points instead of dropping.
|
||||
const repointed = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', []);
|
||||
expect(repointed.categories.backups).toBe('backups-local');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-011: degenerate mirrors are classified, never hidden', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [
|
||||
{ name: 'off-box', type: 's3', options: STATE.backends[2]!.options } as StorageBackend,
|
||||
{ name: 'm1', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } },
|
||||
{ name: 'm2', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } },
|
||||
{ name: 'm-env', type: 'mirror', options: { primary: 'place-photos-local', replicas: ['off-box'] } },
|
||||
{ name: 'm-ghost', type: 'mirror', options: { primary: 'nope', replicas: [] } },
|
||||
],
|
||||
categories: {},
|
||||
};
|
||||
const { rows, degenerate } = foldBackends(STATE, draft);
|
||||
expect(rows.find((r) => r.name === 'uploads-local')!.mirrorName).toBe('m1'); // first wins
|
||||
expect(degenerate.map((d) => [d.backend.name, d.reason])).toEqual([
|
||||
['m2', 'duplicate-mirror'],
|
||||
['m-env', 'env-primary'],
|
||||
['m-ghost', 'missing-primary'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-012: renameBackendRefs rewrites mirror primaries, replicas, and category entries', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [
|
||||
{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } },
|
||||
{ name: 'm', type: 'mirror', options: { primary: 'nas', replicas: ['nas'] } },
|
||||
],
|
||||
categories: { covers: 'nas' },
|
||||
};
|
||||
const renamed = renameBackendRefs(draft, 'nas', 'nas2');
|
||||
const mirror = renamed.backends.find((b) => b.type === 'mirror')!;
|
||||
expect(mirror.options).toEqual({ primary: 'nas2', replicas: ['nas2'] });
|
||||
expect(renamed.categories.covers).toBe('nas2');
|
||||
expect(renamed.backends[0]!.name).toBe('nas'); // the backend row itself is the caller's job
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-013: removeBackendAndMirrors cascades; replicaOfPrimaries phrases in primary names', () => {
|
||||
const draft = mirroredDraft();
|
||||
expect(replicaOfPrimaries(draft, 'off-box')).toEqual(['backups-local']);
|
||||
const removed = removeBackendAndMirrors(MIRRORED_STATE, draft, 'backups-local');
|
||||
expect(removed.backends.some((b) => b.name === 'mirror')).toBe(false);
|
||||
expect(removed.backends.some((b) => b.name === 'backups-local')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-015: removing a replica strips it from remaining mirrors; a mirror stripped empty dissolves', () => {
|
||||
// Single-replica mirror: stripping off-box empties it → dissolve + re-point
|
||||
// (backups is settings-sourced at the mirror in MIRRORED_STATE, so it re-points, not drops).
|
||||
const dissolved = removeBackendAndMirrors(MIRRORED_STATE, mirroredDraft(), 'off-box');
|
||||
expect(dissolved.backends.some((b) => b.name === 'off-box')).toBe(false);
|
||||
expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false);
|
||||
expect(dissolved.categories.backups).toBe('backups-local');
|
||||
// Multi-replica mirror: stripping one target keeps the mirror alive with the rest.
|
||||
const twoTargets = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']);
|
||||
const survived = removeBackendAndMirrors(MIRRORED_STATE, twoTargets, 'off-box');
|
||||
const mirror = survived.backends.find((b) => b.type === 'mirror')!;
|
||||
expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['uploads-local'] });
|
||||
expect(survived.categories.backups).toBe('mirror'); // still routed via the surviving mirror
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-014: primaryNameOf maps mirror names (draft or state) to their primary; others pass through', () => {
|
||||
expect(primaryNameOf(MIRRORED_STATE, mirroredDraft(), 'mirror')).toBe('backups-local');
|
||||
expect(primaryNameOf(MIRRORED_STATE, { backends: [], categories: {} }, 'mirror')).toBe('backups-local'); // state fallback
|
||||
expect(primaryNameOf(STATE, settingsDocumentOf(STATE), 'uploads-local')).toBe('uploads-local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replicaCandidates', () => {
|
||||
it('FE-ADMIN-STORM-020: offers only rows that serve no category, never the row being edited', () => {
|
||||
const { rows } = foldBackends(MIRRORED_STATE, mirroredDraft());
|
||||
// uploads-local/place-photos-local serve categories directly;
|
||||
// backups-local serves backups through the mirror it is the primary of;
|
||||
// off-box carries a direct `covers` assignment. Nothing is offerable.
|
||||
expect(replicaCandidates(rows, 'backups-local')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-021: keeps an already-selected target listed so it can be unchecked', () => {
|
||||
const { rows } = foldBackends(MIRRORED_STATE, mirroredDraft());
|
||||
expect(replicaCandidates(rows, 'backups-local', ['off-box'])).toEqual(['off-box']);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-022: an unassigned row is offered', () => {
|
||||
const state: StorageAdminState = {
|
||||
...MIRRORED_STATE,
|
||||
backends: [
|
||||
...MIRRORED_STATE.backends,
|
||||
{ name: 'cold-store', type: 'local', source: 'settings', options: { root: '/data/cold' }, categories: [] },
|
||||
],
|
||||
};
|
||||
const { rows } = foldBackends(state, settingsDocumentOf(state));
|
||||
expect(replicaCandidates(rows, 'backups-local')).toEqual(['cold-store']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usageByBackend', () => {
|
||||
it('FE-ADMIN-STORM-016: sums categories onto their effective backend rows, mirrors onto the primary, legacy onto uploads-local', () => {
|
||||
const usage = {
|
||||
computedAt: 1,
|
||||
categories: Object.fromEntries(
|
||||
STORAGE_CATEGORIES.map((c) => [c, { objects: 1, bytes: 10 }]),
|
||||
) as Record<StorageCategory, { objects: number; bytes: number }>,
|
||||
legacyPhotos: { objects: 5, bytes: 50 },
|
||||
};
|
||||
const state = { ...MIRRORED_STATE, usage };
|
||||
const sums = usageByBackend(state, mirroredDraft())!;
|
||||
// backups routes via the mirror → attributed to backups-local (the primary row).
|
||||
expect(sums['backups-local']).toEqual({ objects: 1, bytes: 10 });
|
||||
// uploads-local serves 4 direct categories (files, journey, avatars, photos-trek —
|
||||
// this fixture's `places` and `photos-google` default to the env-sourced
|
||||
// place-photos-local backend, not uploads-local) + legacy photos.
|
||||
expect(sums['uploads-local']).toEqual({ objects: 4 + 5, bytes: 40 + 50 });
|
||||
// places + photos-google both default to place-photos-local.
|
||||
expect(sums['place-photos-local']).toEqual({ objects: 2, bytes: 20 });
|
||||
// covers routes to off-box directly.
|
||||
expect(sums['off-box']).toEqual({ objects: 1, bytes: 10 });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-017: null usage yields null', () => {
|
||||
expect(usageByBackend({ ...STATE, usage: null }, settingsDocumentOf(STATE))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeMigrationCandidates', () => {
|
||||
it('FE-ADMIN-STORM-018: lists changed categories with usage counts; zero-object ones are excluded; null usage keeps them with null counts', () => {
|
||||
const state: StorageAdminState = {
|
||||
...STATE,
|
||||
usage: {
|
||||
computedAt: 1,
|
||||
categories: {
|
||||
files: { objects: 3, bytes: 300 },
|
||||
journey: { objects: 0, bytes: 0 },
|
||||
} as Record<StorageCategory, { objects: number; bytes: number }>,
|
||||
legacyPhotos: { objects: 0, bytes: 0 },
|
||||
},
|
||||
};
|
||||
const base = settingsDocumentOf(state);
|
||||
const draft: StorageConfig = {
|
||||
...base,
|
||||
categories: { ...base.categories, files: 'off-box', journey: 'off-box', avatars: 'off-box' },
|
||||
};
|
||||
const candidates = computeMigrationCandidates(draft, state);
|
||||
expect(candidates).toEqual([
|
||||
{ category: 'files', from: 'uploads-local', to: 'off-box', toWire: 'off-box', objects: 3, bytes: 300 },
|
||||
{ category: 'avatars', from: 'uploads-local', to: 'off-box', toWire: 'off-box', objects: null, bytes: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-018b: reassigning onto a mirrored primary yields toWire = the mirror wire name while `to` stays the display primary', () => {
|
||||
const state: StorageAdminState = {
|
||||
...STATE,
|
||||
backends: [
|
||||
...STATE.backends,
|
||||
{ name: 'nas', type: 'local', source: 'settings', options: { root: '/mnt/nas' }, categories: [] },
|
||||
{
|
||||
name: 'off-box-mirror',
|
||||
type: 'mirror',
|
||||
source: 'settings',
|
||||
options: { primary: 'off-box', replicas: ['nas'] },
|
||||
categories: [],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
computedAt: 1,
|
||||
categories: { files: { objects: 3, bytes: 300 } } as Record<StorageCategory, { objects: number; bytes: number }>,
|
||||
legacyPhotos: { objects: 0, bytes: 0 },
|
||||
},
|
||||
};
|
||||
const base = settingsDocumentOf(state);
|
||||
// Reassign 'files' to the mirrored primary's WIRE name — the same value
|
||||
// setCategory() writes into the draft when the operator picks a mirrored
|
||||
// primary in the UI (adoptedMirrorFor(draft, 'off-box')?.name).
|
||||
const draft: StorageConfig = { ...base, categories: { ...base.categories, files: 'off-box-mirror' } };
|
||||
const candidates = computeMigrationCandidates(draft, state);
|
||||
expect(candidates).toEqual([
|
||||
{ category: 'files', from: 'uploads-local', to: 'off-box', toWire: 'off-box-mirror', objects: 3, bytes: 300 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-019: stripCategories restores the saved assignment for exactly the named categories', () => {
|
||||
const base = settingsDocumentOf(STATE);
|
||||
const draft: StorageConfig = {
|
||||
...base,
|
||||
categories: { ...base.categories, files: 'off-box', covers: 'uploads-local' },
|
||||
};
|
||||
|
||||
const strippedFiles = stripCategories(draft, STATE, ['files']);
|
||||
expect(strippedFiles.categories.files).toBeUndefined(); // default-sourced: saved value is "no override"
|
||||
expect(strippedFiles.categories.covers).toBe('uploads-local'); // untouched — not in the strip list
|
||||
|
||||
const strippedCovers = stripCategories(draft, STATE, ['covers']);
|
||||
expect(strippedCovers.categories.covers).toBe('off-box'); // settings-sourced saved value restored explicitly
|
||||
expect(strippedCovers.categories.files).toBe('off-box'); // untouched — not in the strip list
|
||||
});
|
||||
});
|
||||
|
||||
describe('mirrorProbeTargets', () => {
|
||||
it('FE-ADMIN-STORM-020: a non-mirror candidate passes through unchanged', () => {
|
||||
const local: StorageBackend = { name: 'uploads-local', type: 'local', options: { root: '/data/uploads' } };
|
||||
expect(mirrorProbeTargets(settingsDocumentOf(STATE), STATE, local)).toEqual([local]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-021: expands primary + replicas — draft override wins per name, falling back to state for names the draft never touched', () => {
|
||||
const mirror: StorageBackend = {
|
||||
name: 'mirror', type: 'mirror',
|
||||
options: { primary: 'uploads-local', replicas: ['off-box'] },
|
||||
};
|
||||
// The draft carries an EDITED off-box (unsaved endpoint change) but never
|
||||
// touches uploads-local (a built-in, never draft-owned).
|
||||
const editedOffBox = {
|
||||
name: 'off-box', type: 's3',
|
||||
options: { ...(STATE.backends[2]!.options as Record<string, unknown>), endpoint: 'http://edited:9000' },
|
||||
} as StorageBackend;
|
||||
const draft: StorageConfig = { backends: [editedOffBox, mirror], categories: {} };
|
||||
|
||||
const targets = mirrorProbeTargets(draft, STATE, mirror);
|
||||
expect(targets.map((t) => t.name)).toEqual(['uploads-local', 'off-box']);
|
||||
// Primary: no draft entry — resolved from state (the live built-in).
|
||||
expect(targets[0]).toEqual({ name: 'uploads-local', type: 'local', options: { root: '/data/uploads' } });
|
||||
// Replica: draft override wins — the unsaved edit, not the saved options.
|
||||
expect((targets[1]!.options as Record<string, unknown>).endpoint).toBe('http://edited:9000');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-022: a name resolving to neither draft nor state is dropped, not thrown', () => {
|
||||
const mirror: StorageBackend = {
|
||||
name: 'mirror', type: 'mirror',
|
||||
options: { primary: 'uploads-local', replicas: ['ghost'] },
|
||||
};
|
||||
const targets = mirrorProbeTargets(settingsDocumentOf(STATE), STATE, mirror);
|
||||
expect(targets.map((t) => t.name)).toEqual(['uploads-local']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import {
|
||||
STORAGE_CATEGORIES,
|
||||
type StorageAdminState,
|
||||
type StorageBackend,
|
||||
type StorageCategory,
|
||||
type StorageConfig,
|
||||
type StorageConfigPut,
|
||||
} from '@trek/shared'
|
||||
|
||||
/** One entry of the effective world (GET state), as the panels render it. */
|
||||
export type StateBackend = StorageAdminState['backends'][number]
|
||||
|
||||
/**
|
||||
* The settings-owned document the PUT carries: settings-sourced backends and
|
||||
* settings-sourced category assignments ONLY — built-ins and env backends are
|
||||
* never in the body unless the operator overrides one by name (in which case
|
||||
* the override already reports source 'settings'). Carries `version` from
|
||||
* the state it was built at (audit #7 — optimistic concurrency): the hook
|
||||
* re-attaches this same version on every subsequent local edit, so a PUT
|
||||
* always submits the version the operator's form was loaded at, not
|
||||
* whatever the server currently holds.
|
||||
*/
|
||||
export function settingsDocumentOf(state: StorageAdminState): StorageConfigPut {
|
||||
return {
|
||||
backends: state.backends.filter((b) => b.source === 'settings').map(asWireBackend),
|
||||
categories: Object.fromEntries(
|
||||
Object.entries(state.categories)
|
||||
.filter(([, entry]) => entry.source === 'settings')
|
||||
.map(([category, entry]) => [category, entry.backend]),
|
||||
) as StorageConfig['categories'],
|
||||
version: state.version,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State options are already schema-shaped (the wire contract guarantees it;
|
||||
* secret fields carry the mask, which the server unmasks by name on PUT/test) —
|
||||
* the cast re-attaches the discriminated-union type the loose state record dropped.
|
||||
*/
|
||||
export function asWireBackend(backend: Pick<StateBackend, 'name' | 'type' | 'options'>): StorageBackend {
|
||||
return { name: backend.name, type: backend.type, options: backend.options } as StorageBackend
|
||||
}
|
||||
|
||||
/** Draft categories pointing at a backend name (the friendly remove pre-check). */
|
||||
export function categoriesPointingAt(draft: StorageConfig, name: string): StorageCategory[] {
|
||||
return (Object.entries(draft.categories) as Array<[StorageCategory, string]>)
|
||||
.filter(([, backend]) => backend === name)
|
||||
.map(([category]) => category)
|
||||
}
|
||||
|
||||
/** Draft mirrors referencing a backend name as primary or replica (remove pre-check). */
|
||||
export function mirrorsReferencing(draft: StorageConfig, name: string): string[] {
|
||||
return draft.backends
|
||||
.filter((b) => b.type === 'mirror' && (b.options.primary === name || b.options.replicas.includes(name)))
|
||||
.map((b) => b.name)
|
||||
}
|
||||
|
||||
/** Replace by name in place, or append. Never mutates. */
|
||||
export function upsertBackend(draft: StorageConfig, backend: StorageBackend): StorageConfig {
|
||||
const exists = draft.backends.some((b) => b.name === backend.name)
|
||||
return {
|
||||
...draft,
|
||||
backends: exists
|
||||
? draft.backends.map((b) => (b.name === backend.name ? backend : b))
|
||||
: [...draft.backends, backend],
|
||||
}
|
||||
}
|
||||
|
||||
export function removeBackend(draft: StorageConfig, name: string): StorageConfig {
|
||||
return { ...draft, backends: draft.backends.filter((b) => b.name !== name) }
|
||||
}
|
||||
|
||||
// ── Mirror fold/synthesize (replicas-on-primary spec) ─────────────────────────
|
||||
// The draft stays the wire document; folding is a view rule, synthesis a set
|
||||
// of pure draft operations. Mirror NAMES never reach the UI — every helper
|
||||
// that renders phrases mirrors as their primary.
|
||||
|
||||
type MirrorBackend = Extract<StorageBackend, { type: 'mirror' }>
|
||||
const isMirror = (b: StorageBackend): b is MirrorBackend => b.type === 'mirror'
|
||||
|
||||
export interface FoldedBackendRow {
|
||||
name: string
|
||||
type: 'local' | 's3'
|
||||
source: 'built-in' | 'env' | 'settings'
|
||||
backend: StorageBackend
|
||||
/** Direct assignments ∪ assignments routed via the adopted mirror. */
|
||||
categories: StorageCategory[]
|
||||
/** Adopted mirror's replicas — [] when unmirrored. */
|
||||
mirrorTargets: string[]
|
||||
/** Adopted mirror's wire name — needed by draft ops, hidden from the UI. */
|
||||
mirrorName: string | null
|
||||
/** Primaries whose adopted mirror lists this backend as a replica. */
|
||||
replicaOf: string[]
|
||||
}
|
||||
|
||||
export interface DegenerateMirror {
|
||||
backend: StorageBackend
|
||||
reason: 'duplicate-mirror' | 'env-primary' | 'missing-primary'
|
||||
categories: StorageCategory[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetchable content — replicating it is usually wasteful. photos-google is
|
||||
* the Google photo cache (place-photo-cache.service.ts); places is mostly
|
||||
* provider-derived imagery.
|
||||
*/
|
||||
export const CACHE_CATEGORIES: readonly StorageCategory[] = ['photos-google', 'places']
|
||||
|
||||
/** The configurable categories resolved draft-over-state (draft entries win; defaults fill the rest). */
|
||||
export function effectiveCategoryMap(state: StorageAdminState, draft: StorageConfig): Record<StorageCategory, string> {
|
||||
const map = {} as Record<StorageCategory, string>
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
map[category] = draft.categories[category] ?? state.categories[category]!.backend
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** First draft mirror wrapping the named primary — the one the UI manages. */
|
||||
export function adoptedMirrorFor(draft: StorageConfig, primaryName: string): MirrorBackend | undefined {
|
||||
return draft.backends.filter(isMirror).find((m) => m.options.primary === primaryName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side mirror expansion for the test probe (audit fix — the server's
|
||||
* `testBackend` resolves a mirror candidate's primary/replicas against its
|
||||
* OWN live snapshot by name, so probing a mirrored row with unsaved draft
|
||||
* edits to those targets would silently test the SAVED options instead of
|
||||
* what's on the form). Draft overrides win per name, falling back to state
|
||||
* (built-in/env); a name that resolves to neither is dropped — that
|
||||
* primary/replica no longer exists, and the row already renders as
|
||||
* degenerate in that case.
|
||||
*/
|
||||
export function mirrorProbeTargets(
|
||||
draft: StorageConfig,
|
||||
state: StorageAdminState,
|
||||
mirror: StorageBackend,
|
||||
): StorageBackend[] {
|
||||
if (mirror.type !== 'mirror') return [mirror]
|
||||
const names = [mirror.options.primary, ...mirror.options.replicas]
|
||||
return names
|
||||
.map((name): StorageBackend | null => {
|
||||
const draftMatch = draft.backends.find((b) => b.name === name)
|
||||
if (draftMatch) return draftMatch
|
||||
const stateMatch = state.backends.find((b) => b.name === name)
|
||||
return stateMatch ? asWireBackend(stateMatch) : null
|
||||
})
|
||||
.filter((b): b is StorageBackend => b !== null)
|
||||
}
|
||||
|
||||
export function foldBackends(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
): { rows: FoldedBackendRow[]; degenerate: DegenerateMirror[] } {
|
||||
const draftNames = draft.backends.map((b) => b.name)
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const categoriesOf = (name: string): StorageCategory[] =>
|
||||
STORAGE_CATEGORIES.filter((category) => effective[category] === name)
|
||||
|
||||
// Base (non-mirror) rows, slice-2 precedence: built-ins/env from the state
|
||||
// unless a draft row overrides the name; settings rows from the draft.
|
||||
const base: Array<Pick<FoldedBackendRow, 'name' | 'type' | 'source' | 'backend'>> = []
|
||||
for (const b of state.backends) {
|
||||
if (b.type === 'mirror') continue // mirrors fold below; the draft owns them
|
||||
if (draftNames.includes(b.name)) continue
|
||||
if (b.source === 'settings') continue // removed from the draft, pending save
|
||||
base.push({ name: b.name, type: b.type, source: b.source, backend: asWireBackend(b) })
|
||||
}
|
||||
for (const b of draft.backends) {
|
||||
if (isMirror(b)) continue
|
||||
base.push({ name: b.name, type: b.type, source: 'settings', backend: b })
|
||||
}
|
||||
|
||||
// Adoption: the first mirror per editable primary; the rest are degenerate —
|
||||
// rendered unfolded with a reason, never silently dropped.
|
||||
const adopted = new Map<string, MirrorBackend>()
|
||||
const degenerate: DegenerateMirror[] = []
|
||||
for (const mirror of draft.backends.filter(isMirror)) {
|
||||
const primary = base.find((row) => row.name === mirror.options.primary)
|
||||
if (!primary) degenerate.push({ backend: mirror, reason: 'missing-primary', categories: categoriesOf(mirror.name) })
|
||||
else if (primary.source === 'env') degenerate.push({ backend: mirror, reason: 'env-primary', categories: categoriesOf(mirror.name) })
|
||||
else if (adopted.has(primary.name)) degenerate.push({ backend: mirror, reason: 'duplicate-mirror', categories: categoriesOf(mirror.name) })
|
||||
else adopted.set(primary.name, mirror)
|
||||
}
|
||||
|
||||
const rows = base.map((row): FoldedBackendRow => {
|
||||
const mirror = adopted.get(row.name)
|
||||
return {
|
||||
...row,
|
||||
categories: [...categoriesOf(row.name), ...(mirror ? categoriesOf(mirror.name) : [])],
|
||||
mirrorTargets: mirror ? [...mirror.options.replicas] : [],
|
||||
mirrorName: mirror?.name ?? null,
|
||||
replicaOf: [...adopted.values()]
|
||||
.filter((m) => m.options.replicas.includes(row.name))
|
||||
.map((m) => m.options.primary),
|
||||
}
|
||||
})
|
||||
return { rows, degenerate }
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows offerable as mirror replicas (the "Mirror targets" picker).
|
||||
*
|
||||
* A backend that already serves a category — directly, or as the primary of a
|
||||
* mirror a category routes to, which is exactly what `row.categories` unions —
|
||||
* can never also be a replica: the mirror's sync sweep makes each replica
|
||||
* MATCH the primary, deleting whatever the primary doesn't hold, and `backups`
|
||||
* sweeps the replica's entire root. The server refuses such a config outright
|
||||
* (storage-registry.service.ts, assertNoSharedReplicas); the picker simply
|
||||
* never offers one, so the operator can't walk into the refusal.
|
||||
*
|
||||
* Targets already selected on this mirror stay listed regardless — they must
|
||||
* remain visible to be unchecked (and a config that predates this rule is
|
||||
* repaired here, not hidden).
|
||||
*/
|
||||
export function replicaCandidates(
|
||||
rows: FoldedBackendRow[],
|
||||
selfName: string | null,
|
||||
currentTargets: readonly string[] = [],
|
||||
): string[] {
|
||||
return rows
|
||||
.filter((row) => row.name !== selfName)
|
||||
.filter((row) => row.categories.length === 0 || currentTargets.includes(row.name))
|
||||
.map((row) => row.name)
|
||||
}
|
||||
|
||||
function uniqueMirrorName(state: StorageAdminState, draft: StorageConfig, primaryName: string): string {
|
||||
const taken = new Set([...draft.backends.map((b) => b.name), ...state.backends.map((b) => b.name)])
|
||||
const base = `${primaryName}-mirror`
|
||||
if (!taken.has(base)) return base
|
||||
for (let n = 2; ; n++) {
|
||||
if (!taken.has(`${base}-${n}`)) return `${base}-${n}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* targets non-empty: update the adopted mirror in place (or synthesize one)
|
||||
* and route every category that effectively resolves to the primary —
|
||||
* default-sourced included — through it. targets empty: dissolve, re-pointing
|
||||
* the mirror's categories at the primary and dropping entries that revert to
|
||||
* their built-in default.
|
||||
*/
|
||||
export function setMirrorTargets(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
primaryName: string,
|
||||
targets: string[],
|
||||
): StorageConfig {
|
||||
const adopted = adoptedMirrorFor(draft, primaryName)
|
||||
|
||||
if (targets.length === 0) {
|
||||
if (!adopted) return draft
|
||||
const categories = { ...draft.categories }
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
if (categories[category] !== adopted.name) continue
|
||||
const stateEntry = state.categories[category]! // exhaustive by schema contract
|
||||
if (stateEntry.source === 'default' && stateEntry.backend === primaryName) delete categories[category]
|
||||
else categories[category] = primaryName
|
||||
}
|
||||
return { backends: draft.backends.filter((b) => b.name !== adopted.name), categories }
|
||||
}
|
||||
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const mirror: StorageBackend = {
|
||||
name: adopted?.name ?? uniqueMirrorName(state, draft, primaryName),
|
||||
type: 'mirror',
|
||||
options: { primary: primaryName, replicas: [...targets] },
|
||||
}
|
||||
const backends = adopted
|
||||
? draft.backends.map((b) => (b.name === adopted.name ? mirror : b))
|
||||
: [...draft.backends, mirror]
|
||||
const categories = { ...draft.categories }
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
if (effective[category] === primaryName) categories[category] = mirror.name
|
||||
}
|
||||
return { backends, categories }
|
||||
}
|
||||
|
||||
/** Rewrite every reference to a renamed backend: mirror primaries, replicas, category entries. */
|
||||
export function renameBackendRefs(draft: StorageConfig, oldName: string, newName: string): StorageConfig {
|
||||
return {
|
||||
backends: draft.backends.map((b) => {
|
||||
if (!isMirror(b)) return b
|
||||
return {
|
||||
...b,
|
||||
options: {
|
||||
primary: b.options.primary === oldName ? newName : b.options.primary,
|
||||
replicas: b.options.replicas.map((r) => (r === oldName ? newName : r)),
|
||||
},
|
||||
}
|
||||
}),
|
||||
categories: Object.fromEntries(
|
||||
Object.entries(draft.categories).map(([category, backend]) => [category, backend === oldName ? newName : backend]),
|
||||
) as StorageConfig['categories'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a backend, every mirror wrapping it as primary, and every replica
|
||||
* reference to it in the remaining mirrors — a mirror stripped of its last
|
||||
* replica dissolves (with the same category re-pointing as an explicit
|
||||
* dissolve), so the draft never carries a phantom reference the composer
|
||||
* cannot render or repair.
|
||||
*/
|
||||
export function removeBackendAndMirrors(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
name: string,
|
||||
): StorageConfig {
|
||||
const stripped = draft.backends
|
||||
.filter((b) => b.name !== name && !(isMirror(b) && b.options.primary === name))
|
||||
.map((b) =>
|
||||
isMirror(b) && b.options.replicas.includes(name)
|
||||
? { ...b, options: { ...b.options, replicas: b.options.replicas.filter((r) => r !== name) } }
|
||||
: b,
|
||||
)
|
||||
let next: StorageConfig = { ...draft, backends: stripped }
|
||||
for (const mirror of stripped.filter(isMirror)) {
|
||||
if (mirror.options.replicas.length > 0) continue
|
||||
if (adoptedMirrorFor(next, mirror.options.primary)?.name === mirror.name) {
|
||||
next = setMirrorTargets(state, next, mirror.options.primary, [])
|
||||
} else {
|
||||
// A degenerate duplicate stripped empty: drop it and re-point its categories.
|
||||
const categories = { ...next.categories }
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
if (categories[category] === mirror.name) categories[category] = mirror.options.primary
|
||||
}
|
||||
next = { backends: next.backends.filter((b) => b.name !== mirror.name), categories }
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** Primaries whose mirrors use the named backend as a replica — pre-check copy stays in primary names. */
|
||||
export function replicaOfPrimaries(draft: StorageConfig, name: string): string[] {
|
||||
return draft.backends
|
||||
.filter(isMirror)
|
||||
.filter((m) => m.options.replicas.includes(name))
|
||||
.map((m) => m.options.primary)
|
||||
}
|
||||
|
||||
/** Map a mirror name (draft first, then state) to its primary; anything else passes through. */
|
||||
export function primaryNameOf(state: StorageAdminState, draft: StorageConfig, backendName: string): string {
|
||||
const draftMirror = draft.backends.filter(isMirror).find((m) => m.name === backendName)
|
||||
if (draftMirror) return draftMirror.options.primary
|
||||
const stateMirror = state.backends.find((b) => b.name === backendName && b.type === 'mirror')
|
||||
if (stateMirror) return String((stateMirror.options as Record<string, unknown>).primary)
|
||||
return backendName
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage sums per VISIBLE backend row: each category's numbers land on the row
|
||||
* that serves it (a mirror-routed category on the mirror's PRIMARY row), and
|
||||
* the legacy photos directory lands on uploads-local — the only backend the
|
||||
* served-legacy `photos` category ever resolves to.
|
||||
*/
|
||||
export function usageByBackend(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
): Record<string, { objects: number; bytes: number }> | null {
|
||||
if (!state.usage) return null
|
||||
const sums: Record<string, { objects: number; bytes: number }> = {}
|
||||
const add = (row: string, entry: { objects: number; bytes: number }) => {
|
||||
const current = (sums[row] ??= { objects: 0, bytes: 0 })
|
||||
current.objects += entry.objects
|
||||
current.bytes += entry.bytes
|
||||
}
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
const entry = state.usage.categories[category]
|
||||
if (entry) add(primaryNameOf(state, draft, effective[category]), entry)
|
||||
}
|
||||
// The served-legacy `photos` directory always resolves to uploads-local —
|
||||
// hardcoded, not derived, because it is not one of the configurable categories.
|
||||
add(primaryNameOf(state, draft, 'uploads-local'), state.usage.legacyPhotos)
|
||||
return sums
|
||||
}
|
||||
|
||||
// ── Category migration (copy → flip → delta sweep) ────────────────────────────
|
||||
|
||||
/** One reassigned category the operator might want to move existing objects for. */
|
||||
export interface MigrationCandidate {
|
||||
category: StorageCategory
|
||||
from: string
|
||||
/** Display primary — never a hidden mirror name; used by the prompt lines and comparisons. */
|
||||
to: string
|
||||
/**
|
||||
* The RAW effective draft backend name for this category — possibly a
|
||||
* mirror's wire name when `to` is a mirrored primary. The migration POST
|
||||
* must carry this, not `to`: posting the bare primary would silently drop
|
||||
* replication for a category that was just routed onto a mirror.
|
||||
*/
|
||||
toWire: string
|
||||
/** null when usage was never scanned — the prompt still fires, with an unknown-size line. */
|
||||
objects: number | null
|
||||
bytes: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every category whose effective (draft-over-state) PRIMARY backend differs
|
||||
* from what's currently saved — normalized through `primaryNameOf` so simply
|
||||
* wrapping (or unwrapping) a mirror around the same primary never counts as
|
||||
* a candidate; mirroring is the existing sync/backfill flow, not a category
|
||||
* migration. Zero-object categories (usage present, 0 objects) are excluded —
|
||||
* nothing to move; null usage (never scanned) keeps the category as a
|
||||
* candidate with null counts, per spec ("unknown still prompts").
|
||||
*/
|
||||
export function computeMigrationCandidates(draft: StorageConfig, state: StorageAdminState): MigrationCandidate[] {
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const candidates: MigrationCandidate[] = []
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
const from = primaryNameOf(state, draft, state.categories[category]!.backend) // exhaustive by schema contract
|
||||
const toWire = effective[category]
|
||||
const to = primaryNameOf(state, draft, toWire)
|
||||
if (from === to) continue
|
||||
const usage = state.usage?.categories[category]
|
||||
candidates.push({ category, from, to, toWire, objects: usage ? usage.objects : null, bytes: usage ? usage.bytes : null })
|
||||
}
|
||||
return candidates.filter((c) => c.objects !== 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert exactly the named categories in `draft` back to what's currently
|
||||
* saved in `saved` — same default/settings convention as the rest of this
|
||||
* file (drop the override when the saved entry is default-sourced, write the
|
||||
* explicit backend name otherwise). Every other draft edit is untouched.
|
||||
*/
|
||||
export function stripCategories(
|
||||
draft: StorageConfig,
|
||||
saved: StorageAdminState,
|
||||
categories: StorageCategory[],
|
||||
): StorageConfig {
|
||||
const next = { ...draft.categories }
|
||||
for (const category of categories) {
|
||||
const savedEntry = saved.categories[category]! // exhaustive by schema contract
|
||||
if (savedEntry.source === 'default') delete next[category]
|
||||
else next[category] = savedEntry.backend
|
||||
}
|
||||
return { ...draft, categories: next }
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { StorageAdminState, StorageBackend, StorageConfig, StorageConfigPut, StorageTestResponse } from '@trek/shared'
|
||||
import { adminApi } from '../../../api/client'
|
||||
import { getApiErrorMessage, type ApiError } from '../../../types'
|
||||
import { settingsDocumentOf } from './storageModel'
|
||||
|
||||
export type StorageTestResults = Record<string, StorageTestResponse | 'running'>
|
||||
|
||||
/** 50ms under vitest so poll tests run on real timers (MSW + fake timers don't mix). */
|
||||
export const BACKFILL_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 5000
|
||||
|
||||
/** True for a 409 response — the version this draft was built at is no longer current (audit #7). */
|
||||
function isConflictError(err: unknown): boolean {
|
||||
return typeof err === 'object' && err !== null && 'response' in err && (err as ApiError).response?.status === 409
|
||||
}
|
||||
|
||||
export interface StorageAdmin {
|
||||
state: StorageAdminState | null
|
||||
/** 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
|
||||
loading: boolean
|
||||
loadError: string | null
|
||||
/** Server 400s/409s land here VERBATIM (400) or as the distinct conflict copy (409) — long registry messages outlive a toast. */
|
||||
saveError: string | null
|
||||
/** True while `saveError` is the 409 copy — the panels render the discard-and-reload escape next to it. */
|
||||
saveConflict: boolean
|
||||
saving: boolean
|
||||
testResults: StorageTestResults
|
||||
setDraft: (next: StorageConfig) => void
|
||||
/** PUTs `overrideDraft ?? draft` — an override lets a caller strip categories from the wire body without staging them (and marking dirty) via setDraft. */
|
||||
save: (overrideDraft?: StorageConfig) => Promise<boolean>
|
||||
test: (backend: StorageBackend) => Promise<void>
|
||||
/**
|
||||
* Probe each target individually (client-expanded mirror primary +
|
||||
* replicas — see `mirrorProbeTargets`) and merge the results into one
|
||||
* `StorageTestResponse` under `key` (the row's mirror wire name). Never
|
||||
* sends the mirror stub itself — each call probes a concrete backend.
|
||||
*/
|
||||
testMirror: (key: string, targets: StorageBackend[]) => Promise<void>
|
||||
/** null on success; the verbatim server error otherwise. */
|
||||
startBackfill: (mirrorName: string) => Promise<string | null>
|
||||
cancelBackfill: (mirrorName: string) => Promise<string | null>
|
||||
startMigration: (category: string, to: string) => Promise<string | null>
|
||||
cancelMigration: (category: string) => Promise<string | null>
|
||||
refreshStats: () => Promise<string | null>
|
||||
/** Re-GETs admin state (poll-safe — see the implementation comment below). Exposed for the panel's failed-queue recovery path. */
|
||||
refreshState: () => Promise<void>
|
||||
/**
|
||||
* The 409's only exit: throw the local edits away and reload the saved
|
||||
* settings (fresh GET → fresh draft → fresh version), clearing dirty and
|
||||
* saveError. See the implementation comment for why refreshState can't do
|
||||
* this itself.
|
||||
*/
|
||||
discardDraft: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by the desktop and phone storage panels (DefaultUserSettingsTab
|
||||
* model: self-contained, adminApi directly, deliberately no offline core —
|
||||
* hoster-level config is online-only).
|
||||
*/
|
||||
export function useStorageAdmin(genericError: string, conflictError: string): StorageAdmin {
|
||||
const [state, setState] = useState<StorageAdminState | null>(null)
|
||||
const [draft, setDraftState] = useState<StorageConfigPut | null>(null)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saveConflict, setSaveConflict] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testResults, setTestResults] = useState<StorageTestResults>({})
|
||||
|
||||
// Read imperatively from the poll effect/refreshState so a stale closure
|
||||
// never re-derives the draft out from under an in-flight edit.
|
||||
const dirtyRef = useRef(dirty)
|
||||
useEffect(() => {
|
||||
dirtyRef.current = dirty
|
||||
}, [dirty])
|
||||
|
||||
// Read imperatively at refreshState's resolve time so a poll GET that was
|
||||
// in flight when a save started never applies after the save's own state.
|
||||
const savingRef = useRef(saving)
|
||||
useEffect(() => {
|
||||
savingRef.current = saving
|
||||
}, [saving])
|
||||
|
||||
// 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
|
||||
// fresher state — while that GET was in flight.
|
||||
const stateSeq = useRef(0)
|
||||
|
||||
const applyState = useCallback((next: StorageAdminState) => {
|
||||
stateSeq.current += 1
|
||||
setState(next)
|
||||
setDraftState(settingsDocumentOf(next))
|
||||
setDirty(false)
|
||||
setSaveError(null)
|
||||
setSaveConflict(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
adminApi
|
||||
.getStorage()
|
||||
.then((data) => {
|
||||
if (!cancelled) applyState(data)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setLoadError(getApiErrorMessage(err, genericError))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [applyState, genericError])
|
||||
|
||||
// Re-attaches the CURRENT draft's version onto whatever shape the caller
|
||||
// hands in (the storageModel edit helpers are version-blind — they operate
|
||||
// on plain StorageConfig — so this is the one place that keeps `version`
|
||||
// pinned to what the operator's form was loaded at, no matter how many
|
||||
// local edits run in between).
|
||||
const setDraft = useCallback((next: StorageConfig) => {
|
||||
setDraftState((prev) => ({ ...next, version: prev?.version ?? 0 }))
|
||||
setDirty(true)
|
||||
}, [])
|
||||
|
||||
// Poll-safe refresh: replaces `state` unconditionally, but only re-derives
|
||||
// `draft` when the operator has no unsaved edits in flight — never touches
|
||||
// `dirty`/`saveError`, so a running poll cannot clobber a dirty draft or
|
||||
// mask a pending save error.
|
||||
//
|
||||
// Race guard: a poll GET issued before a save's PUT resolves can resolve
|
||||
// AFTER the save applied its own (fresher) response, overwriting it with
|
||||
// the pre-save world. Capture the state-application sequence number before
|
||||
// the await and drop the response if it moved on, or if a save is
|
||||
// in-flight/just landed (read via a ref so it's current at resolve time).
|
||||
//
|
||||
// Declared before `save` (which calls it on a 409) — a hook's `const`s
|
||||
// must exist before another callback's dependency array references them.
|
||||
const refreshState = useCallback(async (): Promise<void> => {
|
||||
const seq = stateSeq.current
|
||||
const next = await adminApi.getStorage()
|
||||
if (stateSeq.current !== seq || savingRef.current) return
|
||||
setState(next)
|
||||
if (!dirtyRef.current) setDraftState(settingsDocumentOf(next))
|
||||
}, [])
|
||||
|
||||
const save = useCallback(
|
||||
async (overrideDraft?: StorageConfig): Promise<boolean> => {
|
||||
if (!draft) return false
|
||||
// Always the DRAFT's own version, even for an overrideDraft (a variant
|
||||
// of the same draft with categories stripped, per stripCategories) —
|
||||
// both share the same version basis: what the operator's form was
|
||||
// loaded/refreshed at, never re-read from `state` here (that would
|
||||
// defeat the whole check — see setDraft's re-attach comment above).
|
||||
const body: StorageConfigPut = { ...(overrideDraft ?? draft), version: draft.version }
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
setSaveConflict(false)
|
||||
try {
|
||||
// The response is the fresh effective world, never the request echo.
|
||||
applyState(await adminApi.updateStorage(body))
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
if (isConflictError(err)) {
|
||||
// Something else (another admin's save, or a category migration's
|
||||
// flip) wrote since this draft's version was captured. Refresh
|
||||
// `state` so the operator sees the fresh world, but the dirty
|
||||
// draft itself is left untouched for review (dirty is never
|
||||
// cleared here) — refreshState() already respects dirtyRef.
|
||||
setSaveError(conflictError)
|
||||
setSaveConflict(true)
|
||||
void refreshState().catch(() => {})
|
||||
} else {
|
||||
setSaveError(getApiErrorMessage(err, genericError))
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
},
|
||||
[draft, applyState, genericError, conflictError, refreshState],
|
||||
)
|
||||
|
||||
/**
|
||||
* Conflict recovery (final-review fix): after a 409 the dirty draft keeps
|
||||
* the version it was built at. refreshState deliberately leaves a dirty
|
||||
* draft's version alone — bumping it there would resurrect audit #7, where
|
||||
* the background poll floats a mid-edit draft past a migration flip it
|
||||
* never carried — so nothing else can ever move it, and every retry 409s
|
||||
* again. The operator's way out is to say so explicitly: discard the local
|
||||
* edits, reload the saved settings, and start over from the fresh version.
|
||||
* A fresh GET (not the possibly-dropped post-409 refresh) guarantees that
|
||||
* version is current, and applyState clears dirty/saveError with it.
|
||||
*/
|
||||
const discardDraft = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
applyState(await adminApi.getStorage())
|
||||
} catch (err: unknown) {
|
||||
setSaveError(getApiErrorMessage(err, genericError))
|
||||
setSaveConflict(false)
|
||||
}
|
||||
}, [applyState, genericError])
|
||||
|
||||
const test = useCallback(
|
||||
async (backend: StorageBackend): Promise<void> => {
|
||||
setTestResults((prev) => ({ ...prev, [backend.name]: 'running' }))
|
||||
try {
|
||||
const result = await adminApi.testStorageBackend(backend)
|
||||
setTestResults((prev) => ({ ...prev, [backend.name]: result }))
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, genericError)
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[backend.name]: { ok: false, targets: [{ name: backend.name, ok: false, error }] },
|
||||
}))
|
||||
}
|
||||
},
|
||||
[genericError],
|
||||
)
|
||||
|
||||
const testMirror = useCallback(
|
||||
async (key: string, targets: StorageBackend[]): Promise<void> => {
|
||||
setTestResults((prev) => ({ ...prev, [key]: 'running' }))
|
||||
const settled = await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const result = await adminApi.testStorageBackend(target)
|
||||
return result.targets[0] ?? { name: target.name, ok: result.ok }
|
||||
} catch (err: unknown) {
|
||||
return { name: target.name, ok: false, error: getApiErrorMessage(err, genericError) }
|
||||
}
|
||||
}),
|
||||
)
|
||||
setTestResults((prev) => ({ ...prev, [key]: { ok: settled.every((r) => r.ok), targets: settled } }))
|
||||
},
|
||||
[genericError],
|
||||
)
|
||||
|
||||
// While any backfill or category migration is running, poll GET state so
|
||||
// progress/counts advance without the operator refreshing the page.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!state?.backfills.some((b) => b.status === 'running') &&
|
||||
!state?.migrations.some((m) => m.status === 'running')
|
||||
)
|
||||
return
|
||||
const iv = setInterval(() => {
|
||||
// A transient poll failure is retried on the next tick — it must never
|
||||
// surface as a toast or interrupt the operator's in-progress edit.
|
||||
void refreshState().catch(() => {})
|
||||
}, BACKFILL_POLL_MS)
|
||||
return () => clearInterval(iv)
|
||||
}, [state, refreshState])
|
||||
|
||||
const startBackfill = useCallback(
|
||||
async (mirrorName: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.startStorageBackfill(mirrorName)
|
||||
void refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const cancelBackfill = useCallback(
|
||||
async (mirrorName: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.cancelStorageBackfill(mirrorName)
|
||||
void refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const startMigration = useCallback(
|
||||
async (category: string, to: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.startStorageMigration(category, to)
|
||||
// Awaited (unlike cancelMigration/startBackfill/cancelBackfill): the
|
||||
// queue effect's in-flight lock releases as soon as this promise
|
||||
// resolves, so admin.state must already reflect the just-started
|
||||
// migration by then — otherwise the next queued candidate can
|
||||
// dequeue and POST before the server has confirmed this one.
|
||||
await refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const cancelMigration = useCallback(
|
||||
async (category: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.cancelStorageMigration(category)
|
||||
void refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const refreshStats = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
const usage = await adminApi.refreshStorageStats()
|
||||
setState((prev) => (prev ? { ...prev, usage } : prev))
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
}, [genericError])
|
||||
|
||||
return {
|
||||
state,
|
||||
draft,
|
||||
dirty,
|
||||
loading,
|
||||
loadError,
|
||||
saveError,
|
||||
saveConflict,
|
||||
saving,
|
||||
testResults,
|
||||
setDraft,
|
||||
save,
|
||||
test,
|
||||
testMirror,
|
||||
startBackfill,
|
||||
cancelBackfill,
|
||||
startMigration,
|
||||
cancelMigration,
|
||||
refreshStats,
|
||||
refreshState,
|
||||
discardDraft,
|
||||
}
|
||||
}
|
||||
@@ -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' })],
|
||||
|
||||
@@ -133,4 +133,21 @@ describe('BackgroundTasksWidget', () => {
|
||||
expect(reservationsApi.importBookingAsync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* Warnings used to appear only when nothing at all was found, so a partly
|
||||
* understood import dropped whatever it could not place without a word — a
|
||||
* station the geocoder could not locate, say (#1969), whose endpoint then
|
||||
* disappeared on save.
|
||||
*/
|
||||
it('shows the warnings on a job that did produce items', () => {
|
||||
const warning = 'voucher.pdf: could not locate "Curbside Pickup Counter 7" — set it manually before saving'
|
||||
useBackgroundTasksStore.setState({
|
||||
tasks: [task({ items: [{ id: 1 } as never], warnings: [warning] })],
|
||||
})
|
||||
render(<BackgroundTasksWidget />)
|
||||
expect(screen.getByText(new RegExp('Curbside Pickup Counter 7'))).toBeInTheDocument()
|
||||
// and the import button is still there — the job did find something
|
||||
expect(screen.getByRole('button', { name: /import/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -157,13 +162,25 @@ export default function BackgroundTasksWidget() {
|
||||
// Restored from a reload; items are being re-fetched (see the poll backstop).
|
||||
<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 ? (
|
||||
<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' }}
|
||||
>
|
||||
{t('common.import')}
|
||||
</button>
|
||||
<div>
|
||||
<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' }}
|
||||
>
|
||||
{t('common.import')}
|
||||
</button>
|
||||
{/* A partly-understood import warns too. Warnings used to be
|
||||
shown only when nothing at all was found, so anything the
|
||||
parse could not place — a station it could not locate, say
|
||||
(#1969) — was dropped without a word on exactly the imports
|
||||
that did produce something. */}
|
||||
{(task.warnings?.length ?? 0) > 0 && (
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b45309', marginTop: 3, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 96, overflowY: 'auto' }}>
|
||||
{task.warnings!.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
|
||||
@@ -175,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"
|
||||
@@ -194,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()}
|
||||
|
||||
@@ -22,7 +22,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 +45,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) : '' })
|
||||
@@ -153,7 +153,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'
|
||||
@@ -594,7 +594,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()
|
||||
|
||||
@@ -916,6 +916,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 }] })
|
||||
|
||||
@@ -1394,6 +1438,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 +1523,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 }) }))
|
||||
@@ -1615,7 +1711,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 +1837,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' }) })
|
||||
@@ -1774,3 +1885,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,11 +10,12 @@ import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput } from '../../utils/formatters'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, cleanAmount } 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 { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
@@ -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])
|
||||
@@ -219,8 +225,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 +249,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 +324,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 +348,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 +401,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 +436,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 +451,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 +534,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 +560,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 +569,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 +589,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 +628,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 +650,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 +666,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 +676,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,12 +705,14 @@ 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
|
||||
@@ -775,7 +806,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 +839,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 +848,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 (
|
||||
@@ -938,7 +970,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
|
||||
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 +991,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,10 +1049,10 @@ 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))
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? String(editing.total_price) : ''
|
||||
if (editing) return editing.total_price ? String(cleanAmount(editing.total_price)) : ''
|
||||
if (prefill?.amount != null) return String(prefill.amount)
|
||||
return ''
|
||||
})
|
||||
@@ -1084,8 +1116,8 @@ 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)
|
||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||
const equalShares = useMemo(() => {
|
||||
@@ -1098,13 +1130,13 @@ 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)
|
||||
.reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
const remaining = Math.max(0, totalNum - enteredSum)
|
||||
|
||||
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))
|
||||
const valid = name.trim().length > 0 && payersOk && (
|
||||
isTicketMode
|
||||
@@ -1231,7 +1263,7 @@ 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 }))
|
||||
.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
|
||||
@@ -1243,7 +1275,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 +1315,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
|
||||
@@ -1338,7 +1370,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>
|
||||
@@ -1445,7 +1477,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 +1500,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 +1524,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 +1564,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 +1589,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(totalNum - splitSum > 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>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { COST_CATEGORIES, typeToCostCategory } from '@trek/shared'
|
||||
import { COST_CAT_META, COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
|
||||
describe('COST_CAT_META', () => {
|
||||
it('has metadata for every fixed category, including fuel and parking', () => {
|
||||
for (const key of COST_CATEGORIES) {
|
||||
expect(COST_CAT_META[key]).toBeDefined()
|
||||
expect(COST_CAT_META[key].key).toBe(key)
|
||||
}
|
||||
})
|
||||
|
||||
it('derives COST_CATEGORY_LIST in the same order as COST_CATEGORIES', () => {
|
||||
expect(COST_CATEGORY_LIST.map(m => m.key)).toEqual([...COST_CATEGORIES])
|
||||
})
|
||||
})
|
||||
|
||||
describe('catMeta', () => {
|
||||
it('resolves fuel and parking directly', () => {
|
||||
expect(catMeta('fuel').key).toBe('fuel')
|
||||
expect(catMeta('parking').key).toBe('parking')
|
||||
})
|
||||
|
||||
it('maps legacy/free-text aliases to the new categories', () => {
|
||||
expect(catMeta('gas').key).toBe('fuel')
|
||||
expect(catMeta('petrol').key).toBe('fuel')
|
||||
expect(catMeta('Parking').key).toBe('parking')
|
||||
expect(catMeta('car park').key).toBe('parking')
|
||||
})
|
||||
|
||||
it('resolves a parking booking the same way whichever path created the expense', () => {
|
||||
expect(catMeta(typeToCostCategory('parking')).key).toBe('parking')
|
||||
expect(catMeta('parking').key).toBe(typeToCostCategory('parking'))
|
||||
})
|
||||
|
||||
it('falls back to other for unknown categories', () => {
|
||||
expect(catMeta('made-up-category').key).toBe('other')
|
||||
expect(catMeta(null).key).toBe('other')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal } from 'lucide-react'
|
||||
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal, Fuel, ParkingCircle } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
|
||||
|
||||
@@ -27,6 +27,8 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
||||
fees: { key: 'fees', labelKey: 'costs.cat.fees', Icon: FileText, color: '#475569' },
|
||||
health: { key: 'health', labelKey: 'costs.cat.health', Icon: HeartPulse, color: '#dc2626' },
|
||||
tips: { key: 'tips', labelKey: 'costs.cat.tips', Icon: Coins, color: '#d97706' },
|
||||
fuel: { key: 'fuel', labelKey: 'costs.cat.fuel', Icon: Fuel, color: '#f59e0b' },
|
||||
parking: { key: 'parking', labelKey: 'costs.cat.parking', Icon: ParkingCircle, color: '#3b82f6' },
|
||||
other: { key: 'other', labelKey: 'costs.cat.other', Icon: MoreHorizontal, color: '#6b7280' },
|
||||
}
|
||||
|
||||
@@ -51,6 +53,8 @@ const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
|
||||
fee: 'fees', fees: 'fees',
|
||||
health: 'health', medical: 'health',
|
||||
tip: 'tips', tips: 'tips',
|
||||
gas: 'fuel', fuel: 'fuel', petrol: 'fuel',
|
||||
parking: 'parking', parkings: 'parking', 'car park': 'parking',
|
||||
other: 'other', misc: 'other',
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,10 @@ export function ChatMessages(props: any) {
|
||||
<>
|
||||
{/* Messages */}
|
||||
{messages.length === 0 ? (
|
||||
<EmptyState scene="chat" title={t('collab.chat.empty')} />
|
||||
// flex-1, like the message list it stands in for: without it the empty
|
||||
// state was only as tall as its own content and the composer rode up to
|
||||
// sit right under the mascot, with the rest of the panel left blank.
|
||||
<EmptyState scene="chat" title={t('collab.chat.empty')} className="min-h-0 flex-1" />
|
||||
) : (
|
||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
|
||||
@@ -21,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',
|
||||
@@ -178,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,
|
||||
@@ -190,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');
|
||||
@@ -1346,7 +1351,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 +1372,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 +1386,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 +1395,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 +1408,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 +1419,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 +1437,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 +1767,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 +1811,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 +1880,36 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,6 +89,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 +148,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 +165,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 +195,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 +264,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 +303,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 +322,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 +335,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 +403,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 +413,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 +430,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)'}>
|
||||
@@ -430,17 +462,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 +495,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 +544,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} />
|
||||
}
|
||||
|
||||
@@ -80,26 +80,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)'}>
|
||||
@@ -171,17 +171,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 {
|
||||
|
||||
@@ -88,8 +88,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>
|
||||
@@ -122,13 +122,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 +174,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={{
|
||||
@@ -250,14 +250,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,7 +278,7 @@ 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,
|
||||
@@ -359,15 +359,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 +394,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [])
|
||||
}, [tripId])
|
||||
|
||||
const handleCreate = useCallback(async (data) => {
|
||||
try {
|
||||
@@ -455,7 +465,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'
|
||||
|
||||
@@ -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 { CARTO_DARK, CARTO_LIGHT } 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 ? CARTO_DARK : CARTO_LIGHT)
|
||||
|
||||
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