From 16bfdd2e907f45c5c7fbe090d5a269ee5d4cf3cd Mon Sep 17 00:00:00 2001 From: Mistral Vibe Date: Wed, 1 Apr 2026 14:55:10 +0200 Subject: [PATCH] Move band management into dedicated settings pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BandSettingsPage (/bands/:id/settings/:panel) with Members, Storage, and Band Settings panels matching the mockup design - Strip members list, invite controls, and NC folder config from BandPage — library view now focuses purely on recordings workflow - Add band-scoped nav section to AppShell sidebar (Members, Storage, Band Settings) with correct per-panel active states - Fix amAdmin bug: was checking if any member is admin; now correctly checks if the current user holds the admin role - Add 31 vitest tests covering BandPage cleanliness, routing, access control (admin vs member), and per-panel mutation behaviour - Add test:web, test:api:unit, test:feature (post-feature pipeline), and ci tasks to Taskfile; frontend tests run via podman node:20-alpine - Add README with architecture overview, setup guide, and test docs - Add @testing-library/dom and @testing-library/jest-dom to package.json Co-Authored-By: Claude Sonnet 4.6 --- README.md | 280 ++++++++ Taskfile.yml | 41 +- web/package-lock.json | 138 +++- web/package.json | 2 + web/src/App.tsx | 13 + web/src/components/AppShell.tsx | 50 +- web/src/pages/BandPage.test.tsx | 97 +++ web/src/pages/BandPage.tsx | 900 ++++++++++++------------ web/src/pages/BandSettingsPage.test.md | 151 ++++ web/src/pages/BandSettingsPage.test.tsx | 320 +++++++++ web/src/pages/BandSettingsPage.tsx | 867 +++++++++++++++++++++++ web/src/test/helpers.tsx | 34 + 12 files changed, 2428 insertions(+), 465 deletions(-) create mode 100644 README.md create mode 100644 web/src/pages/BandPage.test.tsx create mode 100644 web/src/pages/BandSettingsPage.test.md create mode 100644 web/src/pages/BandSettingsPage.test.tsx create mode 100644 web/src/pages/BandSettingsPage.tsx create mode 100644 web/src/test/helpers.tsx diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1fd8cd --- /dev/null +++ b/README.md @@ -0,0 +1,280 @@ +# RehearsalHub + +A web platform for bands to relisten to recorded rehearsals, drop timestamped comments, annotate moments, and collaborate asynchronously — all on top of your own storage (Nextcloud, Google Drive, S3, local). + +--- + +## Architecture + +``` +┌─────────┐ HTTP/WS ┌──────────────┐ asyncpg ┌──────────┐ +│ React │ ──────────► │ FastAPI │ ──────────► │ Postgres │ +│ (Vite) │ │ (Python) │ └──────────┘ +└─────────┘ └──────┬───────┘ + │ Redis pub/sub + ┌──────────┴──────────┐ + │ │ + ┌──────▼──────┐ ┌──────────▼──────┐ + │ Audio Worker │ │ NC Watcher │ + │ (waveforms) │ │ (file polling) │ + └─────────────┘ └─────────────────┘ +``` + +| Service | Language | Purpose | +|---|---|---| +| `web` | TypeScript / React | UI — player, library, settings | +| `api` | Python / FastAPI | REST + WebSocket backend | +| `worker` | Python | Audio analysis, waveform generation | +| `watcher` | Python | Polls Nextcloud for new files | +| `db` | PostgreSQL 16 | Primary datastore | +| `redis` | Redis 7 | Task queue, pub/sub | + +Files are **never copied** to RehearsalHub servers. The platform reads recordings directly from your own storage. + +--- + +## Prerequisites + +| Tool | Purpose | Install | +|---|---|---| +| **Podman** + `podman-compose` | Container runtime | [podman.io](https://podman.io) | +| **uv** | Python package manager (backend) | `curl -Lsf https://astral.sh/uv/install.sh \| sh` | +| **Task** | Task runner (`Taskfile.yml`) | [taskfile.dev](https://taskfile.dev) | +| **Node 20** | Frontend (runs inside podman — not needed locally) | via `podman run node:20-alpine` | + +> Node is only required inside a container. All frontend commands pull `node:20-alpine` via podman automatically. + +--- + +## Quick start + +### 1. Configure environment + +```bash +cp .env.example .env +# Edit .env — set SECRET_KEY, INTERNAL_SECRET, Nextcloud credentials, domain +``` + +Generate secrets: +```bash +openssl rand -hex 32 # paste as SECRET_KEY +openssl rand -hex 32 # paste as INTERNAL_SECRET +``` + +### 2. Start all services + +```bash +task up # starts db, redis, api, audio-worker, nc-watcher, web (nginx) +task migrate # run database migrations +``` + +Or for first-time setup with Nextcloud scaffolding: +```bash +task setup # up + wait for NC + configure NC + seed data +``` + +### 3. Open the app + +Visit `http://localhost:8080` (or your configured `DOMAIN`). + +--- + +## Development + +Start the backend with hot reload and mount source directories: + +```bash +task dev:detach # start db, redis, api, worker, watcher in dev mode (background) +task dev:web # start Vite dev server at http://localhost:3000 (proxies /api) +``` + +Or run both together: +```bash +task dev # foreground, streams all logs +``` + +Follow logs: +```bash +task logs # all services +task dev:logs SERVICE=api # single service +``` + +Restart a single service after a code change: +```bash +task dev:restart SERVICE=api +``` + +### Database migrations + +```bash +# Apply pending migrations +task migrate + +# Create a new migration from model changes +task migrate:auto M="add instrument field to band_member" +``` + +### Useful shells + +```bash +task shell:api # bash in the API container +task shell:db # psql +task shell:redis # redis-cli +``` + +--- + +## Testing + +### After every feature — run this + +```bash +task test:feature +``` + +This runs the full **post-feature pipeline** (no external services required): + +| Step | What it checks | +|---|---| +| `typecheck:web` | TypeScript compilation errors | +| `test:web` | React component tests (via podman + vitest) | +| `test:api:unit` | Python unit tests (no DB needed) | +| `test:worker` | Worker unit tests | +| `test:watcher` | Watcher unit tests | + +Typical runtime: **~60–90 seconds**. + +--- + +### Full CI pipeline + +Runs everything including integration tests against a live database. +**Requires services to be up** (`task dev:detach && task migrate`). + +```bash +task ci +``` + +Stages: + +``` +lint ──► typecheck ──► test:web ──► test:api (unit + integration) + ──► test:worker + ──► test:watcher +``` + +--- + +### Individual test commands + +```bash +# Frontend +task test:web # React/vitest tests (podman, no local Node needed) +task typecheck:web # TypeScript type check only + +# Backend — unit (no services required) +task test:api:unit # API unit tests +task test:worker # Worker tests +task test:watcher # Watcher tests + +# Backend — all (requires DB + services) +task test:api # unit + integration tests with coverage +task test # all backend suites + +# Integration only +task test:integration # API integration tests (DB required) + +# Lint +task lint # ruff + mypy (Python), eslint (TS) +task format # auto-format Python with ruff +``` + +--- + +### Frontend test details + +Frontend tests run inside a `node:20-alpine` container via podman and do not require Node installed on the host: + +```bash +task test:web +# equivalent to: +podman run --rm -v ./web:/app:Z -w /app node:20-alpine \ + sh -c "npm install --legacy-peer-deps --silent && npm run test" +``` + +Tests use **vitest** + **@testing-library/react** and are located alongside the source files they test: + +``` +web/src/pages/ + BandPage.tsx + BandPage.test.tsx ← 7 tests: library view cleanliness + BandSettingsPage.tsx + BandSettingsPage.test.tsx ← 24 tests: routing, access control, mutations +web/src/test/ + setup.ts ← jest-dom matchers + helpers.tsx ← QueryClient + MemoryRouter wrapper +``` + +--- + +## Project structure + +``` +rehearshalhub/ +├── api/ Python / FastAPI backend +│ ├── src/rehearsalhub/ +│ │ ├── routers/ HTTP endpoints +│ │ ├── models/ SQLAlchemy ORM models +│ │ ├── repositories/ DB access layer +│ │ ├── services/ Business logic +│ │ └── schemas/ Pydantic request/response schemas +│ └── tests/ +│ ├── unit/ Pure unit tests (no DB) +│ └── integration/ Full HTTP tests against a real DB +│ +├── web/ TypeScript / React frontend +│ └── src/ +│ ├── api/ API client functions +│ ├── components/ Shared components (AppShell, etc.) +│ ├── pages/ Route-level page components +│ └── test/ Test helpers and setup +│ +├── worker/ Audio analysis service (Python) +├── watcher/ Nextcloud file polling service (Python) +├── scripts/ nc-setup.sh, seed.sh +├── traefik/ Reverse proxy config +├── docker-compose.yml Production compose +├── docker-compose.dev.yml Dev overrides (hot reload, source mounts) +├── Taskfile.yml Task runner (preferred) +└── Makefile Makefile aliases (same targets) +``` + +--- + +## Key design decisions + +- **Storage is always yours.** RehearsalHub never copies audio files. It reads them directly from Nextcloud (or other providers) on demand. +- **Date is the primary axis.** The library groups recordings by session date. Filters narrow within that structure — they never flatten it. +- **Band switching is tenant-level.** Switching bands re-scopes the library, settings, and all band-specific views. +- **Settings are band-scoped.** Member management, storage configuration, and band identity live at `/bands/:id/settings`, not in the library view. + +--- + +## Environment variables + +| Variable | Required | Description | +|---|---|---| +| `SECRET_KEY` | ✅ | 32-byte hex, JWT signing key | +| `INTERNAL_SECRET` | ✅ | 32-byte hex, service-to-service auth | +| `DATABASE_URL` | ✅ | PostgreSQL connection string | +| `REDIS_URL` | ✅ | Redis connection string | +| `NEXTCLOUD_URL` | ✅ | Full URL to your Nextcloud instance | +| `NEXTCLOUD_USER` | ✅ | Nextcloud service account username | +| `NEXTCLOUD_PASS` | ✅ | Nextcloud service account password | +| `DOMAIN` | ✅ | Public domain (used by Traefik TLS) | +| `ACME_EMAIL` | ✅ | Let's Encrypt email | +| `POSTGRES_DB` | ✅ | Database name | +| `POSTGRES_USER` | ✅ | Database user | +| `POSTGRES_PASSWORD` | ✅ | Database password | + +See `.env.example` for the full template. diff --git a/Taskfile.yml b/Taskfile.yml index 7b10e87..ac415eb 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -87,16 +87,51 @@ tasks: # ── Testing ─────────────────────────────────────────────────────────────────── + # Run this after every feature branch — fast, no external services required. + test:feature: + desc: "Post-feature pipeline: typecheck + frontend tests + backend unit tests (no services needed)" + cmds: + - task: typecheck:web + - task: test:web + - task: test:api:unit + - task: test:worker + - task: test:watcher + + # Full CI pipeline — runs everything including integration tests. + # Requires: services up (task dev:detach), DB migrated. + ci: + desc: "Full CI pipeline: lint + typecheck + all tests (requires services running)" + cmds: + - task: lint + - task: typecheck:web + - task: test:web + - task: test:api + - task: test:worker + - task: test:watcher + test: - desc: Run all tests + desc: Run all backend tests (unit + integration) deps: [test:api, test:worker, test:watcher] + test:web: + desc: Run frontend unit tests (via podman — no local Node required) + dir: web + cmds: + - podman run --rm -v "$(pwd)":/app:Z -w /app node:20-alpine + sh -c "npm install --legacy-peer-deps --silent && npm run test" + test:api: - desc: Run API tests with coverage + desc: Run all API tests with coverage (unit + integration) dir: api cmds: - uv run pytest tests/ -v --cov=src/rehearsalhub --cov-report=term-missing + test:api:unit: + desc: Run API unit tests only (no database or external services required) + dir: api + cmds: + - uv run pytest tests/unit/ -v -m "not integration" + test:worker: desc: Run worker tests with coverage dir: worker @@ -110,7 +145,7 @@ tasks: - uv run pytest tests/ -v --cov=src/watcher --cov-report=term-missing test:integration: - desc: Run integration tests + desc: Run integration tests (requires services running) dir: api cmds: - uv run pytest tests/integration/ -v -m integration diff --git a/web/package-lock.json b/web/package-lock.json index dc96e3d..4b7f05e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,6 +17,8 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", "@types/react": "^18.3.5", @@ -32,6 +34,13 @@ "vitest": "^2.1.1" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -1495,7 +1504,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -1510,6 +1518,43 @@ "node": ">=18" } }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/@testing-library/react": { "version": "16.3.2", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", @@ -1557,8 +1602,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1623,14 +1667,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -2132,7 +2176,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2161,14 +2204,13 @@ "license": "Python-2.0" }, "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, "license": "Apache-2.0", - "peer": true, - "dependencies": { - "dequal": "^2.0.3" + "engines": { + "node": ">= 0.4" } }, "node_modules/assertion-error": { @@ -2414,6 +2456,13 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -2439,7 +2488,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -2514,18 +2563,16 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } }, "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3264,6 +3311,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3490,7 +3547,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -3538,6 +3594,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -3776,7 +3842,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -3792,7 +3857,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -3840,8 +3904,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.17.0", @@ -3885,6 +3948,20 @@ "react-dom": ">=16.8" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4040,6 +4117,19 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index 997b5c3..0ac2b1b 100644 --- a/web/package.json +++ b/web/package.json @@ -23,6 +23,8 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", "@types/react": "^18.3.5", diff --git a/web/src/App.tsx b/web/src/App.tsx index 083f52a..7aabe5a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ import { AppShell } from "./components/AppShell"; import { LoginPage } from "./pages/LoginPage"; import { HomePage } from "./pages/HomePage"; import { BandPage } from "./pages/BandPage"; +import { BandSettingsPage } from "./pages/BandSettingsPage"; import { SessionPage } from "./pages/SessionPage"; import { SongPage } from "./pages/SongPage"; import { SettingsPage } from "./pages/SettingsPage"; @@ -50,6 +51,18 @@ export default function App() { } /> + } + /> + + + + } + /> + + + + + + ); +} + +function IconStorage() { + return ( + + + + + + + ); +} + function IconChevron() { return ( @@ -157,6 +179,8 @@ export function AppShell({ children }: { children: React.ReactNode }) { ); const isPlayer = !!matchPath("/bands/:bandId/songs/:songId", location.pathname); const isSettings = location.pathname.startsWith("/settings"); + const isBandSettings = !!matchPath("/bands/:bandId/settings/*", location.pathname); + const bandSettingsPanel = matchPath("/bands/:bandId/settings/:panel", location.pathname)?.params?.panel ?? null; // Close dropdown on outside click useEffect(() => { @@ -425,7 +449,31 @@ export function AppShell({ children }: { children: React.ReactNode }) { )} - Account + {activeBand && ( + <> + Band Settings + } + label="Members" + active={isBandSettings && bandSettingsPanel === "members"} + onClick={() => navigate(`/bands/${activeBand.id}/settings/members`)} + /> + } + label="Storage" + active={isBandSettings && bandSettingsPanel === "storage"} + onClick={() => navigate(`/bands/${activeBand.id}/settings/storage`)} + /> + } + label="Band Settings" + active={isBandSettings && bandSettingsPanel === "band"} + onClick={() => navigate(`/bands/${activeBand.id}/settings/band`)} + /> + + )} + + Account } label="Settings" diff --git a/web/src/pages/BandPage.test.tsx b/web/src/pages/BandPage.test.tsx new file mode 100644 index 0000000..0417c35 --- /dev/null +++ b/web/src/pages/BandPage.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "../test/helpers"; +import { BandPage } from "./BandPage"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("../api/bands", () => ({ + getBand: vi.fn().mockResolvedValue({ + id: "band-1", + name: "Loud Hands", + slug: "loud-hands", + genre_tags: ["post-rock"], + nc_folder_path: null, + }), +})); + +vi.mock("../api/client", () => ({ + api: { + get: vi.fn().mockImplementation((url: string) => { + if (url.includes("/sessions")) { + return Promise.resolve([ + { id: "s1", date: "2026-03-31", label: "Late Night Jam", recording_count: 3 }, + ]); + } + if (url.includes("/songs/search")) { + return Promise.resolve([]); + } + return Promise.resolve([]); + }), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, + isLoggedIn: vi.fn().mockReturnValue(true), +})); + +const renderBandPage = () => + renderWithProviders(, { + path: "/bands/:bandId", + route: "/bands/band-1", + }); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("BandPage — cleanliness (TC-01 to TC-07)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("TC-01: does not render a member list", async () => { + renderBandPage(); + // Allow queries to settle + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByText(/members/i)).toBeNull(); + }); + + it("TC-02: does not render an invite button", async () => { + renderBandPage(); + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByText(/\+ invite/i)).toBeNull(); + }); + + it("TC-03: does not render the Nextcloud folder config widget", async () => { + renderBandPage(); + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByText(/scan path/i)).toBeNull(); + expect(screen.queryByText(/nextcloud scan folder/i)).toBeNull(); + }); + + it("TC-04: renders sessions grouped by date", async () => { + renderBandPage(); + // Sessions appear after the query resolves + const sessionEl = await screen.findByText("Late Night Jam"); + expect(sessionEl).toBeTruthy(); + }); + + it("TC-05: renders the Scan Nextcloud action button", async () => { + renderBandPage(); + const btn = await screen.findByText(/scan nextcloud/i); + expect(btn).toBeTruthy(); + }); + + it("TC-06: renders the + New Song button", async () => { + renderBandPage(); + const btn = await screen.findByText(/\+ new song/i); + expect(btn).toBeTruthy(); + }); + + it("TC-07: renders both By Date and Search tabs", async () => { + renderBandPage(); + const byDate = await screen.findByText(/by date/i); + const search = await screen.findByText(/^search$/i); + expect(byDate).toBeTruthy(); + expect(search).toBeTruthy(); + }); +}); diff --git a/web/src/pages/BandPage.tsx b/web/src/pages/BandPage.tsx index 7e694d4..2a6de0b 100644 --- a/web/src/pages/BandPage.tsx +++ b/web/src/pages/BandPage.tsx @@ -3,7 +3,6 @@ import { useParams, Link } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { getBand } from "../api/bands"; import { api } from "../api/client"; -import { InviteManagement } from "../components/InviteManagement"; interface SongSummary { id: string; @@ -15,21 +14,6 @@ interface SongSummary { version_count: number; } -interface BandMember { - id: string; - display_name: string; - email: string; - role: string; - joined_at: string; -} - -interface BandInvite { - id: string; - token: string; - role: string; - expires_at: string; -} - interface SessionSummary { id: string; date: string; @@ -56,9 +40,6 @@ export function BandPage() { const [scanning, setScanning] = useState(false); const [scanProgress, setScanProgress] = useState(null); const [scanMsg, setScanMsg] = useState(null); - const [inviteLink, setInviteLink] = useState(null); - const [editingFolder, setEditingFolder] = useState(false); - const [folderInput, setFolderInput] = useState(""); // Search state const [searchQ, setSearchQ] = useState(""); @@ -87,12 +68,6 @@ export function BandPage() { enabled: !!bandId && tab === "dates", }); - const { data: members } = useQuery({ - queryKey: ["members", bandId], - queryFn: () => api.get(`/bands/${bandId}/members`), - enabled: !!bandId, - }); - // Search results — only fetch when user has triggered a search const searchParams = new URLSearchParams(); if (searchQ) searchParams.set("q", searchQ); @@ -127,7 +102,6 @@ export function BandPage() { const url = `/api/v1/bands/${bandId}/nc-scan/stream`; try { - // credentials: "include" sends the rh_token httpOnly cookie automatically const resp = await fetch(url, { credentials: "include" }); if (!resp.ok || !resp.body) { const text = await resp.text().catch(() => resp.statusText); @@ -178,31 +152,6 @@ export function BandPage() { } } - const inviteMutation = useMutation({ - mutationFn: () => api.post(`/bands/${bandId}/invites`, {}), - onSuccess: (invite) => { - const url = `${window.location.origin}/invite/${invite.token}`; - setInviteLink(url); - navigator.clipboard.writeText(url).catch(() => {}); - }, - }); - - const removeMemberMutation = useMutation({ - mutationFn: (memberId: string) => api.delete(`/bands/${bandId}/members/${memberId}`), - onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }), - }); - - const updateFolderMutation = useMutation({ - mutationFn: (nc_folder_path: string) => - api.patch(`/bands/${bandId}`, { nc_folder_path }), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["band", bandId] }); - setEditingFolder(false); - }, - }); - - const amAdmin = members?.some((m) => m.role === "admin") ?? false; - function addTag() { const t = searchTagInput.trim(); if (t && !searchTags.includes(t)) setSearchTags((prev) => [...prev, t]); @@ -217,405 +166,292 @@ export function BandPage() { if (!band) return
Band not found
; return ( -
-
- {/* Band header */} -
-

{band.name}

+
+ {/* ── Page header ───────────────────────────────────────── */} +
+
+

{band.name}

{band.genre_tags.length > 0 && ( -
+
{band.genre_tags.map((t: string) => ( - {t} + + {t} + ))}
)}
- {/* Nextcloud folder */} -
-
-
- NEXTCLOUD SCAN FOLDER -
- {band.nc_folder_path ?? `bands/${band.slug}/`} -
-
- {amAdmin && !editingFolder && ( - - )} -
- {editingFolder && ( -
- setFolderInput(e.target.value)} - placeholder={`bands/${band.slug}/`} - style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, fontFamily: "monospace", boxSizing: "border-box" }} - /> -
- - -
-
- )} +
+ +
+
- {/* Members */} -
-
-

Members

- {amAdmin && ( - <> - - - {/* Search for users to invite (new feature) */} - {/* Temporarily hide user search until backend supports it */} - - )} -
- - {inviteLink && ( -
-

Invite link (copied to clipboard, valid 72h):

- {inviteLink} - -
- )} - -
- {members?.map((m) => ( -
-
- {m.display_name} - {m.email} -
-
- - {m.role} - - {amAdmin && m.role !== "admin" && ( - - )} -
-
- ))} -
- - {/* Admin: Invite Management Section (new feature) */} - {amAdmin && } + {/* ── Scan feedback ─────────────────────────────────────── */} + {scanning && scanProgress && ( +
+ {scanProgress}
+ )} + {scanMsg && ( +
+ {scanMsg} +
+ )} - {/* Recordings header */} -
-

Recordings

+ {/* ── New song form ─────────────────────────────────────── */} + {showCreate && ( +
+ {error &&

{error}

} + + setTitle(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()} + style={{ + width: "100%", + padding: "8px 12px", + background: "rgba(255,255,255,0.05)", + border: "1px solid rgba(255,255,255,0.08)", + borderRadius: 7, + color: "#eeeef2", + marginBottom: 12, + fontSize: 14, + fontFamily: "inherit", + boxSizing: "border-box", + outline: "none", + }} + autoFocus + />
- -
-
- - {scanning && scanProgress && ( -
- {scanProgress} -
- )} - {scanMsg && ( -
- {scanMsg} -
- )} - - {showCreate && ( -
- {error &&

{error}

} - - setTitle(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()} - style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", marginBottom: 12, fontSize: 14, boxSizing: "border-box" }} - autoFocus - /> -
- - -
-
- )} - - {/* Tabs */} -
- {(["dates", "search"] as const).map((t) => ( - - ))} + +
+ )} - {/* By Date tab */} - {tab === "dates" && ( -
- {sessions?.map((s) => ( - + {(["dates", "search"] as const).map((t) => ( + + ))} +
+ + {/* ── By Date tab ───────────────────────────────────────── */} + {tab === "dates" && ( +
+ {sessions?.map((s) => ( + { + (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.045)"; + (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.09)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)"; + (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.05)"; + }} + > +
+ + {weekday(s.date)} + + {formatDate(s.date)} + {s.label && ( + + {s.label} + + )} +
+ -
- {weekday(s.date)} - {formatDate(s.date)} - {s.label && ( - {s.label} - )} -
- - {s.recording_count} recording{s.recording_count !== 1 ? "s" : ""} - - - ))} - {sessions?.length === 0 && !unattributedSongs?.length && ( -

- No sessions yet. Scan Nextcloud to import from {band.nc_folder_path ?? `bands/${band.slug}/`}. -

- )} + {s.recording_count} recording{s.recording_count !== 1 ? "s" : ""} +
+ + ))} - {/* Songs not linked to any dated session */} - {!!unattributedSongs?.length && ( -
-
- UNATTRIBUTED RECORDINGS -
-
- {unattributedSongs.map((song) => ( - -
-
{song.title}
-
- {song.tags.map((t) => ( - {t} - ))} -
-
- - {song.status} - {song.version_count} version{song.version_count !== 1 ? "s" : ""} - - - ))} -
-
- )} -
- )} + {sessions?.length === 0 && !unattributedSongs?.length && ( +

+ No sessions yet. Scan Nextcloud or create a song to get started. +

+ )} - {/* Search tab */} - {tab === "search" && ( -
- {/* Filters */} -
-
-
- - setSearchQ(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") { setSearchDirty(true); qc.invalidateQueries({ queryKey: ["songs-search", bandId] }); } }} - placeholder="Search by name…" - style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }} - /> -
-
- - setSearchKey(e.target.value)} - placeholder="e.g. Am, C, F#" - style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }} - /> -
-
- - setSearchBpmMin(e.target.value)} - type="number" - min={0} - placeholder="e.g. 80" - style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }} - /> -
-
- - setSearchBpmMax(e.target.value)} - type="number" - min={0} - placeholder="e.g. 140" - style={{ width: "100%", padding: "8px 12px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 13, boxSizing: "border-box" }} - /> -
-
- - {/* Tag filter */} -
- -
- {searchTags.map((t) => ( - - {t} - - - ))} -
-
- setSearchTagInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && addTag()} - placeholder="Add tag…" - style={{ flex: 1, padding: "6px 10px", background: "var(--bg-inset)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--text)", fontSize: 12 }} - /> - -
-
- - -
- - {/* Results */} - {searchFetching &&

Searching…

} - {!searchFetching && searchDirty && ( -
- {searchResults?.map((song) => ( + Unattributed Recordings +
+
+ {unattributedSongs.map((song) => (
-
{song.title}
+
{song.title}
{song.tags.map((t) => ( - {t} + + {t} + ))} - {song.global_key && ( - {song.global_key} - )} - {song.global_bpm && ( - {song.global_bpm.toFixed(0)} BPM - )}
-
- {song.status} +
+ + {song.status} + {song.version_count} version{song.version_count !== 1 ? "s" : ""}
))} - {searchResults?.length === 0 && ( -

No songs match your filters.

- )}
- )} - {!searchDirty && ( -

Enter filters above and hit Search.

- )} +
+ )} +
+ )} + + {/* ── Search tab ────────────────────────────────────────── */} + {tab === "search" && ( +
+
+
+
+ + setSearchQ(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { setSearchDirty(true); qc.invalidateQueries({ queryKey: ["songs-search", bandId] }); } }} + placeholder="Search by name…" + style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }} + /> +
+
+ + setSearchKey(e.target.value)} + placeholder="e.g. Am, C, F#" + style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }} + /> +
+
+ + setSearchBpmMin(e.target.value)} + type="number" + min={0} + placeholder="e.g. 80" + style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }} + /> +
+
+ + setSearchBpmMax(e.target.value)} + type="number" + min={0} + placeholder="e.g. 140" + style={{ width: "100%", padding: "8px 12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 13, fontFamily: "inherit", boxSizing: "border-box", outline: "none" }} + /> +
+
+ +
+ +
+ {searchTags.map((t) => ( + + {t} + + + ))} +
+
+ setSearchTagInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && addTag()} + placeholder="Add tag…" + style={{ flex: 1, padding: "6px 10px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 7, color: "#eeeef2", fontSize: 12, fontFamily: "inherit", outline: "none" }} + /> + +
+
+ +
- )} -
+ + {searchFetching &&

Searching…

} + {!searchFetching && searchDirty && ( +
+ {searchResults?.map((song) => ( + +
+
{song.title}
+
+ {song.tags.map((t) => ( + {t} + ))} + {song.global_key && ( + {song.global_key} + )} + {song.global_bpm && ( + {song.global_bpm.toFixed(0)} BPM + )} +
+
+
+ {song.status} + {song.version_count} version{song.version_count !== 1 ? "s" : ""} +
+ + ))} + {searchResults?.length === 0 && ( +

No songs match your filters.

+ )} +
+ )} + {!searchDirty && ( +

Enter filters above and hit Search.

+ )} +
+ )}
); } diff --git a/web/src/pages/BandSettingsPage.test.md b/web/src/pages/BandSettingsPage.test.md new file mode 100644 index 0000000..7315ce7 --- /dev/null +++ b/web/src/pages/BandSettingsPage.test.md @@ -0,0 +1,151 @@ +# BandSettingsPage — Test Cases + +Feature branch: `feature/main-view-refactor` + +--- + +## 1. BandPage cleanliness + +**TC-01** — BandPage renders no member list +Navigate to `/bands/:bandId`. Assert that no member name, email, or role badge is rendered. + +**TC-02** — BandPage renders no invite button +Navigate to `/bands/:bandId`. Assert that "+ Invite" is absent. + +**TC-03** — BandPage renders no NC folder widget +Navigate to `/bands/:bandId`. Assert that "NEXTCLOUD SCAN FOLDER" / "SCAN PATH" label is absent. + +**TC-04** — BandPage still shows sessions +Navigate to `/bands/:bandId`. Assert that dated session rows are rendered (or empty-state message if no sessions). + +**TC-05** — BandPage still shows Scan Nextcloud button +Navigate to `/bands/:bandId`. Assert "⟳ Scan Nextcloud" button is present. + +**TC-06** — BandPage still shows + New Song button +Navigate to `/bands/:bandId`. Assert "+ New Song" button is present. + +**TC-07** — BandPage search tab remains functional +Click "Search" tab, enter a query, click Search. Assert results render or empty state shown. + +--- + +## 2. Navigation — sidebar + +**TC-08** — Band settings nav items appear when band is active +Log in, select any band. Assert sidebar contains "Members", "Storage", "Band Settings" nav items under a "Band Settings" section label. + +**TC-09** — Band settings nav items absent when no band active +Navigate to `/` (no band selected). Assert sidebar does NOT show "Members", "Storage", "Band Settings" items. + +**TC-10** — Members nav item highlights correctly +Navigate to `/bands/:bandId/settings/members`. Assert "Members" nav item has amber active style; "Storage" and "Band Settings" do not. + +**TC-11** — Storage nav item highlights correctly +Navigate to `/bands/:bandId/settings/storage`. Assert "Storage" nav item is active. + +**TC-12** — Band Settings nav item highlights correctly +Navigate to `/bands/:bandId/settings/band`. Assert "Band Settings" nav item is active. + +**TC-13** — Switching bands from band switcher while on settings stays on the same panel type +On `/bands/A/settings/storage`, switch to band B. Assert navigation goes to `/bands/B` (library) — band switcher navigates to library, which is correct. Band settings panel is band-specific. + +--- + +## 3. Routing + +**TC-14** — Base settings URL redirects to members panel +Navigate directly to `/bands/:bandId/settings`. Assert browser URL redirects to `/bands/:bandId/settings/members` without a visible flash. + +**TC-15** — Direct URL navigation to storage panel works +Navigate directly to `/bands/:bandId/settings/storage`. Assert Storage panel content is rendered. + +**TC-16** — Direct URL navigation to band panel works +Navigate directly to `/bands/:bandId/settings/band`. Assert Band Settings panel content is rendered. + +**TC-17** — Unknown panel falls back to members +Navigate to `/bands/:bandId/settings/unknown-panel`. Assert Members panel is rendered (fallback in `activePanel` logic). + +--- + +## 4. Members panel — access control + +**TC-18** — Admin sees + Invite button +Log in as admin, navigate to `/bands/:bandId/settings/members`. Assert "+ Invite" button is present. + +**TC-19** — Non-admin does not see + Invite button +Log in as member (non-admin), navigate to `/bands/:bandId/settings/members`. Assert "+ Invite" button is absent. + +**TC-20** — Admin sees Remove button on non-admin members +Log in as admin. Assert "Remove" button appears next to member-role users. + +**TC-21** — Non-admin does not see Remove button +Log in as member. Assert no "Remove" button appears for any member. + +**TC-22** — Admin does not see Remove button for other admins +Log in as admin. Assert "Remove" button is absent next to rows where role is "admin". + +**TC-23** — Pending Invites section only visible to admins +Log in as member. Assert "Pending Invites" heading is absent. + +--- + +## 5. Members panel — functionality + +**TC-24** — Generate invite creates a link and copies to clipboard +As admin, click "+ Invite". Assert an invite URL (`/invite/`) appears in the UI and `navigator.clipboard.writeText` was called with it. + +**TC-25** — Dismiss hides the invite link banner +After generating an invite, click "Dismiss". Assert the invite link banner disappears. + +**TC-26** — Remove member removes from list +As admin, click "Remove" on a member-role row. Mock the DELETE endpoint to 200. Assert the members query is invalidated and the member disappears. + +**TC-27** — Revoke invite removes from pending list +As admin, click "Revoke" on a pending invite. Mock the DELETE endpoint. Assert the invites query is invalidated. + +**TC-28** — Copy invite link writes to clipboard +In the pending invites list, click "Copy" on an invite row. Assert `navigator.clipboard.writeText` was called with the correct URL. + +--- + +## 6. Storage panel — access control and functionality + +**TC-29** — Admin sees Edit button on NC folder path +Log in as admin, navigate to storage panel. Assert "Edit" button is visible next to the scan path. + +**TC-30** — Non-admin does not see Edit button +Log in as member, navigate to storage panel. Assert "Edit" button is absent. + +**TC-31** — Editing NC folder path and saving updates the band +As admin, click Edit, change the path, click Save. Mock PATCH `/bands/:bandId` to 200. Assert band query is invalidated and edit form closes. + +**TC-32** — Cancel edit closes form without saving +As admin, click Edit, change the path, click Cancel. Assert the form disappears and PATCH was not called. + +**TC-33** — Default path shown when nc_folder_path is null +When `band.nc_folder_path` is null, assert the displayed path is `bands//`. + +--- + +## 7. Band settings panel — access control and functionality + +**TC-34** — Admin sees Save changes button +Log in as admin, navigate to band panel. Assert "Save changes" button is present. + +**TC-35** — Non-admin does not see Save button, sees info text +Log in as member, navigate to band panel. Assert "Save changes" absent and "Only admins can edit band settings." is shown. + +**TC-36** — Name field is disabled for non-admins +Log in as member. Assert the band name input has the `disabled` attribute. + +**TC-37** — Saving band name and tags calls PATCH +As admin, change band name to "New Name", click Save. Assert PATCH `/bands/:bandId` called with `{ name: "New Name", genre_tags: [...] }`. + +**TC-38** — Adding a genre tag updates the tag list +Type "punk" in the tag input, press Enter. Assert "punk" pill appears in the tag list. + +**TC-39** — Removing a genre tag removes its pill +Click the × on a genre tag pill. Assert the pill disappears from the list. + +**TC-40** — Delete band button disabled for non-admins +Log in as member. Assert the "Delete band" button has the `disabled` attribute. diff --git a/web/src/pages/BandSettingsPage.test.tsx b/web/src/pages/BandSettingsPage.test.tsx new file mode 100644 index 0000000..bc6aeba --- /dev/null +++ b/web/src/pages/BandSettingsPage.test.tsx @@ -0,0 +1,320 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { screen, fireEvent, waitFor } from "@testing-library/react"; +import { renderWithProviders } from "../test/helpers"; +import { BandSettingsPage } from "./BandSettingsPage"; + +// ── Shared fixtures ─────────────────────────────────────────────────────────── + +const ME = { id: "m-me", email: "s@example.com", display_name: "Steffen", avatar_url: null, created_at: "" }; + +const BAND = { + id: "band-1", + name: "Loud Hands", + slug: "loud-hands", + genre_tags: ["post-rock", "math-rock"], + nc_folder_path: "bands/loud-hands/", +}; + +const MEMBERS_ADMIN = [ + { id: "m-me", display_name: "Steffen", email: "s@example.com", role: "admin", joined_at: "" }, + { id: "m-2", display_name: "Alex", email: "a@example.com", role: "member", joined_at: "" }, +]; + +const MEMBERS_NON_ADMIN = [ + { id: "m-me", display_name: "Steffen", email: "s@example.com", role: "member", joined_at: "" }, + { id: "m-2", display_name: "Alex", email: "a@example.com", role: "admin", joined_at: "" }, +]; + +const INVITES_RESPONSE = { + invites: [ + { + id: "inv-1", + token: "abcdef1234567890abcd", + role: "member", + expires_at: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(), + is_used: false, + band_id: "band-1", + created_at: new Date().toISOString(), + used_at: null, + }, + ], + total: 1, + pending: 1, +}; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const { + mockGetBand, + mockApiGet, + mockApiPost, + mockApiPatch, + mockApiDelete, + mockListInvites, + mockRevokeInvite, +} = vi.hoisted(() => ({ + mockGetBand: vi.fn(), + mockApiGet: vi.fn(), + mockApiPost: vi.fn(), + mockApiPatch: vi.fn(), + mockApiDelete: vi.fn(), + mockListInvites: vi.fn(), + mockRevokeInvite: vi.fn(), +})); + +vi.mock("../api/bands", () => ({ getBand: mockGetBand })); +vi.mock("../api/invites", () => ({ + listInvites: mockListInvites, + revokeInvite: mockRevokeInvite, +})); +vi.mock("../api/client", () => ({ + api: { + get: mockApiGet, + post: mockApiPost, + patch: mockApiPatch, + delete: mockApiDelete, + }, + isLoggedIn: vi.fn().mockReturnValue(true), +})); + +// ── Default mock implementations ───────────────────────────────────────────── + +afterEach(() => vi.clearAllMocks()); + +function setupApiGet(members: typeof MEMBERS_ADMIN) { + mockApiGet.mockImplementation((url: string) => { + if (url === "/auth/me") return Promise.resolve(ME); + if (url.includes("/members")) return Promise.resolve(members); + return Promise.resolve([]); + }); +} + +beforeEach(() => { + mockGetBand.mockResolvedValue(BAND); + setupApiGet(MEMBERS_ADMIN); + mockApiPost.mockResolvedValue({ id: "inv-new", token: "newtoken123", role: "member", expires_at: "" }); + mockApiPatch.mockResolvedValue(BAND); + mockApiDelete.mockResolvedValue({}); + mockListInvites.mockResolvedValue(INVITES_RESPONSE); + mockRevokeInvite.mockResolvedValue({}); +}); + +// ── Render helpers ──────────────────────────────────────────────────────────── + +function renderPanel(panel: "members" | "storage" | "band", members = MEMBERS_ADMIN) { + setupApiGet(members); + return renderWithProviders(, { + path: "/bands/:bandId/settings/:panel", + route: `/bands/band-1/settings/${panel}`, + }); +} + +// ── Routing ─────────────────────────────────────────────────────────────────── + +describe("BandSettingsPage — routing (TC-15 to TC-17)", () => { + it("TC-15: renders Storage panel for /settings/storage", async () => { + renderPanel("storage"); + const heading = await screen.findByRole("heading", { name: /storage/i }); + expect(heading).toBeTruthy(); + }); + + it("TC-16: renders Band Settings panel for /settings/band", async () => { + renderPanel("band"); + const heading = await screen.findByRole("heading", { name: /band settings/i }); + expect(heading).toBeTruthy(); + }); + + it("TC-17: unknown panel falls back to Members", async () => { + mockApiGet.mockResolvedValue(MEMBERS_ADMIN); + renderWithProviders(, { + path: "/bands/:bandId/settings/:panel", + route: "/bands/band-1/settings/unknown-panel", + }); + const heading = await screen.findByRole("heading", { name: /members/i }); + expect(heading).toBeTruthy(); + }); +}); + +// ── Members panel — access control ─────────────────────────────────────────── + +describe("BandSettingsPage — Members panel access control (TC-18 to TC-23)", () => { + it("TC-18: admin sees + Invite button", async () => { + renderPanel("members", MEMBERS_ADMIN); + const btn = await screen.findByText(/\+ invite/i); + expect(btn).toBeTruthy(); + }); + + it("TC-19: non-admin does not see + Invite button", async () => { + renderPanel("members", MEMBERS_NON_ADMIN); + await screen.findByText("Alex"); // wait for members to load + expect(screen.queryByText(/\+ invite/i)).toBeNull(); + }); + + it("TC-20: admin sees Remove button on non-admin members", async () => { + renderPanel("members", MEMBERS_ADMIN); + const removeBtn = await screen.findByText("Remove"); + expect(removeBtn).toBeTruthy(); + }); + + it("TC-21: non-admin does not see any Remove button", async () => { + renderPanel("members", MEMBERS_NON_ADMIN); + await screen.findByText("Alex"); + expect(screen.queryByText("Remove")).toBeNull(); + }); + + it("TC-22: admin does not see Remove on admin-role members", async () => { + renderPanel("members", MEMBERS_ADMIN); + await screen.findByText("Steffen"); + // Only one Remove button — for Alex (member), not Steffen (admin) + const removeBtns = screen.queryAllByText("Remove"); + expect(removeBtns).toHaveLength(1); + }); + + it("TC-23: Pending Invites section hidden from non-admins", async () => { + renderPanel("members", MEMBERS_NON_ADMIN); + await screen.findByText("Alex"); + expect(screen.queryByText(/pending invites/i)).toBeNull(); + }); +}); + +// ── Members panel — functionality ───────────────────────────────────────────── + +describe("BandSettingsPage — Members panel functionality (TC-24 to TC-28)", () => { + it("TC-24: generate invite shows link in UI", async () => { + const token = "tok123abc456def789gh"; + mockApiPost.mockResolvedValue({ id: "inv-new", token, role: "member", expires_at: "" }); + renderPanel("members", MEMBERS_ADMIN); + const inviteBtn = await screen.findByText(/\+ invite/i); + fireEvent.click(inviteBtn); + const linkEl = await screen.findByText(new RegExp(token)); + expect(linkEl).toBeTruthy(); + }); + + it("TC-26: remove member calls DELETE endpoint", async () => { + renderPanel("members", MEMBERS_ADMIN); + const removeBtn = await screen.findByText("Remove"); + fireEvent.click(removeBtn); + await waitFor(() => { + expect(mockApiDelete).toHaveBeenCalledWith("/bands/band-1/members/m-2"); + }); + }); + + it("TC-27: revoke invite calls revokeInvite and refetches", async () => { + renderPanel("members", MEMBERS_ADMIN); + const revokeBtn = await screen.findByText("Revoke"); + fireEvent.click(revokeBtn); + await waitFor(() => { + expect(mockRevokeInvite).toHaveBeenCalledWith("inv-1"); + }); + }); +}); + +// ── Storage panel — access control ─────────────────────────────────────────── + +describe("BandSettingsPage — Storage panel access control (TC-29 to TC-33)", () => { + it("TC-29: admin sees Edit button", async () => { + renderPanel("storage", MEMBERS_ADMIN); + const edit = await screen.findByText("Edit"); + expect(edit).toBeTruthy(); + }); + + it("TC-30: non-admin does not see Edit button", async () => { + renderPanel("storage", MEMBERS_NON_ADMIN); + await screen.findByText(/scan path/i); + expect(screen.queryByText("Edit")).toBeNull(); + }); + + it("TC-31: saving NC folder path calls PATCH and closes form", async () => { + renderPanel("storage", MEMBERS_ADMIN); + fireEvent.click(await screen.findByText("Edit")); + const input = screen.getByPlaceholderText(/bands\/loud-hands\//i); + fireEvent.change(input, { target: { value: "bands/custom-path/" } }); + fireEvent.click(screen.getByText("Save")); + await waitFor(() => { + expect(mockApiPatch).toHaveBeenCalledWith( + "/bands/band-1", + { nc_folder_path: "bands/custom-path/" } + ); + }); + }); + + it("TC-32: cancel edit closes form without calling PATCH", async () => { + renderPanel("storage", MEMBERS_ADMIN); + fireEvent.click(await screen.findByText("Edit")); + fireEvent.click(screen.getByText("Cancel")); + await waitFor(() => { + expect(mockApiPatch).not.toHaveBeenCalled(); + }); + expect(screen.queryByText("Save")).toBeNull(); + }); + + it("TC-33: shows default path when nc_folder_path is null", async () => { + mockGetBand.mockResolvedValueOnce({ ...BAND, nc_folder_path: null }); + renderPanel("storage", MEMBERS_ADMIN); + const path = await screen.findByText("bands/loud-hands/"); + expect(path).toBeTruthy(); + }); +}); + +// ── Band settings panel — access control ────────────────────────────────────── + +describe("BandSettingsPage — Band Settings panel access control (TC-34 to TC-40)", () => { + it("TC-34: admin sees Save changes button", async () => { + renderPanel("band", MEMBERS_ADMIN); + const btn = await screen.findByText(/save changes/i); + expect(btn).toBeTruthy(); + }); + + it("TC-35: non-admin sees info text instead of Save button", async () => { + renderPanel("band", MEMBERS_NON_ADMIN); + // Wait for the band panel heading so we know the page has fully loaded + await screen.findByRole("heading", { name: /band settings/i }); + // Once queries settle, the BandPanel-level info text should appear and Save should be absent + await waitFor(() => { + expect(screen.getByText(/only admins can edit band settings/i)).toBeTruthy(); + }); + expect(screen.queryByText(/save changes/i)).toBeNull(); + }); + + it("TC-36: name field is disabled for non-admins", async () => { + renderPanel("band", MEMBERS_NON_ADMIN); + const input = await screen.findByDisplayValue("Loud Hands"); + expect((input as HTMLInputElement).disabled).toBe(true); + }); + + it("TC-37: saving calls PATCH with name and genre_tags", async () => { + renderPanel("band", MEMBERS_ADMIN); + await screen.findByText(/save changes/i); + fireEvent.click(screen.getByText(/save changes/i)); + await waitFor(() => { + expect(mockApiPatch).toHaveBeenCalledWith("/bands/band-1", { + name: "Loud Hands", + genre_tags: ["post-rock", "math-rock"], + }); + }); + }); + + it("TC-38: adding a genre tag shows the new pill", async () => { + renderPanel("band", MEMBERS_ADMIN); + const tagInput = await screen.findByPlaceholderText(/add genre tag/i); + fireEvent.change(tagInput, { target: { value: "punk" } }); + fireEvent.keyDown(tagInput, { key: "Enter" }); + expect(screen.getByText("punk")).toBeTruthy(); + }); + + it("TC-39: removing a genre tag removes its pill", async () => { + renderPanel("band", MEMBERS_ADMIN); + // Find the × button next to "post-rock" + await screen.findByText("post-rock"); + // There are two tags; find the × buttons + const removeButtons = screen.getAllByText("×"); + fireEvent.click(removeButtons[0]); + expect(screen.queryByText("post-rock")).toBeNull(); + }); + + it("TC-40: Delete band button is disabled for non-admins", async () => { + renderPanel("band", MEMBERS_NON_ADMIN); + const deleteBtn = await screen.findByText(/delete band/i); + expect((deleteBtn as HTMLButtonElement).disabled).toBe(true); + }); +}); diff --git a/web/src/pages/BandSettingsPage.tsx b/web/src/pages/BandSettingsPage.tsx new file mode 100644 index 0000000..57905c7 --- /dev/null +++ b/web/src/pages/BandSettingsPage.tsx @@ -0,0 +1,867 @@ +import { useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { getBand } from "../api/bands"; +import { api } from "../api/client"; +import { listInvites, revokeInvite } from "../api/invites"; +import type { MemberRead } from "../api/auth"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface BandMember { + id: string; + display_name: string; + email: string; + role: string; + joined_at: string; +} + +interface BandInvite { + id: string; + token: string; + role: string; + expires_at: string | null; + is_used: boolean; +} + +type Panel = "members" | "storage" | "band"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function formatExpiry(expiresAt: string | null | undefined): string { + if (!expiresAt) return "No expiry"; + const date = new Date(expiresAt); + const diffHours = Math.floor((date.getTime() - Date.now()) / (1000 * 60 * 60)); + if (diffHours <= 0) return "Expired"; + if (diffHours < 24) return `Expires in ${diffHours}h`; + return `Expires in ${Math.floor(diffHours / 24)}d`; +} + +function isActive(invite: BandInvite): boolean { + return !invite.is_used && !!invite.expires_at && new Date(invite.expires_at) > new Date(); +} + +// ── Panel nav item ──────────────────────────────────────────────────────────── + +function PanelNavItem({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + const [hovered, setHovered] = useState(false); + return ( + + ); +} + +// ── Section title ───────────────────────────────────────────────────────────── + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Divider() { + return
; +} + +// ── Members panel ───────────────────────────────────────────────────────────── + +function MembersPanel({ + bandId, + amAdmin, + members, + membersLoading, +}: { + bandId: string; + amAdmin: boolean; + members: BandMember[] | undefined; + membersLoading: boolean; +}) { + const qc = useQueryClient(); + const [inviteLink, setInviteLink] = useState(null); + + const { data: invitesData, isLoading: invitesLoading } = useQuery({ + queryKey: ["invites", bandId], + queryFn: () => listInvites(bandId), + enabled: amAdmin, + retry: false, + }); + + const inviteMutation = useMutation({ + mutationFn: () => api.post(`/bands/${bandId}/invites`, {}), + onSuccess: (invite) => { + const url = `${window.location.origin}/invite/${invite.token}`; + setInviteLink(url); + navigator.clipboard.writeText(url).catch(() => {}); + qc.invalidateQueries({ queryKey: ["invites", bandId] }); + }, + }); + + const removeMutation = useMutation({ + mutationFn: (memberId: string) => api.delete(`/bands/${bandId}/members/${memberId}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }), + }); + + const revokeMutation = useMutation({ + mutationFn: (inviteId: string) => revokeInvite(inviteId), + onSuccess: () => qc.invalidateQueries({ queryKey: ["invites", bandId] }), + }); + + const activeInvites = invitesData?.invites.filter(isActive) ?? []; + + return ( +
+ {/* Member list */} +
+ Members + {amAdmin && ( + + )} +
+ + {inviteLink && ( +
+

+ Invite link (copied to clipboard · valid 72h): +

+ {inviteLink} + +
+ )} + + {membersLoading ? ( +

Loading…

+ ) : ( +
+ {members?.map((m) => ( +
+
+ {m.display_name.slice(0, 2).toUpperCase()} +
+
+
{m.display_name}
+
{m.email}
+
+ + {m.role} + + {amAdmin && m.role !== "admin" && ( + + )} +
+ ))} +
+ )} + + {/* Role info cards */} +
+
+
Admin
+
+ Upload, delete, manage members and storage +
+
+
+
Member
+
+ Listen, comment, annotate — no upload or management +
+
+
+ + {/* Pending invites — admin only */} + {amAdmin && ( + <> + + Pending Invites + + {invitesLoading ? ( +

Loading invites…

+ ) : activeInvites.length === 0 ? ( +

No pending invites.

+ ) : ( +
+ {activeInvites.map((invite) => ( +
+
+ + {invite.token.slice(0, 8)}…{invite.token.slice(-4)} + +
+ {formatExpiry(invite.expires_at)} · {invite.role} +
+
+ + +
+ ))} +
+ )} +

+ No account needed to accept an invite. +

+ + )} +
+ ); +} + +// ── Storage panel ───────────────────────────────────────────────────────────── + +function StoragePanel({ + bandId, + band, + amAdmin, +}: { + bandId: string; + band: { slug: string; nc_folder_path: string | null }; + amAdmin: boolean; +}) { + const qc = useQueryClient(); + const [editing, setEditing] = useState(false); + const [folderInput, setFolderInput] = useState(""); + + const updateMutation = useMutation({ + mutationFn: (nc_folder_path: string) => api.patch(`/bands/${bandId}`, { nc_folder_path }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["band", bandId] }); + setEditing(false); + }, + }); + + const defaultPath = `bands/${band.slug}/`; + const currentPath = band.nc_folder_path ?? defaultPath; + + return ( +
+ Nextcloud Scan Folder +

+ RehearsalHub reads recordings directly from your Nextcloud — files are never copied to our + servers. +

+ +
+
+
+
+ Scan path +
+ + {currentPath} + +
+ {amAdmin && !editing && ( + + )} +
+ + {editing && ( +
+ setFolderInput(e.target.value)} + placeholder={defaultPath} + style={{ + width: "100%", + padding: "8px 12px", + background: "rgba(255,255,255,0.05)", + border: "1px solid rgba(255,255,255,0.08)", + borderRadius: 7, + color: "#eeeef2", + fontSize: 13, + fontFamily: "monospace", + boxSizing: "border-box", + outline: "none", + }} + /> +
+ + +
+
+ )} +
+
+ ); +} + +// ── Band settings panel ─────────────────────────────────────────────────────── + +function BandPanel({ + bandId, + band, + amAdmin, +}: { + bandId: string; + band: { name: string; slug: string; genre_tags: string[] }; + amAdmin: boolean; +}) { + const qc = useQueryClient(); + const [nameInput, setNameInput] = useState(band.name); + const [tagInput, setTagInput] = useState(""); + const [tags, setTags] = useState(band.genre_tags); + const [saved, setSaved] = useState(false); + + const updateMutation = useMutation({ + mutationFn: (payload: { name?: string; genre_tags?: string[] }) => + api.patch(`/bands/${bandId}`, payload), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["band", bandId] }); + qc.invalidateQueries({ queryKey: ["bands"] }); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }, + }); + + function addTag() { + const t = tagInput.trim(); + if (t && !tags.includes(t)) setTags((prev) => [...prev, t]); + setTagInput(""); + } + + function removeTag(t: string) { + setTags((prev) => prev.filter((x) => x !== t)); + } + + return ( +
+ Identity + +
+ + setNameInput(e.target.value)} + disabled={!amAdmin} + style={{ + width: "100%", + padding: "8px 11px", + background: "rgba(255,255,255,0.05)", + border: "1px solid rgba(255,255,255,0.08)", + borderRadius: 7, + color: "#eeeef2", + fontSize: 13, + fontFamily: "inherit", + boxSizing: "border-box", + outline: "none", + opacity: amAdmin ? 1 : 0.5, + }} + /> +
+ +
+ +
+ {tags.map((t) => ( + + {t} + {amAdmin && ( + + )} + + ))} +
+ {amAdmin && ( +
+ setTagInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && addTag()} + placeholder="Add genre tag…" + style={{ + flex: 1, + padding: "6px 10px", + background: "rgba(255,255,255,0.05)", + border: "1px solid rgba(255,255,255,0.08)", + borderRadius: 7, + color: "#eeeef2", + fontSize: 12, + fontFamily: "inherit", + outline: "none", + }} + /> + +
+ )} +
+ + {amAdmin && ( + + )} + + {!amAdmin && ( +

Only admins can edit band settings.

+ )} + + + + {/* Danger zone */} +
+
Delete this band
+
+ Removes all members and deletes comments. Storage files are NOT deleted. +
+ +
+
+ ); +} + +// ── BandSettingsPage ────────────────────────────────────────────────────────── + +export function BandSettingsPage() { + const { bandId, panel } = useParams<{ bandId: string; panel: string }>(); + const navigate = useNavigate(); + + const activePanel: Panel = + panel === "storage" ? "storage" : panel === "band" ? "band" : "members"; + + const { data: band, isLoading: bandLoading } = useQuery({ + queryKey: ["band", bandId], + queryFn: () => getBand(bandId!), + enabled: !!bandId, + }); + + const { data: members, isLoading: membersLoading } = useQuery({ + queryKey: ["members", bandId], + queryFn: () => api.get(`/bands/${bandId}/members`), + enabled: !!bandId, + }); + + const { data: me } = useQuery({ + queryKey: ["me"], + queryFn: () => api.get("/auth/me"), + }); + + const amAdmin = + !!me && (members?.some((m) => m.id === me.id && m.role === "admin") ?? false); + + const go = (p: Panel) => navigate(`/bands/${bandId}/settings/${p}`); + + if (bandLoading) { + return
Loading…
; + } + if (!band) { + return
Band not found
; + } + + return ( +
+ {/* ── Left panel nav ─────────────────────────────── */} +
+
+ Band — {band.name} +
+ go("members")} /> + go("storage")} /> + go("band")} /> +
+ + {/* ── Panel content ──────────────────────────────── */} +
+
+ {activePanel === "members" && ( + <> +

+ Members +

+

+ Manage who has access to {band.name}'s recordings. +

+ + + )} + + {activePanel === "storage" && ( + <> +

+ Storage +

+

+ Configure where {band.name} stores recordings. +

+ + + )} + + {activePanel === "band" && ( + <> +

+ Band Settings +

+

+ Only admins can edit these settings. +

+ + + )} +
+
+
+ ); +} diff --git a/web/src/test/helpers.tsx b/web/src/test/helpers.tsx new file mode 100644 index 0000000..cc7e6a1 --- /dev/null +++ b/web/src/test/helpers.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { render } from "@testing-library/react"; + +export function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: 0 }, + mutations: { retry: false }, + }, + }); +} + +interface RenderOptions { + path?: string; + route?: string; +} + +export function renderWithProviders( + ui: React.ReactElement, + { path = "/", route = "/" }: RenderOptions = {} +) { + const queryClient = createTestQueryClient(); + return render( + + + + + + + + ); +}