mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 15:44:21 +00:00
Compare commits
312 Commits
v0.3.0
...
6c408fdfbe
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c408fdfbe | |||
| 6753f32520 | |||
| de06939b01 | |||
| c11d956ced | |||
| 9a232819a8 | |||
| f9ce8b9e9f | |||
| a6d2663a7d | |||
| 071e99f4da | |||
| b13ee987c7 | |||
| 543dbe6ffe | |||
| d0842c515f | |||
| 01d9a984fc | |||
|
|
a3975c9acd | ||
| e82f25a2ed | |||
| ec89f95880 | |||
| f101376bef | |||
| d582b1a1e9 | |||
| aca127e52e | |||
| 9ca8a46093 | |||
| 2370043986 | |||
| 386ac2f15f | |||
| cf95bf4862 | |||
| e5ad02df6d | |||
| a904a0d1a3 | |||
| 62605b3689 | |||
| d40321685e | |||
| 8920027a9c | |||
| c0bcb3069b | |||
| 4ec019f2b7 | |||
| 769385d8c7 | |||
| bcc5d2588a | |||
| 8bd9eb2236 | |||
|
|
399dfda6ea | ||
| f710e32659 | |||
|
|
49c1a06d98 | ||
| 14adcf2765 | |||
| 69fa105291 | |||
| 005288b3d8 | |||
| 9f01c64e5e | |||
| 491d056dd4 | |||
| 41d526af4c | |||
| ff4d1de2b7 | |||
| 51c6f62c44 | |||
| 1a7c97fb08 | |||
| 4ea24f07d1 | |||
| f4d8a354c4 | |||
| 3ac657486c | |||
| 0b0cd9160e | |||
| e1760ccd82 | |||
| 9bb95f603c | |||
| 5a6895ecdc | |||
| ae4e9c8db2 | |||
| 4b8ad359a4 | |||
| 48d42a92c9 | |||
| a789c62a68 | |||
| 4a3df3a040 | |||
| b10591ee60 | |||
| 70cd67f8bb | |||
| fa86a950fd | |||
| 520f58435c | |||
| e5c34c25d7 | |||
| 7a31bd4c76 | |||
| d0cdc48f3e | |||
| cf554fbb6e | |||
| 7368797a11 | |||
| 2747f51293 | |||
| 7742a04d9a | |||
| fffd93afeb | |||
| eaa37a262e | |||
| 184d9fae15 | |||
|
|
254bccb1d9 | ||
| d30c0a4b9e | |||
| 5c7edf40a8 | |||
| f4ec3af80c | |||
| e4b584e440 | |||
| 15486c584a | |||
| 1a7ddc1a97 | |||
| 32d03347fc | |||
| 8d9222d084 | |||
| 6add442e03 | |||
| e40aaff905 | |||
| d90f9968c5 | |||
| 5980c308a2 | |||
| b673d2ed2d | |||
|
|
bc863bd8ef | ||
| 734b98d286 | |||
| 3276fd98c2 | |||
| 73653c4271 | |||
| 0fd87c072d | |||
| 07af3f6e39 | |||
| 54feefcd5c | |||
| 9854deb43b | |||
| 1e80edd5ca | |||
| d938c3b03b | |||
| 6a7736ea5b | |||
| dfd9d5b70c | |||
| f37c024d65 | |||
| 3f2aaa34e5 | |||
| bc17005eca | |||
| 9d7f312527 | |||
| 8deededc05 | |||
| e642b73556 | |||
| e279cd4535 | |||
| 2964963f98 | |||
| e01ae5b815 | |||
| 57b9d4cc89 | |||
| 37c49dc0cc | |||
| 33d45568ec | |||
| 19771dd094 | |||
| 2211f85193 | |||
| 5ed3e96350 | |||
| d814c365ea | |||
| 907dffe362 | |||
| b38792a47f | |||
| 6a8b359c84 | |||
| 3e482c546c | |||
| ae35172c2a | |||
| 05c3111f8b | |||
| 1532896b27 | |||
| 00edb9e5a6 | |||
| 9cefe12872 | |||
| 942ff17c4f | |||
| e9abe14364 | |||
| 49cac03db8 | |||
| e5569fc4a5 | |||
| 3c6e767954 | |||
| 44c3271e1d | |||
| 3eaf79f2a7 | |||
| fbfab22666 | |||
| 47b88cb93a | |||
| 63f3679e1f | |||
| 694f842178 | |||
| 3926a2726e | |||
| ca602bd0bd | |||
| f3f2deea35 | |||
| fe2a466a4f | |||
| d2c4a84c32 | |||
| ecc1fe9989 | |||
| a724bc44e9 | |||
| 6cf118280a | |||
| 34ac76b87d | |||
| 07e0647174 | |||
| ab3cb56aa1 | |||
| f511dafad2 | |||
| 15538b243d | |||
| e43efc736a | |||
| 623f619f88 | |||
| 2519d46061 | |||
| 78de42d195 | |||
| ad2334c414 | |||
| 646a897b93 | |||
| 32218e5595 | |||
| 2f181d0f7f | |||
| 16fbbec992 | |||
| 60ab01b0c8 | |||
| 3619cf4ed4 | |||
| bfc5cc2d29 | |||
| db75bdcc89 | |||
| 834a7b1e7e | |||
| a8a525531e | |||
| 9125cbdad3 | |||
| 7044e42e94 | |||
| c478e8e8a1 | |||
| 924d710b2f | |||
| f3993accdc | |||
| 00affe3456 | |||
| b7be5a46a2 | |||
| 5fcd24db3e | |||
| c6d46df7a0 | |||
| 32cb89d329 | |||
| f3691d4dbf | |||
| 5dc427ce00 | |||
| a7c83d0c24 | |||
| 1c477f1022 | |||
| bc60cb3451 | |||
| 14b1a46508 | |||
| 1a06c31705 | |||
| 1e350bb0cf | |||
| 02c8100f0b | |||
| 66fe5e485b | |||
| 905df9f6dd | |||
| 8849deec21 | |||
| 043eab423f | |||
| 65db2d2030 | |||
|
|
1fd8d4af22 | ||
| f1b7fa56b6 | |||
| 689483d490 | |||
| e789025cd1 | |||
| 0769aa2bac | |||
|
|
46f4df155f | ||
| 46e4897881 | |||
|
|
fdf7c1738b | ||
| e6a2fdc0d5 | |||
| e4fb276cf7 | |||
|
|
c2ceb296fd | ||
| 49ecaac720 | |||
| f5cf0131cd | |||
|
|
a2d0f5fc36 | ||
| 5273062e59 | |||
| 9883b1d122 | |||
| 928ddcc758 | |||
|
|
c1e13ce02b | ||
| be3d4ee949 | |||
|
|
c44381bb86 | ||
| 7c0a4166c6 | |||
|
|
191d9f8cdb | ||
| 89d99be10b | |||
|
|
09f1ee6a57 | ||
| 374c910267 | |||
|
|
a1bc82d820 | ||
| bc10ad5c25 | |||
| 0be1bbf9a7 | |||
| d0f6f27526 | |||
| d97f5a0178 | |||
| 94ea2d0d78 | |||
| 3eb4424e86 | |||
| f7825e5a67 | |||
| 72b0ac08ce | |||
| 32628abf09 | |||
| d8de67ae6c | |||
| f55d2644c3 | |||
| 629baa9952 | |||
| 4766a166df | |||
| 7e9aab01cb | |||
| 904d4ce106 | |||
| 52aa406c6d | |||
| 3aa8c838e8 | |||
| 976425d660 | |||
| 0f97927219 | |||
| 829b359e82 | |||
| 5fd9755f12 | |||
| ccac439465 | |||
| 204dacd15e | |||
| 3ce92322f4 | |||
| e89b4a0e14 | |||
| c0de3538dc | |||
| a80b48956a | |||
| 96fc490c1d | |||
| 3b7deaa107 | |||
| 9da51aeb5e | |||
| 802f192dc0 | |||
| 7d05c8aacc | |||
| 27b81ef433 | |||
| d3ffcfbb53 | |||
| 96284c3dbd | |||
| 7cbe6fd272 | |||
| a946b8ae76 | |||
| c76057d605 | |||
| 25defa5b65 | |||
| d47f7d7fb0 | |||
| aef42ff33c | |||
|
|
562f84536d | ||
| 43b5f22793 | |||
|
|
9d0c9dbac6 | ||
| f6d2c3d407 | |||
|
|
e580a2d402 | ||
| c450b4ae2f | |||
| eb98f801a1 | |||
| f1f622cda3 | |||
| 5079f3c96d | |||
| 2861b1a719 | |||
| 9bc1b2ecd4 | |||
| 339ab657b8 | |||
| 28d2c053a8 | |||
| b6b4c01f0e | |||
| 5598c0861d | |||
| f0b6aa0d6e | |||
| cf2e1809a4 | |||
| b065938211 | |||
| 0aa67f5cc2 | |||
| f6de4fb839 | |||
| 7ccd36f0e4 | |||
| 54cefac4b7 | |||
| 032ae1354c | |||
| 03e78c3e6b | |||
| b00f01f033 | |||
| 51004d980d | |||
| e7a48fcd27 | |||
| d8528d1b42 | |||
| 3edce8a0b9 | |||
| d6680d8e03 | |||
| 8dd57bdf0b | |||
| a32e0957ed | |||
| ab00276f0d | |||
| d14eae4de4 | |||
| 71df436a93 | |||
| 1ee8d94789 | |||
| 9d82b6426c | |||
|
|
6280586aaf | ||
| ea916c3ecc | |||
| 3d03da221b | |||
| 26a208123a | |||
| 8a62c9e306 | |||
| a7a97caa6a | |||
| 8ebbe1e076 | |||
| ed6ac312ed | |||
| 2268ea48f2 | |||
| 69af630332 | |||
| 2e1ccb45d6 | |||
| 5633406f5c | |||
| ad4af2f82d | |||
| 8a4508e29f | |||
| de9e9102db | |||
| b4528c1561 | |||
| 1c8e16fc80 | |||
| bd90cf3813 | |||
| 836e233954 | |||
| 3d36c67c90 | |||
| dc8fc6c225 | |||
| e413e955c5 | |||
| c400d81c87 | |||
| 08e76671d0 |
4
.github/workflows/build-and-release.yml
vendored
4
.github/workflows/build-and-release.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Build, Push, and Release
|
name: Docker Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -8,7 +8,7 @@ on:
|
|||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
OWNER: lordmathis
|
OWNER: lordmathis
|
||||||
REPO: novamd
|
REPO: lemma
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-image:
|
build-and-push-image:
|
||||||
|
|||||||
102
.github/workflows/codeql.yml
vendored
Normal file
102
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# For most projects, this workflow file will not need changing; you simply need
|
||||||
|
# to commit it to your repository.
|
||||||
|
#
|
||||||
|
# You may wish to alter this file to override the set of languages analyzed,
|
||||||
|
# or to provide custom queries or build logic.
|
||||||
|
#
|
||||||
|
# ******** NOTE ********
|
||||||
|
# We have attempted to detect the languages in your repository. Please check
|
||||||
|
# the `language` matrix defined below to confirm you have the correct set of
|
||||||
|
# supported CodeQL languages.
|
||||||
|
#
|
||||||
|
name: "CodeQL Advanced"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
schedule:
|
||||||
|
- cron: '40 19 * * 0'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze (${{ matrix.language }})
|
||||||
|
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||||
|
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||||
|
# - https://gh.io/supported-runners-and-hardware-resources
|
||||||
|
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||||
|
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||||
|
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||||
|
permissions:
|
||||||
|
# required for all workflows
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
# required to fetch internal or private CodeQL packs
|
||||||
|
packages: read
|
||||||
|
|
||||||
|
# only required for workflows in private repositories
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- language: actions
|
||||||
|
build-mode: none
|
||||||
|
- language: go
|
||||||
|
build-mode: autobuild
|
||||||
|
- language: javascript-typescript
|
||||||
|
build-mode: none
|
||||||
|
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
|
||||||
|
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||||
|
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||||
|
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||||
|
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||||
|
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||||
|
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||||
|
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Add any setup steps before running the `github/codeql-action/init` action.
|
||||||
|
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
||||||
|
# or others). This is typically only required for manual builds.
|
||||||
|
# - name: Setup runtime (example)
|
||||||
|
# uses: actions/setup-example@v1
|
||||||
|
|
||||||
|
# Initializes the CodeQL tools for scanning.
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v3
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
build-mode: ${{ matrix.build-mode }}
|
||||||
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
|
# By default, queries listed here will override any specified in a config file.
|
||||||
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
|
|
||||||
|
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||||
|
# queries: security-extended,security-and-quality
|
||||||
|
|
||||||
|
# If the analyze step fails for one of the languages you are analyzing with
|
||||||
|
# "We were unable to automatically build your code", modify the matrix above
|
||||||
|
# to set the build mode to "manual" for that language. Then modify this step
|
||||||
|
# to build your code.
|
||||||
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
|
- if: matrix.build-mode == 'manual'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||||
|
'languages you are analyzing, replace this with the commands to build' \
|
||||||
|
'your code, for example:'
|
||||||
|
echo ' make bootstrap'
|
||||||
|
echo ' make release'
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v3
|
||||||
|
with:
|
||||||
|
category: "/language:${{matrix.language}}"
|
||||||
39
.github/workflows/frontend-tests.yml
vendored
Normal file
39
.github/workflows/frontend-tests.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Frontend Tests
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "*"
|
||||||
|
paths:
|
||||||
|
- "app/**"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Run Frontend Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./app
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: "./app/package-lock.json"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run Vitest tests
|
||||||
|
run: npm test
|
||||||
22
.github/workflows/go-test.yml
vendored
22
.github/workflows/go-test.yml
vendored
@@ -4,15 +4,35 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- "*"
|
- "*"
|
||||||
|
paths:
|
||||||
|
- "server/**"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Tests
|
name: Run Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: lemma_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./server
|
working-directory: ./server
|
||||||
@@ -29,6 +49,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: go test -tags=test,integration ./... -v
|
run: go test -tags=test,integration ./... -v
|
||||||
|
env:
|
||||||
|
LEMMA_TEST_POSTGRES_URL: "postgres://postgres:postgres@localhost:5432/lemma_test?sslmode=disable"
|
||||||
|
|
||||||
- name: Run Tests with Race Detector
|
- name: Run Tests with Race Detector
|
||||||
run: go test -tags=test,integration -race ./... -v
|
run: go test -tags=test,integration -race ./... -v
|
||||||
|
|||||||
43
.github/workflows/typescript.yml
vendored
Normal file
43
.github/workflows/typescript.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: TypeScript Type Check
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "*"
|
||||||
|
paths:
|
||||||
|
- "app/**"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
type-check:
|
||||||
|
name: TypeScript Type Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./app
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: "./app/package-lock.json"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run TypeScript type check
|
||||||
|
run: npm run type-check
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
run: npm run lint
|
||||||
|
continue-on-error: true
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -157,6 +157,7 @@ go.work.sum
|
|||||||
|
|
||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
.env.dev
|
||||||
|
|
||||||
main
|
main
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
36
.vscode/launch.json
vendored
Normal file
36
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Launch Backend",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"program": "${workspaceFolder}/server/cmd/server/main.go",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"envFile": "${workspaceFolder}/.env.local"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Launch Frontend",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["start"],
|
||||||
|
"cwd": "${workspaceFolder}/app",
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"envFile": "${workspaceFolder}/.env.local"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compounds": [
|
||||||
|
{
|
||||||
|
"name": "Launch Backend + Frontend",
|
||||||
|
"configurations": ["Launch Backend", "Launch Frontend"],
|
||||||
|
"presentation": {
|
||||||
|
"hidden": false,
|
||||||
|
"group": "",
|
||||||
|
"order": 1
|
||||||
|
},
|
||||||
|
"stopAll": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -15,6 +15,9 @@
|
|||||||
"go.lintOnSave": "package",
|
"go.lintOnSave": "package",
|
||||||
"go.formatTool": "goimports",
|
"go.formatTool": "goimports",
|
||||||
"go.testFlags": ["-tags=test,integration"],
|
"go.testFlags": ["-tags=test,integration"],
|
||||||
|
"go.testEnvVars": {
|
||||||
|
"LEMMA_TEST_POSTGRES_URL": "postgres://postgres:postgres@localhost:5432/lemma_test?sslmode=disable"
|
||||||
|
},
|
||||||
"[go]": {
|
"[go]": {
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Build the frontend
|
# Stage 1: Build the frontend
|
||||||
FROM node:20 AS frontend-builder
|
FROM node:24-slim AS frontend-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY app/package*.json ./
|
COPY app/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -13,21 +13,23 @@ RUN apt-get update && apt-get install -y gcc musl-dev
|
|||||||
COPY server/go.mod server/go.sum ./
|
COPY server/go.mod server/go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY server .
|
COPY server .
|
||||||
RUN CGO_ENABLED=1 GOOS=linux go build -o novamd ./cmd/server
|
RUN CGO_ENABLED=1 GOOS=linux go build -o lemma ./cmd/server
|
||||||
|
|
||||||
# Stage 3: Final stage
|
# Stage 3: Final stage
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y ca-certificates
|
||||||
|
RUN update-ca-certificates
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=backend-builder /app/novamd .
|
COPY --from=backend-builder /app/lemma .
|
||||||
COPY --from=frontend-builder /app/dist ./dist
|
COPY --from=frontend-builder /app/dist ./dist
|
||||||
|
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
# Set default environment variables
|
# Set default environment variables
|
||||||
ENV NOVAMD_STATIC_PATH=/app/dist
|
ENV LEMMA_STATIC_PATH=/app/dist
|
||||||
ENV NOVAMD_PORT=8080
|
ENV LEMMA_PORT=8080
|
||||||
ENV NOVAMD_WORKDIR=/app/data
|
ENV LEMMA_WORKDIR=/app/data
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
CMD ["./novamd"]
|
CMD ["./lemma"]
|
||||||
54
README.md
54
README.md
@@ -1,4 +1,6 @@
|
|||||||
# NovaMD
|
# Lemma
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
Yet another markdown editor. Work in progress
|
Yet another markdown editor. Work in progress
|
||||||
|
|
||||||
@@ -20,37 +22,33 @@ Yet another markdown editor. Work in progress
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
NovaMD can be configured using environment variables. Here are the available configuration options:
|
Lemma can be configured using environment variables. Here are the available configuration options:
|
||||||
|
|
||||||
### Required Environment Variables
|
### Required Environment Variables
|
||||||
|
|
||||||
- `NOVAMD_ADMIN_EMAIL`: Email address for the admin account
|
- `LEMMA_ADMIN_EMAIL`: Email address for the admin account
|
||||||
- `NOVAMD_ADMIN_PASSWORD`: Password for the admin account
|
- `LEMMA_ADMIN_PASSWORD`: Password for the admin account
|
||||||
- `NOVAMD_ENCRYPTION_KEY`: Base64-encoded 32-byte key used for encrypting sensitive data
|
|
||||||
|
|
||||||
### Optional Environment Variables
|
### Optional Environment Variables
|
||||||
|
|
||||||
- `NOVAMD_ENV`: Set to "development" to enable development mode
|
- `LEMMA_ENV`: Set to "development" to enable development mode
|
||||||
- `NOVAMD_DB_PATH`: Path to the SQLite database file (default: "./novamd.db")
|
- `LEMMA_DB_URL`: URL (Connection string) to the database. Supported databases are sqlite and postgres a (default: "./lemma.db")
|
||||||
- `NOVAMD_WORKDIR`: Working directory for application data (default: "./data")
|
- `LEMMA_WORKDIR`: Working directory for application data (default: "sqlite://lemma.db")
|
||||||
- `NOVAMD_STATIC_PATH`: Path to static files (default: "../app/dist")
|
- `LEMMA_STATIC_PATH`: Path to static files (default: "../app/dist")
|
||||||
- `NOVAMD_PORT`: Port to run the server on (default: "8080")
|
- `LEMMA_PORT`: Port to run the server on (default: "8080")
|
||||||
- `NOVAMD_APP_URL`: Full URL where the application is hosted
|
- `LEMMA_DOMAIN`: Domain name where the application is hosted for cookie authentication
|
||||||
- `NOVAMD_CORS_ORIGINS`: Comma-separated list of allowed CORS origins
|
- `LEMMA_CORS_ORIGINS`: Comma-separated list of allowed CORS origins
|
||||||
- `NOVAMD_JWT_SIGNING_KEY`: Key used for signing JWT tokens (autogenerated if not set)
|
- `LEMMA_ENCRYPTION_KEY`: Base64-encoded 32-byte key used for encrypting sensitive data. If not provided, a key will be automatically generated and stored in `{LEMMA_WORKDIR}/secrets/encryption_key`
|
||||||
- `NOVAMD_RATE_LIMIT_REQUESTS`: Number of allowed requests per window (default: 100)
|
- `LEMMA_JWT_SIGNING_KEY`: Key used for signing JWT tokens. If not provided, a key will be automatically generated and stored in `{LEMMA_WORKDIR}/secrets/jwt_signing_key`
|
||||||
- `NOVAMD_RATE_LIMIT_WINDOW`: Duration of the rate limit window (default: 15m)
|
- `LEMMA_LOG_LEVEL`: Logging level (defaults to DEBUG in development mode, INFO in production)
|
||||||
|
- `LEMMA_RATE_LIMIT_REQUESTS`: Number of allowed requests per window (default: 100)
|
||||||
|
- `LEMMA_RATE_LIMIT_WINDOW`: Duration of the rate limit window (default: 15m)
|
||||||
|
|
||||||
### Generating Encryption Keys
|
### Security Keys
|
||||||
|
|
||||||
The encryption key must be a base64-encoded 32-byte value. You can generate a secure encryption key using OpenSSL:
|
Both the encryption key and JWT signing key are automatically generated on first startup if not provided via environment variables. The keys are stored in `{LEMMA_WORKDIR}/secrets/` with restrictive file permissions (0600).
|
||||||
|
|
||||||
```bash
|
**Important**: Back up the `secrets` directory! If these keys are lost, encrypted data will become inaccessible and all users will need to re-authenticate.
|
||||||
# Generate a random 32-byte key and encode it as base64
|
|
||||||
openssl rand -base64 32
|
|
||||||
```
|
|
||||||
|
|
||||||
Store the generated key securely - it will be needed to decrypt any data encrypted by the application. If the key is lost or changed, previously encrypted data will become inaccessible.
|
|
||||||
|
|
||||||
## Running the backend server
|
## Running the backend server
|
||||||
|
|
||||||
@@ -86,10 +84,10 @@ Store the generated key securely - it will be needed to decrypt any data encrypt
|
|||||||
2. Build the backend:
|
2. Build the backend:
|
||||||
```
|
```
|
||||||
cd server
|
cd server
|
||||||
go build -o novamd ./cmd/server
|
go build -o lemma ./cmd/server
|
||||||
```
|
```
|
||||||
3. Set the `NOVAMD_STATIC_PATH` environment variable to point to the frontend build directory
|
3. Set the `LEMMA_STATIC_PATH` environment variable to point to the frontend build directory
|
||||||
4. Run the `novamd` executable
|
4. Run the `lemma` executable
|
||||||
|
|
||||||
## Docker Support
|
## Docker Support
|
||||||
|
|
||||||
@@ -97,11 +95,11 @@ A Dockerfile is provided for easy deployment. To build and run the Docker image:
|
|||||||
|
|
||||||
1. Build the image:
|
1. Build the image:
|
||||||
```
|
```
|
||||||
docker build -t novamd .
|
docker build -t lemma .
|
||||||
```
|
```
|
||||||
2. Run the container:
|
2. Run the container:
|
||||||
```
|
```
|
||||||
docker run -p 8080:8080 -v /path/to/data:/app/data novamd
|
docker run -p 8080:8080 -v /path/to/data:/app/data lemma
|
||||||
```
|
```
|
||||||
|
|
||||||
## Upgrading
|
## Upgrading
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"env": {
|
|
||||||
"browser": true,
|
|
||||||
"es2021": true,
|
|
||||||
"node": true
|
|
||||||
},
|
|
||||||
"extends": [
|
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:react/recommended",
|
|
||||||
"plugin:react-hooks/recommended"
|
|
||||||
],
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaFeatures": {
|
|
||||||
"jsx": true
|
|
||||||
},
|
|
||||||
"ecmaVersion": 12,
|
|
||||||
"sourceType": "module"
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
"react"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"react/prop-types": "off",
|
|
||||||
"no-unused-vars": "warn"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"react": {
|
|
||||||
"version": "detect"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
114
app/eslint.config.mjs
Normal file
114
app/eslint.config.mjs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||||
|
import { fixupConfigRules, fixupPluginRules } from '@eslint/compat';
|
||||||
|
import react from 'eslint-plugin-react';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||||
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import { FlatCompat } from '@eslint/eslintrc';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
recommendedConfig: js.configs.recommended,
|
||||||
|
allConfig: js.configs.all,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores([
|
||||||
|
'**/node_modules',
|
||||||
|
'**/dist',
|
||||||
|
'**/build',
|
||||||
|
'**/coverage',
|
||||||
|
'**/public',
|
||||||
|
'**/*.js',
|
||||||
|
'**/vite.config.ts',
|
||||||
|
'**/eslint.config.mjs',
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
extends: fixupConfigRules(
|
||||||
|
compat.extends(
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:react/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended-requiring-type-checking'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
plugins: {
|
||||||
|
react: fixupPluginRules(react),
|
||||||
|
'react-hooks': fixupPluginRules(reactHooks),
|
||||||
|
'@typescript-eslint': fixupPluginRules(typescriptEslint),
|
||||||
|
},
|
||||||
|
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'module',
|
||||||
|
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
project: './tsconfig.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
rules: {
|
||||||
|
'no-console': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
allow: ['warn', 'error', 'debug'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'no-duplicate-imports': 'error',
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'@typescript-eslint/consistent-type-imports': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
prefer: 'type-imports',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'@typescript-eslint/no-misused-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/unbound-method': 'off',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Override configuration for test files
|
||||||
|
{
|
||||||
|
files: ['**/*.test.ts', '**/*.test.tsx'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
6086
app/package-lock.json
generated
6086
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "novamd-frontend",
|
"name": "lemma-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Yet another markdown editor",
|
"description": "Yet another markdown editor",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"build": "vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"lint": "eslint . --ext .ts,.tsx",
|
||||||
|
"lint:fix": "eslint . --ext .ts,.tsx --fix",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/LordMathis/NovaMD.git"
|
"url": "git+https://github.com/LordMathis/Lemma.git"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"markdown",
|
"markdown",
|
||||||
@@ -19,9 +25,9 @@
|
|||||||
"author": "Matúš Námešný",
|
"author": "Matúš Námešný",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/LordMathis/NovaMD/issues"
|
"url": "https://github.com/LordMathis/Lemma/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/LordMathis/NovaMD#readme",
|
"homepage": "https://github.com/LordMathis/Lemma#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.6.2",
|
"@codemirror/commands": "^6.6.2",
|
||||||
"@codemirror/lang-markdown": "^6.2.5",
|
"@codemirror/lang-markdown": "^6.2.5",
|
||||||
@@ -38,9 +44,8 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "^3.4.0",
|
"react-arborist": "^3.4.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-syntax-highlighter": "^15.5.0",
|
"rehype-highlight": "^7.0.2",
|
||||||
"rehype-mathjax": "^6.0.0",
|
"rehype-mathjax": "^6.0.0",
|
||||||
"rehype-prism": "^2.3.3",
|
|
||||||
"rehype-react": "^8.0.0",
|
"rehype-react": "^8.0.0",
|
||||||
"remark": "^15.0.1",
|
"remark": "^15.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
@@ -50,15 +55,29 @@
|
|||||||
"unist-util-visit": "^5.0.0"
|
"unist-util-visit": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.67",
|
"@eslint/compat": "^1.2.9",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/babel__core": "^7.20.5",
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/react": "^18.3.20",
|
||||||
|
"@types/react-dom": "^18.3.6",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.32.1",
|
||||||
|
"@typescript-eslint/parser": "^8.32.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "^3.1.4",
|
||||||
|
"eslint": "^9.27.0",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"sass": "^1.80.4",
|
"sass": "^1.80.4",
|
||||||
"vite": "^5.4.10",
|
"typescript": "^5.8.2",
|
||||||
"vite-plugin-compression2": "^1.3.0"
|
"vite": "^6.4.1",
|
||||||
|
"vite-plugin-compression2": "^1.3.0",
|
||||||
|
"vitest": "^3.1.4"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MantineProvider, ColorSchemeScript } from '@mantine/core';
|
import {
|
||||||
|
MantineProvider,
|
||||||
|
ColorSchemeScript,
|
||||||
|
localStorageColorSchemeManager,
|
||||||
|
} from '@mantine/core';
|
||||||
import { Notifications } from '@mantine/notifications';
|
import { Notifications } from '@mantine/notifications';
|
||||||
import { ModalsProvider } from '@mantine/modals';
|
import { ModalsProvider } from '@mantine/modals';
|
||||||
import Layout from './components/layout/Layout';
|
import Layout from './components/layout/Layout';
|
||||||
@@ -11,7 +15,9 @@ import '@mantine/core/styles.css';
|
|||||||
import '@mantine/notifications/styles.css';
|
import '@mantine/notifications/styles.css';
|
||||||
import './App.scss';
|
import './App.scss';
|
||||||
|
|
||||||
function AuthenticatedContent() {
|
type AuthenticatedContentProps = object;
|
||||||
|
|
||||||
|
const AuthenticatedContent: React.FC<AuthenticatedContentProps> = () => {
|
||||||
const { user, loading, initialized } = useAuth();
|
const { user, loading, initialized } = useAuth();
|
||||||
|
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
@@ -33,13 +39,22 @@ function AuthenticatedContent() {
|
|||||||
</ModalProvider>
|
</ModalProvider>
|
||||||
</WorkspaceProvider>
|
</WorkspaceProvider>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function App() {
|
type AppProps = object;
|
||||||
|
|
||||||
|
const colorSchemeManager = localStorageColorSchemeManager({
|
||||||
|
key: 'mantine-color-scheme',
|
||||||
|
});
|
||||||
|
|
||||||
|
const App: React.FC<AppProps> = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ColorSchemeScript defaultColorScheme="light" />
|
<ColorSchemeScript defaultColorScheme="light" />
|
||||||
<MantineProvider defaultColorScheme="light">
|
<MantineProvider
|
||||||
|
defaultColorScheme="light"
|
||||||
|
colorSchemeManager={colorSchemeManager}
|
||||||
|
>
|
||||||
<Notifications />
|
<Notifications />
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
@@ -49,6 +64,6 @@ function App() {
|
|||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
135
app/src/api/admin.ts
Normal file
135
app/src/api/admin.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import {
|
||||||
|
API_BASE_URL,
|
||||||
|
type CreateUserRequest,
|
||||||
|
type UpdateUserRequest,
|
||||||
|
} from '@/types/api';
|
||||||
|
import { apiCall } from './api';
|
||||||
|
import {
|
||||||
|
isSystemStats,
|
||||||
|
isUser,
|
||||||
|
isWorkspaceStats,
|
||||||
|
type SystemStats,
|
||||||
|
type User,
|
||||||
|
type WorkspaceStats,
|
||||||
|
} from '@/types/models';
|
||||||
|
|
||||||
|
const ADMIN_BASE_URL = `${API_BASE_URL}/admin`;
|
||||||
|
|
||||||
|
// User Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all users from the API
|
||||||
|
* @returns {Promise<User[]>} A promise that resolves to an array of users
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
* */
|
||||||
|
export const getUsers = async (): Promise<User[]> => {
|
||||||
|
const response = await apiCall(`${ADMIN_BASE_URL}/users`);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error('Invalid users response received from API');
|
||||||
|
}
|
||||||
|
return data.map((user) => {
|
||||||
|
if (!isUser(user)) {
|
||||||
|
throw new Error('Invalid user object received from API');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new user in the system
|
||||||
|
* @param {CreateUserRequest} userData The data for the new user
|
||||||
|
* @returns {Promise<User>} A promise that resolves to the created user
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
* */
|
||||||
|
export const createUser = async (
|
||||||
|
userData: CreateUserRequest
|
||||||
|
): Promise<User> => {
|
||||||
|
const response = await apiCall(`${ADMIN_BASE_URL}/users`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(userData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (!isUser(data)) {
|
||||||
|
throw new Error('Invalid user object received from API');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a user from the system
|
||||||
|
* @param {number} userId The ID of the user to delete
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
* */
|
||||||
|
export const deleteUser = async (userId: number) => {
|
||||||
|
const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (response.status === 204) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to delete user with status: ' + response.status);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing user in the system
|
||||||
|
* @param {number} userId The ID of the user to update
|
||||||
|
* @param {UpdateUserRequest} userData The data to update the user with
|
||||||
|
* @returns {Promise<User>} A promise that resolves to the updated user
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
* */
|
||||||
|
export const updateUser = async (
|
||||||
|
userId: number,
|
||||||
|
userData: UpdateUserRequest
|
||||||
|
): Promise<User> => {
|
||||||
|
const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(userData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (!isUser(data)) {
|
||||||
|
throw new Error('Invalid user object received from API');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Workspace Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all workspaces from the API
|
||||||
|
* @returns {Promise<WorkspaceStats[]>} A promise that resolves to an array of workspaces
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
* */
|
||||||
|
export const getWorkspaces = async (): Promise<WorkspaceStats[]> => {
|
||||||
|
const response = await apiCall(`${ADMIN_BASE_URL}/workspaces`);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error('Invalid workspaces response received from API');
|
||||||
|
}
|
||||||
|
return data.map((workspace) => {
|
||||||
|
if (!isWorkspaceStats(workspace)) {
|
||||||
|
throw new Error('Invalid workspace stats object received from API');
|
||||||
|
}
|
||||||
|
return workspace;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// System Statistics
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches system-wide statistics from the API
|
||||||
|
* @returns {Promise<SystemStats>} A promise that resolves to the system statistics
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
* */
|
||||||
|
export const getSystemStats = async (): Promise<SystemStats> => {
|
||||||
|
const response = await apiCall(`${ADMIN_BASE_URL}/stats`);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (!isSystemStats(data)) {
|
||||||
|
throw new Error('Invalid system stats response received from API');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
469
app/src/api/api.test.ts
Normal file
469
app/src/api/api.test.ts
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { apiCall } from './api';
|
||||||
|
|
||||||
|
// Mock the auth module - move this before any constants
|
||||||
|
vi.mock('./auth', () => {
|
||||||
|
return {
|
||||||
|
refreshToken: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the mocked function after vi.mock
|
||||||
|
const mockRefreshToken = vi.mocked(await import('./auth')).refreshToken;
|
||||||
|
|
||||||
|
// Mock fetch globally
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
// Helper to create mock Response objects
|
||||||
|
const createMockResponse = (
|
||||||
|
status: number,
|
||||||
|
body: unknown = {},
|
||||||
|
ok?: boolean
|
||||||
|
): Response => {
|
||||||
|
const response = {
|
||||||
|
status,
|
||||||
|
ok: ok !== undefined ? ok : status >= 200 && status < 300,
|
||||||
|
json: vi.fn().mockResolvedValue(body),
|
||||||
|
text: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
typeof body === 'string' ? body : JSON.stringify(body)
|
||||||
|
),
|
||||||
|
} as unknown as Response;
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to set document.cookie
|
||||||
|
const setCookie = (name: string, value: string) => {
|
||||||
|
Object.defineProperty(document, 'cookie', {
|
||||||
|
writable: true,
|
||||||
|
value: `${name}=${encodeURIComponent(value)}`,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('apiCall', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Clear cookies
|
||||||
|
Object.defineProperty(document, 'cookie', {
|
||||||
|
writable: true,
|
||||||
|
value: '',
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('basic functionality', () => {
|
||||||
|
it('makes a successful GET request', async () => {
|
||||||
|
const mockResponseData = { success: true };
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(200, mockResponseData));
|
||||||
|
|
||||||
|
const result = await apiCall('https://api.example.com/test');
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes a successful POST request with body', async () => {
|
||||||
|
const requestBody = { name: 'test' };
|
||||||
|
const mockResponseData = { id: 1, name: 'test' };
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(201, mockResponseData));
|
||||||
|
|
||||||
|
const result = await apiCall('https://api.example.com/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.status).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles 204 No Content responses', async () => {
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(204, null, true));
|
||||||
|
|
||||||
|
const result = await apiCall('https://api.example.com/delete', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves custom headers', async () => {
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||||
|
|
||||||
|
await apiCall('https://api.example.com/test', {
|
||||||
|
headers: {
|
||||||
|
'Custom-Header': 'custom-value',
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain', // Custom content type should override
|
||||||
|
'Custom-Header': 'custom-value',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CSRF token handling', () => {
|
||||||
|
it('adds CSRF token to non-GET requests when token exists', async () => {
|
||||||
|
setCookie('csrf_token', 'test-csrf-token');
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||||
|
|
||||||
|
await apiCall('https://api.example.com/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ test: 'data' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ test: 'data' }),
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': 'test-csrf-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits CSRF token with GET methods', async () => {
|
||||||
|
setCookie('csrf_token', 'test-token');
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||||
|
|
||||||
|
await apiCall('https://api.example.com/test', { method: 'GET' });
|
||||||
|
|
||||||
|
// Check that CSRF token is not included in headers
|
||||||
|
const calledOptions = mockFetch.mock.calls?.[0]?.[1] as RequestInit;
|
||||||
|
expect(calledOptions['headers']).not.toHaveProperty('X-CSRF-Token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing CSRF token gracefully', async () => {
|
||||||
|
// No CSRF token in cookies
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||||
|
|
||||||
|
await apiCall('https://api.example.com/create', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
// No X-CSRF-Token header
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('throws error for non-2xx status codes', async () => {
|
||||||
|
const errorResponse = { message: 'Bad Request' };
|
||||||
|
mockFetch.mockResolvedValue(
|
||||||
|
createMockResponse(400, errorResponse, false)
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(apiCall('https://api.example.com/error')).rejects.toThrow(
|
||||||
|
'Bad Request'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws generic error when no error message in response', async () => {
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(500, {}, false));
|
||||||
|
|
||||||
|
await expect(apiCall('https://api.example.com/error')).rejects.toThrow(
|
||||||
|
'HTTP error! status: 500'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles malformed JSON error responses', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
status: 400,
|
||||||
|
ok: false,
|
||||||
|
json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),
|
||||||
|
} as unknown as Response;
|
||||||
|
mockFetch.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
await expect(apiCall('https://api.example.com/error')).rejects.toThrow(
|
||||||
|
'Invalid JSON'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles network errors', async () => {
|
||||||
|
const networkError = new Error('Network error');
|
||||||
|
mockFetch.mockRejectedValue(networkError);
|
||||||
|
|
||||||
|
await expect(apiCall('https://api.example.com/error')).rejects.toThrow(
|
||||||
|
'Network error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles timeout errors', async () => {
|
||||||
|
const timeoutError = new Error('Request timeout');
|
||||||
|
mockFetch.mockRejectedValue(timeoutError);
|
||||||
|
|
||||||
|
await expect(apiCall('https://api.example.com/slow')).rejects.toThrow(
|
||||||
|
'Request timeout'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('authentication and token refresh', () => {
|
||||||
|
it('handles 401 response by attempting token refresh and retrying', async () => {
|
||||||
|
const successResponse = createMockResponse(200, { data: 'success' });
|
||||||
|
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(createMockResponse(401, {}, false)) // First call fails with 401
|
||||||
|
.mockResolvedValueOnce(successResponse); // Retry succeeds
|
||||||
|
|
||||||
|
mockRefreshToken.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await apiCall('https://api.example.com/protected');
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockRefreshToken).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error when token refresh fails', async () => {
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(401, {}, false));
|
||||||
|
mockRefreshToken.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
apiCall('https://api.example.com/protected')
|
||||||
|
).rejects.toThrow('Authentication failed');
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRefreshToken).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not attempt refresh for auth/refresh endpoint', async () => {
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(401, {}, false));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
apiCall('https://api.example.com/auth/refresh')
|
||||||
|
).rejects.toThrow('Authentication failed');
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRefreshToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles successful token refresh but failed retry', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(createMockResponse(401, {}, false)) // First call fails
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
createMockResponse(403, { message: 'Forbidden' }, false)
|
||||||
|
); // Retry fails with different error
|
||||||
|
|
||||||
|
mockRefreshToken.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
apiCall('https://api.example.com/protected')
|
||||||
|
).rejects.toThrow('Forbidden');
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockRefreshToken).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles token refresh throwing an error', async () => {
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(401, {}, false));
|
||||||
|
mockRefreshToken.mockRejectedValue(new Error('Refresh failed'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
apiCall('https://api.example.com/protected')
|
||||||
|
).rejects.toThrow('Refresh failed'); // The test should match the actual error from the mock
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRefreshToken).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves original request options in retry', async () => {
|
||||||
|
const requestBody = { data: 'test' };
|
||||||
|
const customHeaders = { 'Custom-Header': 'value' };
|
||||||
|
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(createMockResponse(401, {}, false))
|
||||||
|
.mockResolvedValueOnce(createMockResponse(200, { success: true }));
|
||||||
|
|
||||||
|
mockRefreshToken.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await apiCall('https://api.example.com/protected', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
headers: customHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that both calls had the same options
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'https://api.example.com/protected',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Custom-Header': 'value',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'https://api.example.com/protected',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Custom-Header': 'value',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('console logging', () => {
|
||||||
|
it('logs debug information for requests and responses', async () => {
|
||||||
|
const consoleSpy = vi
|
||||||
|
.spyOn(console, 'debug')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||||
|
|
||||||
|
await apiCall('https://api.example.com/test');
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
'Making API call to: https://api.example.com/test'
|
||||||
|
);
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
'Response status: 200 for URL: https://api.example.com/test'
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs errors when API calls fail', async () => {
|
||||||
|
const consoleSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
const networkError = new Error('Network failure');
|
||||||
|
mockFetch.mockRejectedValue(networkError);
|
||||||
|
|
||||||
|
await expect(apiCall('https://api.example.com/error')).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
'API call failed: Network failure'
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('request options handling', () => {
|
||||||
|
it('merges provided options with defaults', async () => {
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||||
|
|
||||||
|
await apiCall('https://api.example.com/test', {
|
||||||
|
method: 'PUT',
|
||||||
|
cache: 'no-cache' as RequestCache,
|
||||||
|
redirect: 'follow' as RequestRedirect,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
|
||||||
|
method: 'PUT',
|
||||||
|
cache: 'no-cache',
|
||||||
|
redirect: 'follow',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles undefined options parameter', async () => {
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||||
|
|
||||||
|
await apiCall('https://api.example.com/test');
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty options object', async () => {
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||||
|
|
||||||
|
await apiCall('https://api.example.com/test', {});
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HTTP methods', () => {
|
||||||
|
it('handles different HTTP methods correctly', async () => {
|
||||||
|
setCookie('csrf_token', 'test-token');
|
||||||
|
mockFetch.mockResolvedValue(createMockResponse(200, {}));
|
||||||
|
|
||||||
|
const methods = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
||||||
|
|
||||||
|
for (const method of methods) {
|
||||||
|
mockFetch.mockClear();
|
||||||
|
|
||||||
|
await apiCall('https://api.example.com/test', { method });
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
|
||||||
|
method,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': 'test-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('handles null response body', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue(null),
|
||||||
|
} as unknown as Response;
|
||||||
|
mockFetch.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await apiCall('https://api.example.com/test');
|
||||||
|
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
91
app/src/api/api.ts
Normal file
91
app/src/api/api.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { refreshToken } from './auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the CSRF token from cookies
|
||||||
|
* @returns {string} The CSRF token or an empty string if not found
|
||||||
|
*/
|
||||||
|
const getCsrfToken = (): string => {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
let csrfToken = '';
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
const [name, value] = cookie.trim().split('=');
|
||||||
|
if (name === 'csrf_token' && value) {
|
||||||
|
csrfToken = decodeURIComponent(value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return csrfToken;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes an API call with proper cookie handling and error handling
|
||||||
|
*/
|
||||||
|
export const apiCall = async (
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<Response> => {
|
||||||
|
console.debug(`Making API call to: ${url}`);
|
||||||
|
try {
|
||||||
|
// Set up headers with CSRF token for non-GET requests
|
||||||
|
const method = options.method || 'GET';
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only set Content-Type to application/json if not already set and body is not FormData
|
||||||
|
// FormData requires the browser to set Content-Type with the boundary parameter
|
||||||
|
const isFormData = options.body instanceof FormData;
|
||||||
|
if (!headers['Content-Type'] && !isFormData) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add CSRF token for non-GET methods
|
||||||
|
if (method !== 'GET') {
|
||||||
|
const csrfToken = getCsrfToken();
|
||||||
|
if (csrfToken) {
|
||||||
|
headers['X-CSRF-Token'] = csrfToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For FormData, don't include Content-Type in headers - let the browser set it
|
||||||
|
const fetchHeaders = isFormData
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(headers).filter(([key]) => key !== 'Content-Type')
|
||||||
|
)
|
||||||
|
: headers;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
// Include credentials to send/receive cookies
|
||||||
|
credentials: 'include',
|
||||||
|
headers: fetchHeaders,
|
||||||
|
});
|
||||||
|
console.debug(`Response status: ${response.status} for URL: ${url}`);
|
||||||
|
|
||||||
|
// Handle 401 responses
|
||||||
|
if (response.status === 401) {
|
||||||
|
const isRefreshEndpoint = url.endsWith('/auth/refresh');
|
||||||
|
if (!isRefreshEndpoint) {
|
||||||
|
// Attempt token refresh and retry the request
|
||||||
|
const refreshSuccess = await refreshToken();
|
||||||
|
if (refreshSuccess) {
|
||||||
|
// Retry the original request
|
||||||
|
return apiCall(url, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Authentication failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 204) {
|
||||||
|
const errorData = (await response.json()) as { message: string };
|
||||||
|
throw new Error(
|
||||||
|
errorData?.message || `HTTP error! status: ${response.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`API call failed: ${(error as Error).message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
75
app/src/api/auth.ts
Normal file
75
app/src/api/auth.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { API_BASE_URL, isLoginResponse, type LoginRequest } from '@/types/api';
|
||||||
|
import { apiCall } from './api';
|
||||||
|
import { isUser, type User } from '@/types/models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs in a user with email and password
|
||||||
|
* @param {string} email - The user's email
|
||||||
|
* @param {string} password - The user's password
|
||||||
|
* @returns {Promise<User>} A promise that resolves to the user
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
* @throws {Error} If the login fails
|
||||||
|
*/
|
||||||
|
export const login = async (email: string, password: string): Promise<User> => {
|
||||||
|
const loginData: LoginRequest = { email, password };
|
||||||
|
const response = await apiCall(`${API_BASE_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(loginData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (!isLoginResponse(data)) {
|
||||||
|
throw new Error('Invalid login response from API');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.user;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs out the current user
|
||||||
|
* @returns {Promise<void>} A promise that resolves when the logout is successful
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
* @throws {Error} If the logout fails
|
||||||
|
*/
|
||||||
|
export const logout = async (): Promise<void> => {
|
||||||
|
const response = await apiCall(`${API_BASE_URL}/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 204) {
|
||||||
|
throw new Error('Failed to log out');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the auth token
|
||||||
|
* @returns true if refresh was successful, false otherwise
|
||||||
|
*/
|
||||||
|
export const refreshToken = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await apiCall(`${API_BASE_URL}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the currently authenticated user
|
||||||
|
* @returns {Promise<User>} A promise that resolves to the current user
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
* @throws {Error} If the user data is invalid
|
||||||
|
*/
|
||||||
|
export const getCurrentUser = async (): Promise<User> => {
|
||||||
|
const response = await apiCall(`${API_BASE_URL}/auth/me`);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
|
||||||
|
if (!isUser(data)) {
|
||||||
|
throw new Error('Invalid user data received from API');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
237
app/src/api/file.ts
Normal file
237
app/src/api/file.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { isFileNode, type FileNode } from '@/types/models';
|
||||||
|
import { apiCall } from './api';
|
||||||
|
import {
|
||||||
|
API_BASE_URL,
|
||||||
|
isLookupResponse,
|
||||||
|
isSaveFileResponse,
|
||||||
|
isUploadFilesResponse,
|
||||||
|
type SaveFileResponse,
|
||||||
|
type UploadFilesResponse,
|
||||||
|
} from '@/types/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* listFiles fetches the list of files in a workspace
|
||||||
|
* @param workspaceName - The name of the workspace
|
||||||
|
* @returns {Promise<FileNode[]>} A promise that resolves to an array of FileNode objects
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const listFiles = async (workspaceName: string): Promise<FileNode[]> => {
|
||||||
|
const response = await apiCall(
|
||||||
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}/files`
|
||||||
|
);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error('Invalid files response received from API');
|
||||||
|
}
|
||||||
|
return data.map((file) => {
|
||||||
|
if (!isFileNode(file)) {
|
||||||
|
throw new Error('Invalid file object received from API');
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lookupFileByName fetches the file paths that match the given filename in a workspace
|
||||||
|
* @param workspaceName - The name of the workspace
|
||||||
|
* @param filename - The name of the file to look up
|
||||||
|
* @returns {Promise<string[]>} A promise that resolves to an array of file paths
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const lookupFileByName = async (
|
||||||
|
workspaceName: string,
|
||||||
|
filename: string
|
||||||
|
): Promise<string[]> => {
|
||||||
|
if (!filename || typeof filename !== 'string') {
|
||||||
|
throw new Error('Invalid filename provided for lookup');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiCall(
|
||||||
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
||||||
|
workspaceName
|
||||||
|
)}/files/lookup?filename=${encodeURIComponent(filename)}`
|
||||||
|
);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (!isLookupResponse(data)) {
|
||||||
|
throw new Error('Invalid lookup response received from API');
|
||||||
|
}
|
||||||
|
const lookupResponse = data;
|
||||||
|
return lookupResponse.paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getFileContent fetches the content of a file in a workspace
|
||||||
|
* @param workspaceName - The name of the workspace
|
||||||
|
* @param filePath - The path of the file to fetch
|
||||||
|
* @returns {Promise<string>} A promise that resolves to the file content
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const getFileContent = async (
|
||||||
|
workspaceName: string,
|
||||||
|
filePath: string
|
||||||
|
): Promise<string> => {
|
||||||
|
const response = await apiCall(
|
||||||
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
||||||
|
workspaceName
|
||||||
|
)}/files/content?file_path=${encodeURIComponent(filePath)}`
|
||||||
|
);
|
||||||
|
return response.text();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* saveFile saves the content to a file in a workspace
|
||||||
|
* @param workspaceName - The name of the workspace
|
||||||
|
* @param filePath - The path of the file to save
|
||||||
|
* @param content - The content to save in the file
|
||||||
|
* @returns {Promise<SaveFileResponse>} A promise that resolves to the save file response
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const saveFile = async (
|
||||||
|
workspaceName: string,
|
||||||
|
filePath: string,
|
||||||
|
content: string
|
||||||
|
): Promise<SaveFileResponse> => {
|
||||||
|
const response = await apiCall(
|
||||||
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
||||||
|
workspaceName
|
||||||
|
)}/files?file_path=${encodeURIComponent(filePath)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
},
|
||||||
|
body: content,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (!isSaveFileResponse(data)) {
|
||||||
|
throw new Error('Invalid save file response received from API');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* deleteFile deletes a file in a workspace
|
||||||
|
* @param workspaceName - The name of the workspace
|
||||||
|
* @param filePath - The path of the file to delete
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const deleteFile = async (workspaceName: string, filePath: string) => {
|
||||||
|
await apiCall(
|
||||||
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
||||||
|
workspaceName
|
||||||
|
)}/files?file_path=${encodeURIComponent(filePath)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getLastOpenedFile fetches the last opened file in a workspace
|
||||||
|
* @param workspaceName - The name of the workspace
|
||||||
|
* @returns {Promise<string>} A promise that resolves to the last opened file path
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const getLastOpenedFile = async (
|
||||||
|
workspaceName: string
|
||||||
|
): Promise<string> => {
|
||||||
|
const response = await apiCall(
|
||||||
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}/files/last`
|
||||||
|
);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (
|
||||||
|
typeof data !== 'object' ||
|
||||||
|
data === null ||
|
||||||
|
!('lastOpenedFilePath' in data)
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid last opened file response received from API');
|
||||||
|
}
|
||||||
|
return data.lastOpenedFilePath as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* updateLastOpenedFile updates the last opened file in a workspace
|
||||||
|
* @param workspaceName - The name of the workspace
|
||||||
|
* @param filePath - The path of the file to set as last opened
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const updateLastOpenedFile = async (
|
||||||
|
workspaceName: string,
|
||||||
|
filePath: string
|
||||||
|
) => {
|
||||||
|
await apiCall(
|
||||||
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
||||||
|
workspaceName
|
||||||
|
)}/files/last?file_path=${encodeURIComponent(filePath)}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* moveFile moves a file to a new location in a workspace
|
||||||
|
* @param workspaceName - The name of the workspace
|
||||||
|
* @param srcPath - The source path of the file to move
|
||||||
|
* @param destPath - The destination path for the file
|
||||||
|
* @returns {Promise<SaveFileResponse>} A promise that resolves to the move file response
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const moveFile = async (
|
||||||
|
workspaceName: string,
|
||||||
|
srcPath: string,
|
||||||
|
destPath: string
|
||||||
|
): Promise<SaveFileResponse> => {
|
||||||
|
const response = await apiCall(
|
||||||
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
||||||
|
workspaceName
|
||||||
|
)}/files/move?src_path=${encodeURIComponent(
|
||||||
|
srcPath
|
||||||
|
)}&dest_path=${encodeURIComponent(destPath)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (!isSaveFileResponse(data)) {
|
||||||
|
throw new Error('Invalid move file response received from API');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* uploadFile uploads multiple files to a workspace
|
||||||
|
* @param workspaceName - The name of the workspace
|
||||||
|
* @param directoryPath - The directory path where files should be uploaded
|
||||||
|
* @param files - Multiple files to upload
|
||||||
|
* @returns {Promise<UploadFilesResponse>} A promise that resolves to the upload file response
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const uploadFile = async (
|
||||||
|
workspaceName: string,
|
||||||
|
directoryPath: string,
|
||||||
|
files: FileList
|
||||||
|
): Promise<UploadFilesResponse> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Add all files to the form data
|
||||||
|
Array.from(files).forEach((file) => {
|
||||||
|
formData.append('files', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await apiCall(
|
||||||
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
||||||
|
workspaceName
|
||||||
|
)}/files/upload?file_path=${encodeURIComponent(directoryPath)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (!isUploadFilesResponse(data)) {
|
||||||
|
throw new Error('Invalid upload file response received from API');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
50
app/src/api/git.ts
Normal file
50
app/src/api/git.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { API_BASE_URL } from '@/types/api';
|
||||||
|
import { apiCall } from './api';
|
||||||
|
import type { CommitHash } from '@/types/models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pullChanges fetches the latest changes from the remote repository
|
||||||
|
* @param workspaceName - The name of the workspace
|
||||||
|
* @returns {Promise<string>} A promise that resolves to a message indicating the result of the pull operation
|
||||||
|
*/
|
||||||
|
export const pullChanges = async (workspaceName: string): Promise<string> => {
|
||||||
|
const response = await apiCall(
|
||||||
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}/git/pull`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (typeof data !== 'object' || data === null || !('message' in data)) {
|
||||||
|
throw new Error('Invalid pull response received from API');
|
||||||
|
}
|
||||||
|
return data.message as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pushChanges pushes the local changes to the remote repository
|
||||||
|
* @param workspaceName - The name of the workspace
|
||||||
|
* @returns {Promise<CommitHash>} A promise that resolves to the commit hash of the pushed changes
|
||||||
|
*/
|
||||||
|
export const commitAndPush = async (
|
||||||
|
workspaceName: string,
|
||||||
|
message: string
|
||||||
|
): Promise<CommitHash> => {
|
||||||
|
const response = await apiCall(
|
||||||
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(
|
||||||
|
workspaceName
|
||||||
|
)}/git/commit`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (typeof data !== 'object' || data === null || !('commitHash' in data)) {
|
||||||
|
throw new Error('Invalid commit response received from API');
|
||||||
|
}
|
||||||
|
return data.commitHash as CommitHash;
|
||||||
|
};
|
||||||
41
app/src/api/user.ts
Normal file
41
app/src/api/user.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { API_BASE_URL, type UpdateProfileRequest } from '@/types/api';
|
||||||
|
import { isUser, type User } from '@/types/models';
|
||||||
|
import { apiCall } from './api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* updateProfile updates the user's profile information.
|
||||||
|
* @param updateRequest - The request object containing the updated profile information.
|
||||||
|
* @returns A promise that resolves to the updated user object.
|
||||||
|
* @throws An error if the response is not valid user data.
|
||||||
|
*/
|
||||||
|
export const updateProfile = async (
|
||||||
|
updateRequest: UpdateProfileRequest
|
||||||
|
): Promise<User> => {
|
||||||
|
const response = await apiCall(`${API_BASE_URL}/profile`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(updateRequest),
|
||||||
|
});
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
|
||||||
|
if (!isUser(data)) {
|
||||||
|
throw new Error('Invalid user data');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* deleteProfile deletes the user's profile.
|
||||||
|
* @param password - The password of the user.
|
||||||
|
* @throws An error if the response status is not 204 (No Content).
|
||||||
|
*/
|
||||||
|
export const deleteUser = async (password: string) => {
|
||||||
|
const response = await apiCall(`${API_BASE_URL}/profile`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 204) {
|
||||||
|
throw new Error('Failed to delete profile');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
153
app/src/api/workspace.ts
Normal file
153
app/src/api/workspace.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { type Workspace, isWorkspace } from '@/types/models';
|
||||||
|
import { apiCall } from './api';
|
||||||
|
import { API_BASE_URL } from '@/types/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* listWorkspaces fetches the list of workspaces
|
||||||
|
* @returns {Promise<Workspace[]>} A promise that resolves to an array of Workspace objects
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const listWorkspaces = async (): Promise<Workspace[]> => {
|
||||||
|
const response = await apiCall(`${API_BASE_URL}/workspaces`);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error('Invalid workspaces response received from API');
|
||||||
|
}
|
||||||
|
return data.map((workspace) => {
|
||||||
|
if (!isWorkspace(workspace)) {
|
||||||
|
throw new Error('Invalid workspace object received from API');
|
||||||
|
}
|
||||||
|
return workspace;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createWorkspace creates a new workspace with the given name
|
||||||
|
* @param name - The name of the workspace to create
|
||||||
|
* @returns {Promise<Workspace>} A promise that resolves to the created Workspace object
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const createWorkspace = async (name: string): Promise<Workspace> => {
|
||||||
|
const response = await apiCall(`${API_BASE_URL}/workspaces`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (!isWorkspace(data)) {
|
||||||
|
throw new Error('Invalid workspace object received from API');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getWorkspace fetches the workspace with the given name
|
||||||
|
* @param workspaceName - The name of the workspace to fetch
|
||||||
|
* @returns {Promise<Workspace>} A promise that resolves to the Workspace object
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const getWorkspace = async (
|
||||||
|
workspaceName: string
|
||||||
|
): Promise<Workspace> => {
|
||||||
|
const response = await apiCall(
|
||||||
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}`
|
||||||
|
);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (!isWorkspace(data)) {
|
||||||
|
throw new Error('Invalid workspace object received from API');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* updateWorkspace updates the workspace with the given name
|
||||||
|
* @param workspaceName - The name of the workspace to update
|
||||||
|
* @param workspaceData - The updated Workspace object
|
||||||
|
* @returns {Promise<Workspace>} A promise that resolves to the updated Workspace object
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const updateWorkspace = async (
|
||||||
|
workspaceName: string,
|
||||||
|
workspaceData: Workspace
|
||||||
|
): Promise<Workspace> => {
|
||||||
|
const response = await apiCall(
|
||||||
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(workspaceData),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (!isWorkspace(data)) {
|
||||||
|
throw new Error('Invalid workspace object received from API');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* deleteWorkspace deletes the workspace with the given name
|
||||||
|
* @param workspaceName - The name of the workspace to delete
|
||||||
|
* @returns {Promise<string>} A promise that resolves to the next workspace name to switch to
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const deleteWorkspace = async (
|
||||||
|
workspaceName: string
|
||||||
|
): Promise<string> => {
|
||||||
|
const response = await apiCall(
|
||||||
|
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (
|
||||||
|
typeof data !== 'object' ||
|
||||||
|
data === null ||
|
||||||
|
!('nextWorkspaceName' in data)
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid delete workspace response received from API');
|
||||||
|
}
|
||||||
|
return data.nextWorkspaceName as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getLastWorkspaceName fetches the last workspace name
|
||||||
|
* @returns {Promise<string>} A promise that resolves to the last workspace name
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const getLastWorkspaceName = async (): Promise<string> => {
|
||||||
|
const response = await apiCall(`${API_BASE_URL}/workspaces/_op/last`);
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
if (
|
||||||
|
typeof data !== 'object' ||
|
||||||
|
data === null ||
|
||||||
|
!('lastWorkspaceName' in data)
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid last workspace name response received from API');
|
||||||
|
}
|
||||||
|
return data.lastWorkspaceName as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* updateLastWorkspaceName updates the last workspace name
|
||||||
|
* @param workspaceName - The name of the workspace to set as last
|
||||||
|
* @throws {Error} If the API call fails or returns an invalid response
|
||||||
|
*/
|
||||||
|
export const updateLastWorkspaceName = async (workspaceName: string) => {
|
||||||
|
const response = await apiCall(`${API_BASE_URL}/workspaces/_op/last`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ workspaceName }),
|
||||||
|
});
|
||||||
|
if (response.status !== 204) {
|
||||||
|
throw new Error('Failed to update last workspace name');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
256
app/src/components/auth/LoginPage.test.tsx
Normal file
256
app/src/components/auth/LoginPage.test.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import { AuthProvider } from '@/contexts/AuthContext';
|
||||||
|
import LoginPage from './LoginPage';
|
||||||
|
|
||||||
|
// Mock the auth API functions
|
||||||
|
const mockApiLogin = vi.fn();
|
||||||
|
const mockApiLogout = vi.fn();
|
||||||
|
const mockApiRefreshToken = vi.fn();
|
||||||
|
const mockGetCurrentUser = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/api/auth', () => ({
|
||||||
|
login: (...args: unknown[]): unknown => mockApiLogin(...args),
|
||||||
|
logout: (...args: unknown[]): unknown => mockApiLogout(...args),
|
||||||
|
refreshToken: (...args: unknown[]): unknown => mockApiRefreshToken(...args),
|
||||||
|
getCurrentUser: (...args: unknown[]): unknown => mockGetCurrentUser(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock notifications
|
||||||
|
vi.mock('@mantine/notifications', () => ({
|
||||||
|
notifications: {
|
||||||
|
show: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = async (ui: React.ReactElement) => {
|
||||||
|
const result = rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
|
||||||
|
// Wait for AuthProvider initialization to complete
|
||||||
|
await waitFor(() => {
|
||||||
|
// The LoginPage should be rendered (indicates AuthProvider has initialized)
|
||||||
|
expect(screen.getByText('Welcome to Lemma')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('LoginPage', () => {
|
||||||
|
let mockNotificationShow: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Get the mocked notification function
|
||||||
|
const { notifications } = await import('@mantine/notifications');
|
||||||
|
mockNotificationShow = vi.mocked(notifications.show);
|
||||||
|
|
||||||
|
// Setup default mock implementations
|
||||||
|
mockGetCurrentUser.mockRejectedValue(new Error('No user session'));
|
||||||
|
mockApiLogin.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
role: 'editor',
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
lastWorkspaceId: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initial Render', () => {
|
||||||
|
it('renders the login form with all required elements', async () => {
|
||||||
|
await render(<LoginPage />);
|
||||||
|
|
||||||
|
// Check title and subtitle
|
||||||
|
expect(screen.getByText('Welcome to Lemma')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('Please sign in to continue')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check form fields with correct attributes
|
||||||
|
const emailInput = screen.getByTestId('email-input');
|
||||||
|
const passwordInput = screen.getByTestId('password-input');
|
||||||
|
const submitButton = screen.getByTestId('login-button');
|
||||||
|
|
||||||
|
expect(emailInput).toBeInTheDocument();
|
||||||
|
expect(emailInput).toHaveAttribute('type', 'email');
|
||||||
|
expect(emailInput).toHaveAttribute('placeholder', 'your@email.com');
|
||||||
|
expect(emailInput).toBeRequired();
|
||||||
|
|
||||||
|
expect(passwordInput).toBeInTheDocument();
|
||||||
|
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||||
|
expect(passwordInput).toHaveAttribute('placeholder', 'Your password');
|
||||||
|
expect(passwordInput).toBeRequired();
|
||||||
|
|
||||||
|
expect(submitButton).toBeInTheDocument();
|
||||||
|
expect(submitButton).toHaveAttribute('type', 'submit');
|
||||||
|
expect(submitButton).not.toHaveAttribute('data-loading', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form Interaction', () => {
|
||||||
|
it('updates input values when user types', async () => {
|
||||||
|
await render(<LoginPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByTestId('email-input');
|
||||||
|
const passwordInput = screen.getByTestId('password-input');
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||||
|
|
||||||
|
expect((emailInput as HTMLInputElement).value).toBe('test@example.com');
|
||||||
|
expect((passwordInput as HTMLInputElement).value).toBe('password123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents form submission with empty fields due to HTML5 validation', async () => {
|
||||||
|
await render(<LoginPage />);
|
||||||
|
|
||||||
|
const submitButton = screen.getByTestId('login-button');
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
expect(mockApiLogin).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form Submission', () => {
|
||||||
|
const fillAndSubmitForm = (email: string, password: string) => {
|
||||||
|
const emailInput = screen.getByTestId('email-input');
|
||||||
|
const passwordInput = screen.getByTestId('password-input');
|
||||||
|
const submitButton = screen.getByTestId('login-button');
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: email } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: password } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
return { emailInput, passwordInput, submitButton };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('calls login function with correct credentials on form submit', async () => {
|
||||||
|
await render(<LoginPage />);
|
||||||
|
fillAndSubmitForm('test@example.com', 'password123');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiLogin).toHaveBeenCalledWith(
|
||||||
|
'test@example.com',
|
||||||
|
'password123'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state during login and resets after completion', async () => {
|
||||||
|
// Create a controlled promise for login
|
||||||
|
let resolveLogin: () => void;
|
||||||
|
const loginPromise = new Promise<void>((resolve) => {
|
||||||
|
resolveLogin = resolve;
|
||||||
|
});
|
||||||
|
mockApiLogin.mockReturnValue(loginPromise);
|
||||||
|
|
||||||
|
await render(<LoginPage />);
|
||||||
|
const { submitButton } = fillAndSubmitForm(
|
||||||
|
'test@example.com',
|
||||||
|
'password123'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check loading state appears
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).toHaveAttribute('data-loading', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve the login and check loading state is removed
|
||||||
|
resolveLogin!();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).not.toHaveAttribute('data-loading', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles login success with notification', async () => {
|
||||||
|
await render(<LoginPage />);
|
||||||
|
fillAndSubmitForm('test@example.com', 'password123');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiLogin).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify success notification is shown
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNotificationShow).toHaveBeenCalledWith({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Logged in successfully',
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles login errors gracefully with notification', async () => {
|
||||||
|
const consoleErrorSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
const errorMessage = 'Invalid credentials';
|
||||||
|
mockApiLogin.mockRejectedValue(new Error(errorMessage));
|
||||||
|
|
||||||
|
await render(<LoginPage />);
|
||||||
|
const { submitButton } = fillAndSubmitForm(
|
||||||
|
'test@example.com',
|
||||||
|
'wrongpassword'
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiLogin).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify error is logged
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Login failed:',
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify error notification is shown
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNotificationShow).toHaveBeenCalledWith({
|
||||||
|
title: 'Error',
|
||||||
|
message: errorMessage,
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify loading state is reset
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).not.toHaveAttribute('data-loading', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles special characters in credentials', async () => {
|
||||||
|
await render(<LoginPage />);
|
||||||
|
|
||||||
|
const specialEmail = 'user+test@example-domain.com';
|
||||||
|
const specialPassword = 'P@ssw0rd!#$%';
|
||||||
|
|
||||||
|
fillAndSubmitForm(specialEmail, specialPassword);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiLogin).toHaveBeenCalledWith(
|
||||||
|
specialEmail,
|
||||||
|
specialPassword
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, type FormEvent } from 'react';
|
||||||
import {
|
import {
|
||||||
TextInput,
|
TextInput,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
@@ -11,35 +11,39 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage: React.FC = () => {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState<string>('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState<string>('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = (e: FormEvent<HTMLElement>): void => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
login(email, password)
|
||||||
await login(email, password);
|
.catch((error) => {
|
||||||
} finally {
|
console.error('Login failed:', error);
|
||||||
setLoading(false);
|
})
|
||||||
}
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40}>
|
<Container size={420} my={40}>
|
||||||
<Title ta="center">Welcome to NovaMD</Title>
|
<Title ta="center">Welcome to Lemma</Title>
|
||||||
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
||||||
Please sign in to continue
|
Please sign in to continue
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit} role="form">
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
type="email"
|
||||||
label="Email"
|
label="Email"
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
|
data-testid="email-input"
|
||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(event) => setEmail(event.currentTarget.value)}
|
onChange={(event) => setEmail(event.currentTarget.value)}
|
||||||
@@ -48,12 +52,13 @@ const LoginPage = () => {
|
|||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Password"
|
label="Password"
|
||||||
placeholder="Your password"
|
placeholder="Your password"
|
||||||
|
data-testid="password-input"
|
||||||
required
|
required
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.currentTarget.value)}
|
onChange={(event) => setPassword(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" loading={loading}>
|
<Button type="submit" loading={loading} data-testid="login-button">
|
||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
223
app/src/components/editor/ContentView.test.tsx
Normal file
223
app/src/components/editor/ContentView.test.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render } from '../../test/utils';
|
||||||
|
import ContentView from './ContentView';
|
||||||
|
import { Theme } from '@/types/models';
|
||||||
|
|
||||||
|
// Mock child components
|
||||||
|
vi.mock('./Editor', () => ({
|
||||||
|
default: ({
|
||||||
|
content,
|
||||||
|
selectedFile,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
selectedFile: string;
|
||||||
|
}) => (
|
||||||
|
<div data-testid="editor">
|
||||||
|
Editor - {selectedFile} - {content}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./MarkdownPreview', () => ({
|
||||||
|
default: ({ content }: { content: string }) => (
|
||||||
|
<div data-testid="markdown-preview">Preview - {content}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock contexts
|
||||||
|
vi.mock('../../contexts/WorkspaceContext', () => ({
|
||||||
|
useWorkspace: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock utils
|
||||||
|
vi.mock('../../utils/fileHelpers', () => ({
|
||||||
|
getFileUrl: vi.fn(
|
||||||
|
(workspace: string, file: string) => `http://test.com/${workspace}/${file}`
|
||||||
|
),
|
||||||
|
isImageFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('ContentView', () => {
|
||||||
|
const mockHandleContentChange = vi.fn();
|
||||||
|
const mockHandleSave = vi.fn();
|
||||||
|
const mockHandleFileSelect = vi.fn();
|
||||||
|
|
||||||
|
const mockCurrentWorkspace = {
|
||||||
|
id: 1,
|
||||||
|
name: 'test-workspace',
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
theme: Theme.Light,
|
||||||
|
autoSave: false,
|
||||||
|
showHiddenFiles: false,
|
||||||
|
gitEnabled: false,
|
||||||
|
gitUrl: '',
|
||||||
|
gitUser: '',
|
||||||
|
gitToken: '',
|
||||||
|
gitAutoCommit: false,
|
||||||
|
gitCommitMsgTemplate: '${action} ${filename}',
|
||||||
|
gitCommitName: '',
|
||||||
|
gitCommitEmail: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const { useWorkspace } = await import('../../contexts/WorkspaceContext');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: mockCurrentWorkspace,
|
||||||
|
workspaces: [],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: false,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: vi.fn(),
|
||||||
|
deleteCurrentWorkspace: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isImageFile } = await import('../../utils/fileHelpers');
|
||||||
|
vi.mocked(isImageFile).mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no workspace message when no workspace selected', async () => {
|
||||||
|
const { useWorkspace } = await import('../../contexts/WorkspaceContext');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: null,
|
||||||
|
workspaces: [],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: false,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: vi.fn(),
|
||||||
|
deleteCurrentWorkspace: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByText } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<ContentView
|
||||||
|
activeTab="source"
|
||||||
|
selectedFile="test.md"
|
||||||
|
content="Test content"
|
||||||
|
handleContentChange={mockHandleContentChange}
|
||||||
|
handleSave={mockHandleSave}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('No workspace selected.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no file message when no file selected', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<ContentView
|
||||||
|
activeTab="source"
|
||||||
|
selectedFile={null}
|
||||||
|
content=""
|
||||||
|
handleContentChange={mockHandleContentChange}
|
||||||
|
handleSave={mockHandleSave}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('No file selected.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders editor when activeTab is source', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<ContentView
|
||||||
|
activeTab="source"
|
||||||
|
selectedFile="test.md"
|
||||||
|
content="Test content"
|
||||||
|
handleContentChange={mockHandleContentChange}
|
||||||
|
handleSave={mockHandleSave}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const editor = getByTestId('editor');
|
||||||
|
expect(editor).toBeInTheDocument();
|
||||||
|
expect(editor).toHaveTextContent('Editor - test.md - Test content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders markdown preview when activeTab is preview', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<ContentView
|
||||||
|
activeTab="preview"
|
||||||
|
selectedFile="test.md"
|
||||||
|
content="# Test content"
|
||||||
|
handleContentChange={mockHandleContentChange}
|
||||||
|
handleSave={mockHandleSave}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const preview = getByTestId('markdown-preview');
|
||||||
|
expect(preview).toBeInTheDocument();
|
||||||
|
expect(preview).toHaveTextContent('Preview - # Test content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders image preview for image files', async () => {
|
||||||
|
const { isImageFile } = await import('../../utils/fileHelpers');
|
||||||
|
vi.mocked(isImageFile).mockReturnValue(true);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<ContentView
|
||||||
|
activeTab="source"
|
||||||
|
selectedFile="image.png"
|
||||||
|
content=""
|
||||||
|
handleContentChange={mockHandleContentChange}
|
||||||
|
handleSave={mockHandleSave}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const imagePreview = container.querySelector('.image-preview');
|
||||||
|
expect(imagePreview).toBeInTheDocument();
|
||||||
|
|
||||||
|
const img = container.querySelector('img');
|
||||||
|
expect(img).toBeInTheDocument();
|
||||||
|
expect(img).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
'http://test.com/test-workspace/image.png'
|
||||||
|
);
|
||||||
|
expect(img).toHaveAttribute('alt', 'image.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores activeTab for image files', async () => {
|
||||||
|
const { isImageFile } = await import('../../utils/fileHelpers');
|
||||||
|
vi.mocked(isImageFile).mockReturnValue(true);
|
||||||
|
|
||||||
|
const { container, queryByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<ContentView
|
||||||
|
activeTab="preview"
|
||||||
|
selectedFile="image.png"
|
||||||
|
content=""
|
||||||
|
handleContentChange={mockHandleContentChange}
|
||||||
|
handleSave={mockHandleSave}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show image preview regardless of activeTab
|
||||||
|
const imagePreview = container.querySelector('.image-preview');
|
||||||
|
expect(imagePreview).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should not render editor or markdown preview
|
||||||
|
expect(queryByTestId('editor')).not.toBeInTheDocument();
|
||||||
|
expect(queryByTestId('markdown-preview')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,10 +2,21 @@ import React from 'react';
|
|||||||
import { Text, Center } from '@mantine/core';
|
import { Text, Center } from '@mantine/core';
|
||||||
import Editor from './Editor';
|
import Editor from './Editor';
|
||||||
import MarkdownPreview from './MarkdownPreview';
|
import MarkdownPreview from './MarkdownPreview';
|
||||||
import { getFileUrl } from '../../services/api';
|
import { getFileUrl, isImageFile } from '../../utils/fileHelpers';
|
||||||
import { isImageFile } from '../../utils/fileHelpers';
|
import { useWorkspace } from '@/contexts/WorkspaceContext';
|
||||||
|
|
||||||
const ContentView = ({
|
type ViewTab = 'source' | 'preview';
|
||||||
|
|
||||||
|
interface ContentViewProps {
|
||||||
|
activeTab: ViewTab;
|
||||||
|
selectedFile: string | null;
|
||||||
|
content: string;
|
||||||
|
handleContentChange: (content: string) => void;
|
||||||
|
handleSave: (filePath: string, content: string) => Promise<boolean>;
|
||||||
|
handleFileSelect: (filePath: string | null) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContentView: React.FC<ContentViewProps> = ({
|
||||||
activeTab,
|
activeTab,
|
||||||
selectedFile,
|
selectedFile,
|
||||||
content,
|
content,
|
||||||
@@ -13,10 +24,21 @@ const ContentView = ({
|
|||||||
handleSave,
|
handleSave,
|
||||||
handleFileSelect,
|
handleFileSelect,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
if (!currentWorkspace) {
|
||||||
|
return (
|
||||||
|
<Center style={{ height: '100%' }}>
|
||||||
|
<Text size="xl" fw={500}>
|
||||||
|
No workspace selected.
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
return (
|
return (
|
||||||
<Center style={{ height: '100%' }}>
|
<Center style={{ height: '100%' }}>
|
||||||
<Text size="xl" weight={500}>
|
<Text size="xl" fw={500}>
|
||||||
No file selected.
|
No file selected.
|
||||||
</Text>
|
</Text>
|
||||||
</Center>
|
</Center>
|
||||||
@@ -27,7 +49,7 @@ const ContentView = ({
|
|||||||
return (
|
return (
|
||||||
<Center className="image-preview">
|
<Center className="image-preview">
|
||||||
<img
|
<img
|
||||||
src={getFileUrl(selectedFile)}
|
src={getFileUrl(currentWorkspace.name, selectedFile)}
|
||||||
alt={selectedFile}
|
alt={selectedFile}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
@@ -5,16 +5,28 @@ import { EditorView, keymap } from '@codemirror/view';
|
|||||||
import { markdown } from '@codemirror/lang-markdown';
|
import { markdown } from '@codemirror/lang-markdown';
|
||||||
import { defaultKeymap } from '@codemirror/commands';
|
import { defaultKeymap } from '@codemirror/commands';
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
import { useWorkspace } from '../../hooks/useWorkspace';
|
||||||
|
|
||||||
const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
|
interface EditorProps {
|
||||||
|
content: string;
|
||||||
|
handleContentChange: (content: string) => void;
|
||||||
|
handleSave: (filePath: string, content: string) => Promise<boolean>;
|
||||||
|
selectedFile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Editor: React.FC<EditorProps> = ({
|
||||||
|
content,
|
||||||
|
handleContentChange,
|
||||||
|
handleSave,
|
||||||
|
selectedFile,
|
||||||
|
}) => {
|
||||||
const { colorScheme } = useWorkspace();
|
const { colorScheme } = useWorkspace();
|
||||||
const editorRef = useRef();
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef();
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEditorSave = (view) => {
|
const handleEditorSave = (view: EditorView): boolean => {
|
||||||
handleSave(selectedFile, view.state.doc.toString());
|
void handleSave(selectedFile, view.state.doc.toString());
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,6 +48,8 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
const state = EditorState.create({
|
const state = EditorState.create({
|
||||||
doc: content,
|
doc: content,
|
||||||
extensions: [
|
extensions: [
|
||||||
@@ -69,8 +83,11 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
view.destroy();
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
};
|
};
|
||||||
}, [colorScheme, handleContentChange]);
|
// TODO: Refactor
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [colorScheme, handleContentChange, handleSave, selectedFile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewRef.current && content !== viewRef.current.state.doc.toString()) {
|
if (viewRef.current && content !== viewRef.current.state.doc.toString()) {
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { unified } from 'unified';
|
|
||||||
import remarkParse from 'remark-parse';
|
|
||||||
import remarkMath from 'remark-math';
|
|
||||||
import remarkRehype from 'remark-rehype';
|
|
||||||
import rehypeMathjax from 'rehype-mathjax';
|
|
||||||
import rehypeReact from 'rehype-react';
|
|
||||||
import rehypePrism from 'rehype-prism';
|
|
||||||
import * as prod from 'react/jsx-runtime';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
|
||||||
import { remarkWikiLinks } from '../../utils/remarkWikiLinks';
|
|
||||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
|
||||||
|
|
||||||
const MarkdownPreview = ({ content, handleFileSelect }) => {
|
|
||||||
const [processedContent, setProcessedContent] = useState(null);
|
|
||||||
const baseUrl = window.API_BASE_URL;
|
|
||||||
const { currentWorkspace } = useWorkspace();
|
|
||||||
|
|
||||||
const handleLinkClick = (e, href) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (href.startsWith(`${baseUrl}/internal/`)) {
|
|
||||||
// For existing files, extract the path and directly select it
|
|
||||||
const [filePath] = decodeURIComponent(
|
|
||||||
href.replace(`${baseUrl}/internal/`, '')
|
|
||||||
).split('#');
|
|
||||||
handleFileSelect(filePath);
|
|
||||||
} else if (href.startsWith(`${baseUrl}/notfound/`)) {
|
|
||||||
// For non-existent files, show a notification
|
|
||||||
const fileName = decodeURIComponent(
|
|
||||||
href.replace(`${baseUrl}/notfound/`, '')
|
|
||||||
);
|
|
||||||
notifications.show({
|
|
||||||
title: 'File Not Found',
|
|
||||||
message: `The file "${fileName}" does not exist.`,
|
|
||||||
color: 'red',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const processor = useMemo(
|
|
||||||
() =>
|
|
||||||
unified()
|
|
||||||
.use(remarkParse)
|
|
||||||
.use(remarkWikiLinks, currentWorkspace?.name)
|
|
||||||
.use(remarkMath)
|
|
||||||
.use(remarkRehype)
|
|
||||||
.use(rehypeMathjax)
|
|
||||||
.use(rehypePrism)
|
|
||||||
.use(rehypeReact, {
|
|
||||||
production: true,
|
|
||||||
jsx: prod.jsx,
|
|
||||||
jsxs: prod.jsxs,
|
|
||||||
Fragment: prod.Fragment,
|
|
||||||
components: {
|
|
||||||
img: ({ src, alt, ...props }) => (
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={alt}
|
|
||||||
onError={(event) => {
|
|
||||||
console.error('Failed to load image:', event.target.src);
|
|
||||||
event.target.alt = 'Failed to load image';
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
a: ({ href, children, ...props }) => (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
onClick={(e) => handleLinkClick(e, href)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
code: ({ children, className, ...props }) => {
|
|
||||||
const language = className
|
|
||||||
? className.replace('language-', '')
|
|
||||||
: null;
|
|
||||||
return (
|
|
||||||
<pre className={className}>
|
|
||||||
<code {...props}>{children}</code>
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[baseUrl, handleFileSelect, currentWorkspace?.name]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const processContent = async () => {
|
|
||||||
if (!currentWorkspace) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await processor.process(content);
|
|
||||||
setProcessedContent(result.result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing markdown:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
processContent();
|
|
||||||
}, [content, processor, currentWorkspace]);
|
|
||||||
|
|
||||||
return <div className="markdown-preview">{processedContent}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MarkdownPreview;
|
|
||||||
347
app/src/components/editor/MarkdownPreview.test.tsx
Normal file
347
app/src/components/editor/MarkdownPreview.test.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import MarkdownPreview from './MarkdownPreview';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { Theme } from '../../types/models';
|
||||||
|
|
||||||
|
// Mock notifications
|
||||||
|
vi.mock('@mantine/notifications', () => ({
|
||||||
|
notifications: {
|
||||||
|
show: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock useWorkspace hook
|
||||||
|
vi.mock('../../hooks/useWorkspace', () => ({
|
||||||
|
useWorkspace: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the remarkWikiLinks utility
|
||||||
|
vi.mock('../../utils/remarkWikiLinks', () => ({
|
||||||
|
remarkWikiLinks: vi.fn(() => () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock window.API_BASE_URL
|
||||||
|
Object.defineProperty(window, 'API_BASE_URL', {
|
||||||
|
value: 'http://localhost:3000',
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('MarkdownPreview', () => {
|
||||||
|
const mockHandleFileSelect = vi.fn();
|
||||||
|
const mockNotificationsShow = vi.mocked(notifications.show);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Setup useWorkspace mock
|
||||||
|
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: {
|
||||||
|
id: 1,
|
||||||
|
name: 'test-workspace',
|
||||||
|
theme: Theme.Light,
|
||||||
|
autoSave: false,
|
||||||
|
showHiddenFiles: false,
|
||||||
|
gitEnabled: false,
|
||||||
|
gitUrl: '',
|
||||||
|
gitUser: '',
|
||||||
|
gitToken: '',
|
||||||
|
gitAutoCommit: false,
|
||||||
|
gitCommitMsgTemplate: '',
|
||||||
|
gitCommitName: '',
|
||||||
|
gitCommitEmail: '',
|
||||||
|
createdAt: '2023-01-01T00:00:00Z',
|
||||||
|
lastOpenedFilePath: '',
|
||||||
|
},
|
||||||
|
workspaces: [],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: false,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: vi.fn(),
|
||||||
|
deleteCurrentWorkspace: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders basic markdown content', async () => {
|
||||||
|
const content = '# Hello World\n\nThis is a test.';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MarkdownPreview
|
||||||
|
content={content}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('This is a test.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders code blocks with syntax highlighting', async () => {
|
||||||
|
const content = '```javascript\nconst hello = "world";\n```';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MarkdownPreview
|
||||||
|
content={content}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Check for the code element containing the text pieces
|
||||||
|
const codeElement = screen.getByText((_, element) => {
|
||||||
|
return !!(
|
||||||
|
element?.tagName.toLowerCase() === 'code' &&
|
||||||
|
element?.textContent?.includes('const') &&
|
||||||
|
element?.textContent?.includes('hello') &&
|
||||||
|
element?.textContent?.includes('world')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(codeElement).toBeInTheDocument();
|
||||||
|
expect(codeElement.closest('pre')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders code blocks with correct structure for theme switching', async () => {
|
||||||
|
const content = '```javascript\nconst hello = "world";\n```';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MarkdownPreview
|
||||||
|
content={content}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Check that rehype-highlight generates the correct structure
|
||||||
|
const preElement = screen
|
||||||
|
.getByRole('code', { hidden: true })
|
||||||
|
.closest('pre');
|
||||||
|
const codeElement = preElement?.querySelector('code');
|
||||||
|
|
||||||
|
expect(preElement).toBeInTheDocument();
|
||||||
|
expect(codeElement).toBeInTheDocument();
|
||||||
|
|
||||||
|
// The code element should have hljs class for theme switching to work
|
||||||
|
expect(codeElement).toHaveClass('hljs');
|
||||||
|
|
||||||
|
// Should also have language class
|
||||||
|
expect(codeElement).toHaveClass('language-javascript');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles image loading errors gracefully', async () => {
|
||||||
|
const content = '';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MarkdownPreview
|
||||||
|
content={content}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
expect(img).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Simulate image load error
|
||||||
|
fireEvent.error(img);
|
||||||
|
|
||||||
|
expect(img).toHaveAttribute('alt', 'Failed to load image');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles internal link clicks and calls handleFileSelect', async () => {
|
||||||
|
const content = '[Test Link](http://localhost:3000/internal/test-file.md)';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MarkdownPreview
|
||||||
|
content={content}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const link = screen.getByText('Test Link');
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(link);
|
||||||
|
|
||||||
|
expect(mockHandleFileSelect).toHaveBeenCalledWith('test-file.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows notification for non-existent file links', async () => {
|
||||||
|
const content =
|
||||||
|
'[Missing File](http://localhost:3000/notfound/missing-file.md)';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MarkdownPreview
|
||||||
|
content={content}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const link = screen.getByText('Missing File');
|
||||||
|
fireEvent.click(link);
|
||||||
|
|
||||||
|
expect(mockNotificationsShow).toHaveBeenCalledWith({
|
||||||
|
title: 'File Not Found',
|
||||||
|
message: 'The file "missing-file.md" does not exist.',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
expect(mockHandleFileSelect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles external links normally without interference', async () => {
|
||||||
|
const content = '[External Link](https://example.com)';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MarkdownPreview
|
||||||
|
content={content}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const link = screen.getByText('External Link');
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
expect(link).toHaveAttribute('href', 'https://example.com');
|
||||||
|
|
||||||
|
// Click should be prevented but no file selection should occur
|
||||||
|
fireEvent.click(link);
|
||||||
|
expect(mockHandleFileSelect).not.toHaveBeenCalled();
|
||||||
|
expect(mockNotificationsShow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not process content when no workspace is available', async () => {
|
||||||
|
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: null,
|
||||||
|
workspaces: [],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: false,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: vi.fn(),
|
||||||
|
deleteCurrentWorkspace: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = '# Test Content';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MarkdownPreview
|
||||||
|
content={content}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should render empty content when no workspace
|
||||||
|
const markdownPreview = screen.getByTestId('markdown-preview');
|
||||||
|
expect(markdownPreview).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty content gracefully', async () => {
|
||||||
|
render(
|
||||||
|
<MarkdownPreview content="" handleFileSelect={mockHandleFileSelect} />
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const markdownPreview = screen.getByTestId('markdown-preview');
|
||||||
|
expect(markdownPreview).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates content when markdown changes', async () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<MarkdownPreview
|
||||||
|
content="# First Content"
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('First Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<TestWrapper>
|
||||||
|
<MarkdownPreview
|
||||||
|
content="# Updated Content"
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Updated Content')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('First Content')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles markdown processing errors gracefully', async () => {
|
||||||
|
// Mock console.error to avoid noise in test output
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Create content that might cause processing issues
|
||||||
|
const problematicContent = '# Test\n\n```invalid-syntax\nbroken code\n```';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MarkdownPreview
|
||||||
|
content={problematicContent}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for async content processing to complete
|
||||||
|
await waitFor(() => {
|
||||||
|
// Should still render something even if processing has issues
|
||||||
|
const markdownPreview = screen.getByTestId('markdown-preview');
|
||||||
|
expect(markdownPreview).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles URL decoding for file paths correctly', async () => {
|
||||||
|
const encodedContent =
|
||||||
|
'[Test Link](http://localhost:3000/internal/test%20file%20with%20spaces.md)';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MarkdownPreview
|
||||||
|
content={encodedContent}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const link = screen.getByText('Test Link');
|
||||||
|
fireEvent.click(link);
|
||||||
|
|
||||||
|
expect(mockHandleFileSelect).toHaveBeenCalledWith(
|
||||||
|
'test file with spaces.md'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
136
app/src/components/editor/MarkdownPreview.tsx
Normal file
136
app/src/components/editor/MarkdownPreview.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, type ReactNode } from 'react';
|
||||||
|
import { unified } from 'unified';
|
||||||
|
import remarkParse from 'remark-parse';
|
||||||
|
import remarkMath from 'remark-math';
|
||||||
|
import remarkRehype from 'remark-rehype';
|
||||||
|
import rehypeMathjax from 'rehype-mathjax';
|
||||||
|
import rehypeReact, { type Options } from 'rehype-react';
|
||||||
|
import rehypeHighlight from 'rehype-highlight';
|
||||||
|
import * as prod from 'react/jsx-runtime';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { remarkWikiLinks } from '../../utils/remarkWikiLinks';
|
||||||
|
import { useWorkspace } from '../../hooks/useWorkspace';
|
||||||
|
import { useHighlightTheme } from '../../hooks/useHighlightTheme';
|
||||||
|
|
||||||
|
interface MarkdownPreviewProps {
|
||||||
|
content: string;
|
||||||
|
handleFileSelect: (filePath: string | null) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownImageProps {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownLinkProps {
|
||||||
|
href: string;
|
||||||
|
children: ReactNode;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
|
||||||
|
content,
|
||||||
|
handleFileSelect,
|
||||||
|
}) => {
|
||||||
|
const [processedContent, setProcessedContent] = useState<ReactNode | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const baseUrl = window.API_BASE_URL;
|
||||||
|
const { currentWorkspace, colorScheme } = useWorkspace();
|
||||||
|
|
||||||
|
// Use the highlight theme hook
|
||||||
|
useHighlightTheme(colorScheme === 'auto' ? 'light' : colorScheme);
|
||||||
|
|
||||||
|
const processor = useMemo(() => {
|
||||||
|
const handleLinkClick = (
|
||||||
|
e: React.MouseEvent<HTMLAnchorElement>,
|
||||||
|
href: string
|
||||||
|
): void => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (href.startsWith(`${baseUrl}/internal/`)) {
|
||||||
|
// For existing files, extract the path and directly select it
|
||||||
|
const [filePath] = decodeURIComponent(
|
||||||
|
href.replace(`${baseUrl}/internal/`, '')
|
||||||
|
).split('#');
|
||||||
|
if (filePath) {
|
||||||
|
void handleFileSelect(filePath);
|
||||||
|
}
|
||||||
|
} else if (href.startsWith(`${baseUrl}/notfound/`)) {
|
||||||
|
// For non-existent files, show a notification
|
||||||
|
const fileName = decodeURIComponent(
|
||||||
|
href.replace(`${baseUrl}/notfound/`, '')
|
||||||
|
);
|
||||||
|
notifications.show({
|
||||||
|
title: 'File Not Found',
|
||||||
|
message: `The file "${fileName}" does not exist.`,
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Only create the processor if we have a workspace name
|
||||||
|
if (!currentWorkspace?.name) {
|
||||||
|
return unified();
|
||||||
|
}
|
||||||
|
|
||||||
|
return unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkWikiLinks, currentWorkspace.name)
|
||||||
|
.use(remarkMath)
|
||||||
|
.use(remarkRehype)
|
||||||
|
.use(rehypeMathjax)
|
||||||
|
.use(rehypeHighlight)
|
||||||
|
.use(rehypeReact, {
|
||||||
|
jsx: prod.jsx,
|
||||||
|
jsxs: prod.jsxs,
|
||||||
|
Fragment: prod.Fragment,
|
||||||
|
development: false,
|
||||||
|
elementAttributeNameCase: 'react',
|
||||||
|
stylePropertyNameCase: 'dom',
|
||||||
|
components: {
|
||||||
|
img: ({ src, alt, ...props }: MarkdownImageProps) => (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt || ''}
|
||||||
|
onError={(event) => {
|
||||||
|
console.error('Failed to load image:', event.currentTarget.src);
|
||||||
|
event.currentTarget.alt = 'Failed to load image';
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
a: ({ href, children, ...props }: MarkdownLinkProps) => (
|
||||||
|
<a href={href} onClick={(e) => handleLinkClick(e, href)} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
} as Options);
|
||||||
|
}, [currentWorkspace?.name, baseUrl, handleFileSelect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const processContent = async (): Promise<void> => {
|
||||||
|
if (!currentWorkspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await processor.process(content);
|
||||||
|
setProcessedContent(result.result as ReactNode);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing markdown:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void processContent();
|
||||||
|
}, [content, processor, currentWorkspace]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="markdown-preview" data-testid="markdown-preview">
|
||||||
|
{processedContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarkdownPreview;
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { ActionIcon, Tooltip, Group } from '@mantine/core';
|
|
||||||
import {
|
|
||||||
IconPlus,
|
|
||||||
IconTrash,
|
|
||||||
IconGitPullRequest,
|
|
||||||
IconGitCommit,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import { useModalContext } from '../../contexts/ModalContext';
|
|
||||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
|
||||||
|
|
||||||
const FileActions = ({ handlePullChanges, selectedFile }) => {
|
|
||||||
const { settings } = useWorkspace();
|
|
||||||
const {
|
|
||||||
setNewFileModalVisible,
|
|
||||||
setDeleteFileModalVisible,
|
|
||||||
setCommitMessageModalVisible,
|
|
||||||
} = useModalContext();
|
|
||||||
|
|
||||||
const handleCreateFile = () => setNewFileModalVisible(true);
|
|
||||||
const handleDeleteFile = () => setDeleteFileModalVisible(true);
|
|
||||||
const handleCommitAndPush = () => setCommitMessageModalVisible(true);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group gap="xs">
|
|
||||||
<Tooltip label="Create new file">
|
|
||||||
<ActionIcon variant="default" size="md" onClick={handleCreateFile}>
|
|
||||||
<IconPlus size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip
|
|
||||||
label={selectedFile ? 'Delete current file' : 'No file selected'}
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="md"
|
|
||||||
onClick={handleDeleteFile}
|
|
||||||
disabled={!selectedFile}
|
|
||||||
color="red"
|
|
||||||
>
|
|
||||||
<IconTrash size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip
|
|
||||||
label={
|
|
||||||
settings.gitEnabled
|
|
||||||
? 'Pull changes from remote'
|
|
||||||
: 'Git is not enabled'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="md"
|
|
||||||
onClick={handlePullChanges}
|
|
||||||
disabled={!settings.gitEnabled}
|
|
||||||
>
|
|
||||||
<IconGitPullRequest size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip
|
|
||||||
label={
|
|
||||||
!settings.gitEnabled
|
|
||||||
? 'Git is not enabled'
|
|
||||||
: settings.gitAutoCommit
|
|
||||||
? 'Auto-commit is enabled'
|
|
||||||
: 'Commit and push changes'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="md"
|
|
||||||
onClick={handleCommitAndPush}
|
|
||||||
disabled={!settings.gitEnabled || settings.gitAutoCommit}
|
|
||||||
>
|
|
||||||
<IconGitCommit size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FileActions;
|
|
||||||
258
app/src/components/files/FileActions.test.tsx
Normal file
258
app/src/components/files/FileActions.test.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { fireEvent } from '@testing-library/react';
|
||||||
|
import { render } from '../../test/utils';
|
||||||
|
import FileActions from './FileActions';
|
||||||
|
import { Theme } from '@/types/models';
|
||||||
|
import { ModalProvider } from '../../contexts/ModalContext';
|
||||||
|
import { ThemeProvider } from '../../contexts/ThemeContext';
|
||||||
|
import { WorkspaceDataProvider } from '../../contexts/WorkspaceDataContext';
|
||||||
|
|
||||||
|
// Mock the contexts and hooks
|
||||||
|
vi.mock('../../contexts/ModalContext', () => ({
|
||||||
|
useModalContext: vi.fn(),
|
||||||
|
ModalProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useWorkspace', () => ({
|
||||||
|
useWorkspace: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock contexts
|
||||||
|
vi.mock('../../contexts/ThemeContext', () => ({
|
||||||
|
useTheme: () => ({
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
}),
|
||||||
|
ThemeProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../contexts/WorkspaceDataContext', () => ({
|
||||||
|
useWorkspaceData: () => ({
|
||||||
|
currentWorkspace: { name: 'test-workspace', path: '/test' },
|
||||||
|
workspaces: [],
|
||||||
|
settings: {},
|
||||||
|
loading: false,
|
||||||
|
loadWorkspaces: vi.fn(),
|
||||||
|
loadWorkspaceData: vi.fn(),
|
||||||
|
setCurrentWorkspace: vi.fn(),
|
||||||
|
}),
|
||||||
|
WorkspaceDataProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<ThemeProvider>
|
||||||
|
<WorkspaceDataProvider>
|
||||||
|
<ModalProvider>{children}</ModalProvider>
|
||||||
|
</WorkspaceDataProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('FileActions', () => {
|
||||||
|
const mockHandlePullChanges = vi.fn();
|
||||||
|
const mockLoadFileList = vi.fn();
|
||||||
|
const mockSetNewFileModalVisible = vi.fn();
|
||||||
|
const mockSetDeleteFileModalVisible = vi.fn();
|
||||||
|
const mockSetCommitMessageModalVisible = vi.fn();
|
||||||
|
|
||||||
|
const mockCurrentWorkspace = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Workspace',
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
gitEnabled: true,
|
||||||
|
gitAutoCommit: false,
|
||||||
|
theme: Theme.Light,
|
||||||
|
autoSave: true,
|
||||||
|
showHiddenFiles: false,
|
||||||
|
gitUrl: '',
|
||||||
|
gitBranch: 'main',
|
||||||
|
gitUsername: '',
|
||||||
|
gitEmail: '',
|
||||||
|
gitToken: '',
|
||||||
|
gitUser: '',
|
||||||
|
gitCommitMsgTemplate: '',
|
||||||
|
gitCommitName: '',
|
||||||
|
gitCommitEmail: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const { useModalContext } = await import('../../contexts/ModalContext');
|
||||||
|
vi.mocked(useModalContext).mockReturnValue({
|
||||||
|
newFileModalVisible: false,
|
||||||
|
setNewFileModalVisible: mockSetNewFileModalVisible,
|
||||||
|
deleteFileModalVisible: false,
|
||||||
|
setDeleteFileModalVisible: mockSetDeleteFileModalVisible,
|
||||||
|
renameFileModalVisible: false,
|
||||||
|
setRenameFileModalVisible: vi.fn(),
|
||||||
|
commitMessageModalVisible: false,
|
||||||
|
setCommitMessageModalVisible: mockSetCommitMessageModalVisible,
|
||||||
|
settingsModalVisible: false,
|
||||||
|
setSettingsModalVisible: vi.fn(),
|
||||||
|
switchWorkspaceModalVisible: false,
|
||||||
|
setSwitchWorkspaceModalVisible: vi.fn(),
|
||||||
|
createWorkspaceModalVisible: false,
|
||||||
|
setCreateWorkspaceModalVisible: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: mockCurrentWorkspace,
|
||||||
|
workspaces: [],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: false,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: vi.fn(),
|
||||||
|
deleteCurrentWorkspace: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens new file modal when create button is clicked', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<FileActions
|
||||||
|
handlePullChanges={mockHandlePullChanges}
|
||||||
|
selectedFile={null}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const createButton = getByTestId('create-file-button');
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
expect(mockSetNewFileModalVisible).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens delete modal when delete button is clicked with selected file', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<FileActions
|
||||||
|
handlePullChanges={mockHandlePullChanges}
|
||||||
|
selectedFile="test.md"
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteButton = getByTestId('delete-file-button');
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
expect(mockSetDeleteFileModalVisible).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables delete button when no file is selected', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<FileActions
|
||||||
|
handlePullChanges={mockHandlePullChanges}
|
||||||
|
selectedFile={null}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteButton = getByTestId('delete-file-button');
|
||||||
|
expect(deleteButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls pull changes when pull button is clicked', () => {
|
||||||
|
mockHandlePullChanges.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<FileActions
|
||||||
|
handlePullChanges={mockHandlePullChanges}
|
||||||
|
selectedFile="test.md"
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const pullButton = getByTestId('pull-changes-button');
|
||||||
|
fireEvent.click(pullButton);
|
||||||
|
|
||||||
|
expect(mockHandlePullChanges).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables git buttons when git is not enabled', async () => {
|
||||||
|
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: { ...mockCurrentWorkspace, gitEnabled: false },
|
||||||
|
workspaces: [],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: false,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: vi.fn(),
|
||||||
|
deleteCurrentWorkspace: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<FileActions
|
||||||
|
handlePullChanges={mockHandlePullChanges}
|
||||||
|
selectedFile="test.md"
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const pullButton = getByTestId('pull-changes-button');
|
||||||
|
expect(pullButton).toBeDisabled();
|
||||||
|
|
||||||
|
const commitButton = getByTestId('commit-push-button');
|
||||||
|
expect(commitButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens commit modal when commit button is clicked', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<FileActions
|
||||||
|
handlePullChanges={mockHandlePullChanges}
|
||||||
|
selectedFile="test.md"
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitButton = getByTestId('commit-push-button');
|
||||||
|
fireEvent.click(commitButton);
|
||||||
|
|
||||||
|
expect(mockSetCommitMessageModalVisible).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables commit button when auto-commit is enabled', async () => {
|
||||||
|
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: { ...mockCurrentWorkspace, gitAutoCommit: true },
|
||||||
|
workspaces: [],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: false,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: vi.fn(),
|
||||||
|
deleteCurrentWorkspace: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<FileActions
|
||||||
|
handlePullChanges={mockHandlePullChanges}
|
||||||
|
selectedFile="test.md"
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitButton = getByTestId('commit-push-button');
|
||||||
|
expect(commitButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
187
app/src/components/files/FileActions.tsx
Normal file
187
app/src/components/files/FileActions.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { ActionIcon, Tooltip, Group } from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconPlus,
|
||||||
|
IconTrash,
|
||||||
|
IconGitPullRequest,
|
||||||
|
IconGitCommit,
|
||||||
|
IconUpload,
|
||||||
|
IconEdit,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useModalContext } from '../../contexts/ModalContext';
|
||||||
|
import { useWorkspace } from '../../hooks/useWorkspace';
|
||||||
|
import { useFileOperations } from '../../hooks/useFileOperations';
|
||||||
|
|
||||||
|
interface FileActionsProps {
|
||||||
|
handlePullChanges: () => Promise<boolean>;
|
||||||
|
selectedFile: string | null;
|
||||||
|
loadFileList: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileActions: React.FC<FileActionsProps> = ({
|
||||||
|
handlePullChanges,
|
||||||
|
selectedFile,
|
||||||
|
loadFileList,
|
||||||
|
}) => {
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const {
|
||||||
|
setNewFileModalVisible,
|
||||||
|
setDeleteFileModalVisible,
|
||||||
|
setCommitMessageModalVisible,
|
||||||
|
setRenameFileModalVisible,
|
||||||
|
} = useModalContext();
|
||||||
|
|
||||||
|
const { handleUpload } = useFileOperations();
|
||||||
|
|
||||||
|
// Hidden file input for upload
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleCreateFile = (): void => setNewFileModalVisible(true);
|
||||||
|
const handleDeleteFile = (): void => setDeleteFileModalVisible(true);
|
||||||
|
const handleRenameFile = (): void => setRenameFileModalVisible(true);
|
||||||
|
const handleCommitAndPush = (): void => setCommitMessageModalVisible(true);
|
||||||
|
|
||||||
|
const handleUploadClick = (): void => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileInputChange = (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>
|
||||||
|
): void => {
|
||||||
|
const files = event.target.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
const uploadFiles = async () => {
|
||||||
|
try {
|
||||||
|
const success = await handleUpload(files);
|
||||||
|
if (success) {
|
||||||
|
await loadFileList();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading files:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void uploadFiles();
|
||||||
|
|
||||||
|
// Reset the input so the same file can be selected again
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Tooltip label="Create new file">
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="md"
|
||||||
|
onClick={handleCreateFile}
|
||||||
|
aria-label="Create new file"
|
||||||
|
data-testid="create-file-button"
|
||||||
|
>
|
||||||
|
<IconPlus size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label="Upload files">
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="md"
|
||||||
|
onClick={handleUploadClick}
|
||||||
|
aria-label="Upload files"
|
||||||
|
data-testid="upload-files-button"
|
||||||
|
>
|
||||||
|
<IconUpload size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
label={selectedFile ? 'Rename current file' : 'No file selected'}
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="md"
|
||||||
|
onClick={handleRenameFile}
|
||||||
|
disabled={!selectedFile}
|
||||||
|
aria-label="Rename current file"
|
||||||
|
data-testid="rename-file-button"
|
||||||
|
>
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
label={selectedFile ? 'Delete current file' : 'No file selected'}
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="md"
|
||||||
|
onClick={handleDeleteFile}
|
||||||
|
disabled={!selectedFile}
|
||||||
|
color="red"
|
||||||
|
aria-label="Delete current file"
|
||||||
|
data-testid="delete-file-button"
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
currentWorkspace?.gitEnabled
|
||||||
|
? 'Pull changes from remote'
|
||||||
|
: 'Git is not enabled'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="md"
|
||||||
|
onClick={() => {
|
||||||
|
handlePullChanges().catch((error) => {
|
||||||
|
console.error('Error pulling changes:', error);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={!currentWorkspace?.gitEnabled}
|
||||||
|
aria-label="Pull changes from remote"
|
||||||
|
data-testid="pull-changes-button"
|
||||||
|
>
|
||||||
|
<IconGitPullRequest size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
!currentWorkspace?.gitEnabled
|
||||||
|
? 'Git is not enabled'
|
||||||
|
: currentWorkspace.gitAutoCommit
|
||||||
|
? 'Auto-commit is enabled'
|
||||||
|
: 'Commit and push changes'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="md"
|
||||||
|
onClick={handleCommitAndPush}
|
||||||
|
disabled={
|
||||||
|
!currentWorkspace?.gitEnabled || currentWorkspace.gitAutoCommit
|
||||||
|
}
|
||||||
|
aria-label="Commit and push changes"
|
||||||
|
data-testid="commit-push-button"
|
||||||
|
>
|
||||||
|
<IconGitCommit size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Hidden file input */}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
multiple
|
||||||
|
aria-label="File upload input"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileActions;
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import React, { useRef, useState, useLayoutEffect } from 'react';
|
|
||||||
import { Tree } from 'react-arborist';
|
|
||||||
import { IconFile, IconFolder, IconFolderOpen } from '@tabler/icons-react';
|
|
||||||
import { Tooltip } from '@mantine/core';
|
|
||||||
import useResizeObserver from '@react-hook/resize-observer';
|
|
||||||
|
|
||||||
const useSize = (target) => {
|
|
||||||
const [size, setSize] = useState();
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
setSize(target.current.getBoundingClientRect());
|
|
||||||
}, [target]);
|
|
||||||
|
|
||||||
useResizeObserver(target, (entry) => setSize(entry.contentRect));
|
|
||||||
return size;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FileIcon = ({ node }) => {
|
|
||||||
if (node.isLeaf) {
|
|
||||||
return <IconFile size={16} />;
|
|
||||||
}
|
|
||||||
return node.isOpen ? (
|
|
||||||
<IconFolderOpen size={16} color="var(--mantine-color-yellow-filled)" />
|
|
||||||
) : (
|
|
||||||
<IconFolder size={16} color="var(--mantine-color-yellow-filled)" />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Node = ({ node, style, dragHandle }) => {
|
|
||||||
return (
|
|
||||||
<Tooltip label={node.data.name} openDelay={500}>
|
|
||||||
<div
|
|
||||||
ref={dragHandle}
|
|
||||||
style={{
|
|
||||||
...style,
|
|
||||||
paddingLeft: `${node.level * 20}px`,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
cursor: 'pointer',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (node.isInternal) {
|
|
||||||
node.toggle();
|
|
||||||
} else {
|
|
||||||
node.tree.props.onNodeClick(node);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FileIcon node={node} />
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: '8px',
|
|
||||||
fontSize: '14px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
flexGrow: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{node.data.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const FileTree = ({ files, handleFileSelect, showHiddenFiles }) => {
|
|
||||||
const target = useRef(null);
|
|
||||||
const size = useSize(target);
|
|
||||||
|
|
||||||
files = files.filter((file) => {
|
|
||||||
if (file.name.startsWith('.') && !showHiddenFiles) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={target}
|
|
||||||
style={{ height: 'calc(100vh - 140px)', marginTop: '20px' }}
|
|
||||||
>
|
|
||||||
{size && (
|
|
||||||
<Tree
|
|
||||||
data={files}
|
|
||||||
openByDefault={false}
|
|
||||||
width={size.width}
|
|
||||||
height={size.height}
|
|
||||||
indent={24}
|
|
||||||
rowHeight={28}
|
|
||||||
onActivate={(node) => {
|
|
||||||
if (!node.isInternal) {
|
|
||||||
handleFileSelect(node.data.path);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onNodeClick={(node) => {
|
|
||||||
if (!node.isInternal) {
|
|
||||||
handleFileSelect(node.data.path);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Node}
|
|
||||||
</Tree>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FileTree;
|
|
||||||
288
app/src/components/files/FileTree.test.tsx
Normal file
288
app/src/components/files/FileTree.test.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { render } from '../../test/utils';
|
||||||
|
import FileTree from './FileTree';
|
||||||
|
import type { FileNode } from '../../types/models';
|
||||||
|
import { ModalProvider } from '../../contexts/ModalContext';
|
||||||
|
import { ThemeProvider } from '../../contexts/ThemeContext';
|
||||||
|
import { WorkspaceDataProvider } from '../../contexts/WorkspaceDataContext';
|
||||||
|
|
||||||
|
// Mock react-arborist
|
||||||
|
vi.mock('react-arborist', () => ({
|
||||||
|
Tree: ({
|
||||||
|
children,
|
||||||
|
data,
|
||||||
|
onActivate,
|
||||||
|
}: {
|
||||||
|
children: (props: {
|
||||||
|
node: {
|
||||||
|
data: FileNode;
|
||||||
|
isLeaf: boolean;
|
||||||
|
isInternal: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
|
level: number;
|
||||||
|
toggle: () => void;
|
||||||
|
};
|
||||||
|
style: Record<string, unknown>;
|
||||||
|
onNodeClick: (node: { isInternal: boolean }) => void;
|
||||||
|
}) => React.ReactNode;
|
||||||
|
data: FileNode[];
|
||||||
|
onActivate: (node: { isInternal: boolean; data: FileNode }) => void;
|
||||||
|
}) => (
|
||||||
|
<div data-testid="file-tree">
|
||||||
|
{data.map((file) => {
|
||||||
|
const mockNode = {
|
||||||
|
data: file,
|
||||||
|
isLeaf: !file.children || file.children.length === 0,
|
||||||
|
isInternal: !!(file.children && file.children.length > 0),
|
||||||
|
isOpen: false,
|
||||||
|
level: 0,
|
||||||
|
toggle: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
data-testid={`file-node-${file.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
// Simulate the Tree's onActivate behavior
|
||||||
|
if (!mockNode.isInternal) {
|
||||||
|
onActivate({ isInternal: mockNode.isInternal, data: file });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children({
|
||||||
|
node: mockNode,
|
||||||
|
style: {},
|
||||||
|
onNodeClick: (node: { isInternal: boolean }) => {
|
||||||
|
if (!node.isInternal) {
|
||||||
|
onActivate({ isInternal: node.isInternal, data: file });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock resize observer hook
|
||||||
|
vi.mock('@react-hook/resize-observer', () => ({
|
||||||
|
default: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock contexts
|
||||||
|
vi.mock('../../contexts/ThemeContext', () => ({
|
||||||
|
useTheme: () => ({
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
}),
|
||||||
|
ThemeProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../contexts/WorkspaceDataContext', () => ({
|
||||||
|
useWorkspaceData: () => ({
|
||||||
|
currentWorkspace: { name: 'test-workspace', path: '/test' },
|
||||||
|
workspaces: [],
|
||||||
|
settings: {},
|
||||||
|
loading: false,
|
||||||
|
loadWorkspaces: vi.fn(),
|
||||||
|
loadWorkspaceData: vi.fn(),
|
||||||
|
setCurrentWorkspace: vi.fn(),
|
||||||
|
}),
|
||||||
|
WorkspaceDataProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../contexts/ModalContext', () => ({
|
||||||
|
useModalContext: () => ({
|
||||||
|
newFileModalVisible: false,
|
||||||
|
setNewFileModalVisible: vi.fn(),
|
||||||
|
deleteFileModalVisible: false,
|
||||||
|
setDeleteFileModalVisible: vi.fn(),
|
||||||
|
renameFileModalVisible: false,
|
||||||
|
setRenameFileModalVisible: vi.fn(),
|
||||||
|
commitMessageModalVisible: false,
|
||||||
|
setCommitMessageModalVisible: vi.fn(),
|
||||||
|
settingsModalVisible: false,
|
||||||
|
setSettingsModalVisible: vi.fn(),
|
||||||
|
switchWorkspaceModalVisible: false,
|
||||||
|
setSwitchWorkspaceModalVisible: vi.fn(),
|
||||||
|
createWorkspaceModalVisible: false,
|
||||||
|
setCreateWorkspaceModalVisible: vi.fn(),
|
||||||
|
}),
|
||||||
|
ModalProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useFileOperations', () => ({
|
||||||
|
useFileOperations: () => ({
|
||||||
|
handleSave: vi.fn(),
|
||||||
|
handleCreate: vi.fn(),
|
||||||
|
handleDelete: vi.fn(),
|
||||||
|
handleUpload: vi.fn(),
|
||||||
|
handleMove: vi.fn(),
|
||||||
|
handleRename: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<ThemeProvider>
|
||||||
|
<WorkspaceDataProvider>
|
||||||
|
<ModalProvider>{children}</ModalProvider>
|
||||||
|
</WorkspaceDataProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('FileTree', () => {
|
||||||
|
const mockHandleFileSelect = vi.fn();
|
||||||
|
const mockLoadFileList = vi.fn();
|
||||||
|
|
||||||
|
const mockFiles: FileNode[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'README.md',
|
||||||
|
path: 'README.md',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'docs',
|
||||||
|
path: 'docs',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'guide.md',
|
||||||
|
path: 'docs/guide.md',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: '.hidden',
|
||||||
|
path: '.hidden',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders file tree with files', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<FileTree
|
||||||
|
files={mockFiles}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
showHiddenFiles={true}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('file-tree')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('file-node-1')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('file-node-2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls handleFileSelect when file is clicked', async () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<FileTree
|
||||||
|
files={mockFiles}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
showHiddenFiles={true}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileNode = getByTestId('file-node-1');
|
||||||
|
fireEvent.click(fileNode);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockHandleFileSelect).toHaveBeenCalledWith('README.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out hidden files when showHiddenFiles is false', () => {
|
||||||
|
const { getByTestId, queryByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<FileTree
|
||||||
|
files={mockFiles}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
showHiddenFiles={false}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show regular files
|
||||||
|
expect(getByTestId('file-node-1')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('file-node-2')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should not show hidden file
|
||||||
|
expect(queryByTestId('file-node-4')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows hidden files when showHiddenFiles is true', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<FileTree
|
||||||
|
files={mockFiles}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
showHiddenFiles={true}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show all files including hidden
|
||||||
|
expect(getByTestId('file-node-1')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('file-node-2')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('file-node-4')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty tree when no files provided', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<FileTree
|
||||||
|
files={[]}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
showHiddenFiles={true}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const tree = getByTestId('file-tree');
|
||||||
|
expect(tree).toBeInTheDocument();
|
||||||
|
expect(tree.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call handleFileSelect for folder clicks', async () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<FileTree
|
||||||
|
files={mockFiles}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
showHiddenFiles={true}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click on folder (has children)
|
||||||
|
const folderNode = getByTestId('file-node-2');
|
||||||
|
fireEvent.click(folderNode);
|
||||||
|
|
||||||
|
// Should not call handleFileSelect for folders
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockHandleFileSelect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
323
app/src/components/files/FileTree.tsx
Normal file
323
app/src/components/files/FileTree.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import React, { useRef, useState, useLayoutEffect, useCallback } from 'react';
|
||||||
|
import { Tree, type NodeApi } from 'react-arborist';
|
||||||
|
import {
|
||||||
|
IconFile,
|
||||||
|
IconFolder,
|
||||||
|
IconFolderOpen,
|
||||||
|
IconUpload,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { Tooltip, Text, Box } from '@mantine/core';
|
||||||
|
import useResizeObserver from '@react-hook/resize-observer';
|
||||||
|
import { useFileOperations } from '../../hooks/useFileOperations';
|
||||||
|
import type { FileNode } from '@/types/models';
|
||||||
|
|
||||||
|
interface Size {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileTreeProps {
|
||||||
|
files: FileNode[];
|
||||||
|
handleFileSelect: (filePath: string | null) => Promise<void>;
|
||||||
|
showHiddenFiles: boolean;
|
||||||
|
loadFileList: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useSize = (target: React.RefObject<HTMLElement>): Size | undefined => {
|
||||||
|
const [size, setSize] = useState<Size>();
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (target.current) {
|
||||||
|
setSize(target.current.getBoundingClientRect());
|
||||||
|
}
|
||||||
|
}, [target]);
|
||||||
|
|
||||||
|
useResizeObserver(target, (entry) => setSize(entry.contentRect));
|
||||||
|
return size;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileIcon = ({ node }: { node: NodeApi<FileNode> }) => {
|
||||||
|
if (node.isLeaf) {
|
||||||
|
return <IconFile size={16} />;
|
||||||
|
}
|
||||||
|
return node.isOpen ? (
|
||||||
|
<IconFolderOpen size={16} color="var(--mantine-color-yellow-filled)" />
|
||||||
|
) : (
|
||||||
|
<IconFolder size={16} color="var(--mantine-color-yellow-filled)" />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enhanced Node component with drag handle
|
||||||
|
function Node({
|
||||||
|
node,
|
||||||
|
style,
|
||||||
|
dragHandle,
|
||||||
|
onNodeClick,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
node: NodeApi<FileNode>;
|
||||||
|
style: React.CSSProperties;
|
||||||
|
dragHandle?: React.Ref<HTMLDivElement>;
|
||||||
|
onNodeClick?: (node: NodeApi<FileNode>) => void;
|
||||||
|
} & Record<string, unknown>) {
|
||||||
|
const handleClick = () => {
|
||||||
|
if (node.isInternal) {
|
||||||
|
node.toggle();
|
||||||
|
} else if (typeof onNodeClick === 'function') {
|
||||||
|
onNodeClick(node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={node.data.name} openDelay={500}>
|
||||||
|
<div
|
||||||
|
ref={dragHandle} // This enables dragging for the node
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
paddingLeft: `${node.level * 20}px`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
// Add visual feedback when being dragged
|
||||||
|
opacity: node.state?.isDragging ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<FileIcon node={node} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
flexGrow: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{node.data.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to recursively find file paths by IDs
|
||||||
|
const findFilePathsById = (files: FileNode[], ids: string[]): string[] => {
|
||||||
|
const paths: string[] = [];
|
||||||
|
|
||||||
|
const searchFiles = (nodes: FileNode[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (ids.includes(node.id)) {
|
||||||
|
paths.push(node.path);
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
searchFiles(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
searchFiles(files);
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to find parent path by ID
|
||||||
|
const findParentPathById = (
|
||||||
|
files: FileNode[],
|
||||||
|
parentId: string | null
|
||||||
|
): string => {
|
||||||
|
if (!parentId) return '';
|
||||||
|
|
||||||
|
const searchFiles = (nodes: FileNode[]): string | null => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.id === parentId) {
|
||||||
|
return node.path;
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
const result = searchFiles(node.children);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return searchFiles(files) || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileTree: React.FC<FileTreeProps> = ({
|
||||||
|
files,
|
||||||
|
handleFileSelect,
|
||||||
|
showHiddenFiles,
|
||||||
|
loadFileList,
|
||||||
|
}) => {
|
||||||
|
const target = useRef<HTMLDivElement>(null);
|
||||||
|
const size = useSize(target);
|
||||||
|
const { handleMove, handleUpload } = useFileOperations();
|
||||||
|
|
||||||
|
// State for drag and drop overlay
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
|
const filteredFiles = files.filter((file) => {
|
||||||
|
if (file.name.startsWith('.') && !showHiddenFiles) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handler for node click
|
||||||
|
const onNodeClick = (node: NodeApi<FileNode>) => {
|
||||||
|
const fileNode = node.data;
|
||||||
|
if (!node.isInternal) {
|
||||||
|
void handleFileSelect(fileNode.path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle file movement within the tree
|
||||||
|
const handleTreeMove = useCallback(
|
||||||
|
async ({
|
||||||
|
dragIds,
|
||||||
|
parentId,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
dragIds: string[];
|
||||||
|
parentId: string | null;
|
||||||
|
index: number;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
// Map dragged file IDs to their corresponding paths
|
||||||
|
const dragPaths = findFilePathsById(filteredFiles, dragIds);
|
||||||
|
|
||||||
|
// Find the parent path where files will be moved
|
||||||
|
const targetParentPath = findParentPathById(filteredFiles, parentId);
|
||||||
|
|
||||||
|
// Move files to the new location
|
||||||
|
const success = await handleMove(dragPaths, targetParentPath, index);
|
||||||
|
if (success) {
|
||||||
|
await loadFileList();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error moving files:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleMove, loadFileList, filteredFiles]
|
||||||
|
);
|
||||||
|
|
||||||
|
// External file drag and drop handlers
|
||||||
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Check if drag contains files (not internal tree nodes)
|
||||||
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
|
setIsDragOver(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Only hide overlay when leaving the container itself
|
||||||
|
if (e.currentTarget === e.target) {
|
||||||
|
setIsDragOver(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
// Set the drop effect to indicate this is a valid drop target
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
|
const { files } = e.dataTransfer;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
const uploadFiles = async () => {
|
||||||
|
try {
|
||||||
|
const success = await handleUpload(files);
|
||||||
|
if (success) {
|
||||||
|
await loadFileList();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading files:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void uploadFiles();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleUpload, loadFileList]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={target}
|
||||||
|
style={{
|
||||||
|
height: 'calc(100vh - 140px)',
|
||||||
|
marginTop: '20px',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{/* Drag overlay */}
|
||||||
|
{isDragOver && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 123, 255, 0.1)',
|
||||||
|
border: '2px dashed var(--mantine-color-blue-6)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconUpload size={48} color="var(--mantine-color-blue-6)" />
|
||||||
|
<Text size="lg" fw={500} c="blue" mt="md">
|
||||||
|
Drop files here to upload
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{size && (
|
||||||
|
<Tree
|
||||||
|
data={filteredFiles}
|
||||||
|
openByDefault={false}
|
||||||
|
width={size.width}
|
||||||
|
height={size.height}
|
||||||
|
indent={24}
|
||||||
|
rowHeight={28}
|
||||||
|
onMove={handleTreeMove}
|
||||||
|
onActivate={(node) => {
|
||||||
|
const fileNode = node.data;
|
||||||
|
if (!node.isInternal) {
|
||||||
|
void handleFileSelect(fileNode.path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(props) => <Node {...props} onNodeClick={onNodeClick} />}
|
||||||
|
</Tree>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileTree;
|
||||||
62
app/src/components/layout/Header.test.tsx
Normal file
62
app/src/components/layout/Header.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render } from '../../test/utils';
|
||||||
|
import Header from './Header';
|
||||||
|
|
||||||
|
// Mock the child components
|
||||||
|
vi.mock('../navigation/UserMenu', () => ({
|
||||||
|
default: () => <div data-testid="user-menu">User Menu</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../navigation/WorkspaceSwitcher', () => ({
|
||||||
|
default: () => <div data-testid="workspace-switcher">Workspace Switcher</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../settings/workspace/WorkspaceSettings', () => ({
|
||||||
|
default: () => <div data-testid="workspace-settings">Workspace Settings</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('Header', () => {
|
||||||
|
it('renders the app title', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<Header />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Lemma')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders user menu component', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<Header />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('user-menu')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders workspace switcher component', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<Header />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('workspace-switcher')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders workspace settings component', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<Header />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('workspace-settings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,11 +4,11 @@ import UserMenu from '../navigation/UserMenu';
|
|||||||
import WorkspaceSwitcher from '../navigation/WorkspaceSwitcher';
|
import WorkspaceSwitcher from '../navigation/WorkspaceSwitcher';
|
||||||
import WorkspaceSettings from '../settings/workspace/WorkspaceSettings';
|
import WorkspaceSettings from '../settings/workspace/WorkspaceSettings';
|
||||||
|
|
||||||
const Header = () => {
|
const Header: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between" h={60} px="md">
|
<Group justify="space-between" h={60} px="md">
|
||||||
<Text fw={700} size="lg">
|
<Text fw={700} size="lg">
|
||||||
NovaMD
|
Lemma
|
||||||
</Text>
|
</Text>
|
||||||
<Group>
|
<Group>
|
||||||
<WorkspaceSwitcher />
|
<WorkspaceSwitcher />
|
||||||
156
app/src/components/layout/Layout.test.tsx
Normal file
156
app/src/components/layout/Layout.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render } from '../../test/utils';
|
||||||
|
import Layout from './Layout';
|
||||||
|
import { Theme, type FileNode } from '../../types/models';
|
||||||
|
|
||||||
|
// Mock child components
|
||||||
|
vi.mock('./Header', () => ({
|
||||||
|
default: () => <div data-testid="header">Header</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./Sidebar', () => ({
|
||||||
|
default: () => <div data-testid="sidebar">Sidebar</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./MainContent', () => ({
|
||||||
|
default: () => <div data-testid="main-content">Main Content</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock hooks
|
||||||
|
vi.mock('../../hooks/useFileNavigation', () => ({
|
||||||
|
useFileNavigation: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useFileList', () => ({
|
||||||
|
useFileList: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useWorkspace', () => ({
|
||||||
|
useWorkspace: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('Layout', () => {
|
||||||
|
const mockHandleFileSelect = vi.fn();
|
||||||
|
const mockLoadFileList = vi.fn();
|
||||||
|
|
||||||
|
const mockCurrentWorkspace = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Workspace',
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
gitEnabled: true,
|
||||||
|
gitAutoCommit: false,
|
||||||
|
theme: Theme.Light,
|
||||||
|
autoSave: true,
|
||||||
|
showHiddenFiles: false,
|
||||||
|
gitUrl: '',
|
||||||
|
gitBranch: 'main',
|
||||||
|
gitUsername: '',
|
||||||
|
gitEmail: '',
|
||||||
|
gitToken: '',
|
||||||
|
gitUser: '',
|
||||||
|
gitCommitMsgTemplate: '',
|
||||||
|
gitCommitName: '',
|
||||||
|
gitCommitEmail: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFiles: FileNode[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'README.md',
|
||||||
|
path: 'README.md',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: mockCurrentWorkspace,
|
||||||
|
workspaces: [],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: false,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: vi.fn(),
|
||||||
|
deleteCurrentWorkspace: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { useFileNavigation } = await import('../../hooks/useFileNavigation');
|
||||||
|
vi.mocked(useFileNavigation).mockReturnValue({
|
||||||
|
selectedFile: 'README.md',
|
||||||
|
isNewFile: false,
|
||||||
|
handleFileSelect: mockHandleFileSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { useFileList } = await import('../../hooks/useFileList');
|
||||||
|
vi.mocked(useFileList).mockReturnValue({
|
||||||
|
files: mockFiles,
|
||||||
|
loadFileList: mockLoadFileList,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all layout components when workspace is loaded', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<Layout />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('header')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('sidebar')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('main-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading spinner when workspace is loading', async () => {
|
||||||
|
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: mockCurrentWorkspace,
|
||||||
|
workspaces: [],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: true,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: vi.fn(),
|
||||||
|
deleteCurrentWorkspace: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByRole } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<Layout />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getByRole('status', { name: 'Loading workspace' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no workspace message when no current workspace', async () => {
|
||||||
|
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: null,
|
||||||
|
workspaces: [],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: false,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: vi.fn(),
|
||||||
|
deleteCurrentWorkspace: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByText } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<Layout />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getByText('No workspace found. Please create a workspace.')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,16 +5,20 @@ import Sidebar from './Sidebar';
|
|||||||
import MainContent from './MainContent';
|
import MainContent from './MainContent';
|
||||||
import { useFileNavigation } from '../../hooks/useFileNavigation';
|
import { useFileNavigation } from '../../hooks/useFileNavigation';
|
||||||
import { useFileList } from '../../hooks/useFileList';
|
import { useFileList } from '../../hooks/useFileList';
|
||||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
import { useWorkspace } from '../../hooks/useWorkspace';
|
||||||
|
|
||||||
const Layout = () => {
|
const Layout: React.FC = () => {
|
||||||
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
|
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
|
||||||
const { selectedFile, handleFileSelect } = useFileNavigation();
|
const { selectedFile, handleFileSelect } = useFileNavigation();
|
||||||
const { files, loadFileList } = useFileList();
|
const { files, loadFileList } = useFileList();
|
||||||
|
|
||||||
if (workspaceLoading) {
|
if (workspaceLoading) {
|
||||||
return (
|
return (
|
||||||
<Center style={{ height: '100vh' }}>
|
<Center
|
||||||
|
style={{ height: '100vh' }}
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading workspace"
|
||||||
|
>
|
||||||
<Loader size="xl" />
|
<Loader size="xl" />
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
198
app/src/components/layout/MainContent.test.tsx
Normal file
198
app/src/components/layout/MainContent.test.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render } from '../../test/utils';
|
||||||
|
import MainContent from './MainContent';
|
||||||
|
import { ModalProvider } from '../../contexts/ModalContext';
|
||||||
|
import { ThemeProvider } from '../../contexts/ThemeContext';
|
||||||
|
import { WorkspaceDataProvider } from '../../contexts/WorkspaceDataContext';
|
||||||
|
|
||||||
|
// Mock child components
|
||||||
|
vi.mock('../editor/ContentView', () => ({
|
||||||
|
default: ({
|
||||||
|
activeTab,
|
||||||
|
selectedFile,
|
||||||
|
}: {
|
||||||
|
activeTab: string;
|
||||||
|
selectedFile: string | null;
|
||||||
|
}) => (
|
||||||
|
<div data-testid="content-view">
|
||||||
|
Content View - {activeTab} - {selectedFile || 'No file'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../modals/file/CreateFileModal', () => ({
|
||||||
|
default: () => <div data-testid="create-file-modal">Create File Modal</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../modals/file/DeleteFileModal', () => ({
|
||||||
|
default: () => <div data-testid="delete-file-modal">Delete File Modal</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../modals/git/CommitMessageModal', () => ({
|
||||||
|
default: () => (
|
||||||
|
<div data-testid="commit-message-modal">Commit Message Modal</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock contexts
|
||||||
|
vi.mock('../../contexts/ThemeContext', () => ({
|
||||||
|
useTheme: () => ({
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
}),
|
||||||
|
ThemeProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../contexts/WorkspaceDataContext', () => ({
|
||||||
|
useWorkspaceData: () => ({
|
||||||
|
currentWorkspace: { name: 'test-workspace', path: '/test' },
|
||||||
|
workspaces: [],
|
||||||
|
settings: {},
|
||||||
|
loading: false,
|
||||||
|
loadWorkspaces: vi.fn(),
|
||||||
|
loadWorkspaceData: vi.fn(),
|
||||||
|
setCurrentWorkspace: vi.fn(),
|
||||||
|
}),
|
||||||
|
WorkspaceDataProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock hooks
|
||||||
|
vi.mock('../../hooks/useFileContent', () => ({
|
||||||
|
useFileContent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useFileOperations', () => ({
|
||||||
|
useFileOperations: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useGitOperations', () => ({
|
||||||
|
useGitOperations: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<ThemeProvider>
|
||||||
|
<WorkspaceDataProvider>
|
||||||
|
<ModalProvider>{children}</ModalProvider>
|
||||||
|
</WorkspaceDataProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('MainContent', () => {
|
||||||
|
const mockHandleFileSelect = vi.fn();
|
||||||
|
const mockLoadFileList = vi.fn();
|
||||||
|
const mockHandleContentChange = vi.fn();
|
||||||
|
const mockSetHasUnsavedChanges = vi.fn();
|
||||||
|
const mockHandleSave = vi.fn();
|
||||||
|
const mockHandleCreate = vi.fn();
|
||||||
|
const mockHandleDelete = vi.fn();
|
||||||
|
const mockHandleUpload = vi.fn();
|
||||||
|
const mockHandleMove = vi.fn();
|
||||||
|
const mockHandleRename = vi.fn();
|
||||||
|
const mockHandleCommitAndPush = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const { useFileContent } = await import('../../hooks/useFileContent');
|
||||||
|
vi.mocked(useFileContent).mockReturnValue({
|
||||||
|
content: 'Test content',
|
||||||
|
setContent: vi.fn(),
|
||||||
|
hasUnsavedChanges: false,
|
||||||
|
setHasUnsavedChanges: mockSetHasUnsavedChanges,
|
||||||
|
loadFileContent: vi.fn(),
|
||||||
|
handleContentChange: mockHandleContentChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { useFileOperations } = await import('../../hooks/useFileOperations');
|
||||||
|
vi.mocked(useFileOperations).mockReturnValue({
|
||||||
|
handleSave: mockHandleSave,
|
||||||
|
handleCreate: mockHandleCreate,
|
||||||
|
handleDelete: mockHandleDelete,
|
||||||
|
handleUpload: mockHandleUpload,
|
||||||
|
handleMove: mockHandleMove,
|
||||||
|
handleRename: mockHandleRename,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { useGitOperations } = await import('../../hooks/useGitOperations');
|
||||||
|
vi.mocked(useGitOperations).mockReturnValue({
|
||||||
|
handlePull: vi.fn(),
|
||||||
|
handleCommitAndPush: mockHandleCommitAndPush,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows breadcrumbs for selected file', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<MainContent
|
||||||
|
selectedFile="docs/guide.md"
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('docs')).toBeInTheDocument();
|
||||||
|
expect(getByText('guide.md')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows unsaved changes indicator when file has changes', async () => {
|
||||||
|
const { useFileContent } = await import('../../hooks/useFileContent');
|
||||||
|
vi.mocked(useFileContent).mockReturnValue({
|
||||||
|
content: 'Test content',
|
||||||
|
setContent: vi.fn(),
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
setHasUnsavedChanges: mockSetHasUnsavedChanges,
|
||||||
|
loadFileContent: vi.fn(),
|
||||||
|
handleContentChange: mockHandleContentChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<MainContent
|
||||||
|
selectedFile="test.md"
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show unsaved changes indicator (yellow dot)
|
||||||
|
const indicator = container.querySelector('svg[style*="yellow"]');
|
||||||
|
expect(indicator).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all modal components', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<MainContent
|
||||||
|
selectedFile="test.md"
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('create-file-modal')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('delete-file-modal')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('commit-message-modal')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles no selected file', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<MainContent
|
||||||
|
selectedFile={null}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentView = getByTestId('content-view');
|
||||||
|
expect(contentView).toHaveTextContent('Content View - source - No file');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,32 +5,48 @@ import { IconCode, IconEye, IconPointFilled } from '@tabler/icons-react';
|
|||||||
import ContentView from '../editor/ContentView';
|
import ContentView from '../editor/ContentView';
|
||||||
import CreateFileModal from '../modals/file/CreateFileModal';
|
import CreateFileModal from '../modals/file/CreateFileModal';
|
||||||
import DeleteFileModal from '../modals/file/DeleteFileModal';
|
import DeleteFileModal from '../modals/file/DeleteFileModal';
|
||||||
|
import RenameFileModal from '../modals/file/RenameFileModal';
|
||||||
import CommitMessageModal from '../modals/git/CommitMessageModal';
|
import CommitMessageModal from '../modals/git/CommitMessageModal';
|
||||||
|
|
||||||
import { useFileContent } from '../../hooks/useFileContent';
|
import { useFileContent } from '../../hooks/useFileContent';
|
||||||
import { useFileOperations } from '../../hooks/useFileOperations';
|
import { useFileOperations } from '../../hooks/useFileOperations';
|
||||||
import { useGitOperations } from '../../hooks/useGitOperations';
|
import { useGitOperations } from '../../hooks/useGitOperations';
|
||||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
import { useModalContext } from '../../contexts/ModalContext';
|
||||||
|
|
||||||
const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => {
|
type ViewTab = 'source' | 'preview';
|
||||||
const [activeTab, setActiveTab] = useState('source');
|
|
||||||
const { settings } = useWorkspace();
|
interface MainContentProps {
|
||||||
|
selectedFile: string | null;
|
||||||
|
handleFileSelect: (filePath: string | null) => Promise<void>;
|
||||||
|
loadFileList: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MainContent: React.FC<MainContentProps> = ({
|
||||||
|
selectedFile,
|
||||||
|
handleFileSelect,
|
||||||
|
loadFileList,
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<ViewTab>('source');
|
||||||
const {
|
const {
|
||||||
content,
|
content,
|
||||||
hasUnsavedChanges,
|
hasUnsavedChanges,
|
||||||
setHasUnsavedChanges,
|
setHasUnsavedChanges,
|
||||||
handleContentChange,
|
handleContentChange,
|
||||||
} = useFileContent(selectedFile);
|
} = useFileContent(selectedFile);
|
||||||
const { handleSave, handleCreate, handleDelete } = useFileOperations();
|
const { handleSave, handleCreate, handleDelete, handleRename } =
|
||||||
const { handleCommitAndPush } = useGitOperations(settings.gitEnabled);
|
useFileOperations();
|
||||||
|
const { handleCommitAndPush } = useGitOperations();
|
||||||
|
const { setRenameFileModalVisible } = useModalContext();
|
||||||
|
|
||||||
const handleTabChange = useCallback((value) => {
|
const handleTabChange = useCallback((value: string | null): void => {
|
||||||
setActiveTab(value);
|
if (value) {
|
||||||
|
setActiveTab(value as ViewTab);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSaveFile = useCallback(
|
const handleSaveFile = useCallback(
|
||||||
async (filePath, content) => {
|
async (filePath: string, fileContent: string): Promise<boolean> => {
|
||||||
let success = await handleSave(filePath, content);
|
const success = await handleSave(filePath, fileContent);
|
||||||
if (success) {
|
if (success) {
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
}
|
}
|
||||||
@@ -40,35 +56,71 @@ const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateFile = useCallback(
|
const handleCreateFile = useCallback(
|
||||||
async (fileName) => {
|
async (fileName: string): Promise<void> => {
|
||||||
const success = await handleCreate(fileName);
|
const success = await handleCreate(fileName);
|
||||||
if (success) {
|
if (success) {
|
||||||
loadFileList();
|
await loadFileList();
|
||||||
handleFileSelect(fileName);
|
await handleFileSelect(fileName);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleCreate, handleFileSelect, loadFileList]
|
[handleCreate, handleFileSelect, loadFileList]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteFile = useCallback(
|
const handleDeleteFile = useCallback(
|
||||||
async (filePath) => {
|
async (filePath: string): Promise<void> => {
|
||||||
const success = await handleDelete(filePath);
|
const success = await handleDelete(filePath);
|
||||||
if (success) {
|
if (success) {
|
||||||
loadFileList();
|
await loadFileList();
|
||||||
handleFileSelect(null);
|
await handleFileSelect(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleDelete, handleFileSelect, loadFileList]
|
[handleDelete, handleFileSelect, loadFileList]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleRenameFile = useCallback(
|
||||||
|
async (oldPath: string, newPath: string): Promise<void> => {
|
||||||
|
const success = await handleRename(oldPath, newPath);
|
||||||
|
if (success) {
|
||||||
|
await loadFileList();
|
||||||
|
// If we renamed the currently selected file, update the selection
|
||||||
|
if (selectedFile === oldPath) {
|
||||||
|
await handleFileSelect(newPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleRename, handleFileSelect, loadFileList, selectedFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBreadcrumbClick = useCallback(() => {
|
||||||
|
if (selectedFile) {
|
||||||
|
setRenameFileModalVisible(true);
|
||||||
|
}
|
||||||
|
}, [selectedFile, setRenameFileModalVisible]);
|
||||||
|
|
||||||
const renderBreadcrumbs = useMemo(() => {
|
const renderBreadcrumbs = useMemo(() => {
|
||||||
if (!selectedFile) return null;
|
if (!selectedFile) return null;
|
||||||
const pathParts = selectedFile.split('/');
|
const pathParts = selectedFile.split('/');
|
||||||
const items = pathParts.map((part, index) => (
|
const items = pathParts.map((part, index) => {
|
||||||
<Text key={index} size="sm">
|
// Make the filename (last part) clickable for rename
|
||||||
{part}
|
const isFileName = index === pathParts.length - 1;
|
||||||
</Text>
|
return (
|
||||||
));
|
<Text
|
||||||
|
key={index}
|
||||||
|
size="sm"
|
||||||
|
style={{
|
||||||
|
cursor: isFileName ? 'pointer' : 'default',
|
||||||
|
...(isFileName && {
|
||||||
|
textDecoration: 'underline',
|
||||||
|
textDecorationStyle: 'dotted',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
onClick={isFileName ? handleBreadcrumbClick : undefined}
|
||||||
|
title={isFileName ? 'Click to rename file' : undefined}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group>
|
<Group>
|
||||||
@@ -81,7 +133,7 @@ const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => {
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}, [selectedFile, hasUnsavedChanges]);
|
}, [selectedFile, hasUnsavedChanges, handleBreadcrumbClick]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -116,6 +168,10 @@ const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => {
|
|||||||
onDeleteFile={handleDeleteFile}
|
onDeleteFile={handleDeleteFile}
|
||||||
selectedFile={selectedFile}
|
selectedFile={selectedFile}
|
||||||
/>
|
/>
|
||||||
|
<RenameFileModal
|
||||||
|
onRenameFile={handleRenameFile}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
/>
|
||||||
<CommitMessageModal onCommitAndPush={handleCommitAndPush} />
|
<CommitMessageModal onCommitAndPush={handleCommitAndPush} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import { Box } from '@mantine/core';
|
|
||||||
import FileActions from '../files/FileActions';
|
|
||||||
import FileTree from '../files/FileTree';
|
|
||||||
import { useGitOperations } from '../../hooks/useGitOperations';
|
|
||||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
|
||||||
|
|
||||||
const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => {
|
|
||||||
const { settings } = useWorkspace();
|
|
||||||
const { handlePull } = useGitOperations(settings.gitEnabled);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadFileList();
|
|
||||||
}, [loadFileList]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: '25%',
|
|
||||||
minWidth: '200px',
|
|
||||||
maxWidth: '300px',
|
|
||||||
borderRight: '1px solid var(--app-shell-border-color)',
|
|
||||||
height: '100%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FileActions handlePullChanges={handlePull} selectedFile={selectedFile} />
|
|
||||||
<FileTree
|
|
||||||
files={files}
|
|
||||||
handleFileSelect={handleFileSelect}
|
|
||||||
showHiddenFiles={settings.showHiddenFiles}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Sidebar;
|
|
||||||
182
app/src/components/layout/Sidebar.test.tsx
Normal file
182
app/src/components/layout/Sidebar.test.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render } from '../../test/utils';
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
|
import { Theme, type FileNode } from '../../types/models';
|
||||||
|
|
||||||
|
// Mock the child components
|
||||||
|
vi.mock('../files/FileActions', () => ({
|
||||||
|
default: ({ selectedFile }: { selectedFile: string | null }) => (
|
||||||
|
<div data-testid="file-actions">
|
||||||
|
File Actions - {selectedFile || 'No file'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../files/FileTree', () => ({
|
||||||
|
default: ({
|
||||||
|
files,
|
||||||
|
showHiddenFiles,
|
||||||
|
}: {
|
||||||
|
files: FileNode[];
|
||||||
|
showHiddenFiles: boolean;
|
||||||
|
}) => (
|
||||||
|
<div data-testid="file-tree">
|
||||||
|
File Tree - {files.length} files - Hidden: {showHiddenFiles.toString()}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the hooks
|
||||||
|
vi.mock('../../hooks/useGitOperations', () => ({
|
||||||
|
useGitOperations: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useWorkspace', () => ({
|
||||||
|
useWorkspace: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('Sidebar', () => {
|
||||||
|
const mockHandleFileSelect = vi.fn();
|
||||||
|
const mockLoadFileList = vi.fn();
|
||||||
|
const mockHandlePull = vi.fn();
|
||||||
|
|
||||||
|
const mockFiles: FileNode[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'README.md',
|
||||||
|
path: 'README.md',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'docs',
|
||||||
|
path: 'docs',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockCurrentWorkspace = {
|
||||||
|
id: 1,
|
||||||
|
name: 'test-workspace',
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
gitEnabled: true,
|
||||||
|
gitAutoCommit: false,
|
||||||
|
theme: Theme.Light,
|
||||||
|
autoSave: true,
|
||||||
|
showHiddenFiles: false,
|
||||||
|
gitUrl: '',
|
||||||
|
gitBranch: 'main',
|
||||||
|
gitUsername: '',
|
||||||
|
gitEmail: '',
|
||||||
|
gitToken: '',
|
||||||
|
gitUser: '',
|
||||||
|
gitCommitMsgTemplate: '',
|
||||||
|
gitCommitName: '',
|
||||||
|
gitCommitEmail: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const { useGitOperations } = await import('../../hooks/useGitOperations');
|
||||||
|
vi.mocked(useGitOperations).mockReturnValue({
|
||||||
|
handlePull: mockHandlePull,
|
||||||
|
handleCommitAndPush: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: null,
|
||||||
|
workspaces: [],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: false,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: vi.fn(),
|
||||||
|
deleteCurrentWorkspace: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders child components', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<Sidebar
|
||||||
|
selectedFile="test.md"
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={mockFiles}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileActions = getByTestId('file-actions');
|
||||||
|
expect(fileActions).toBeInTheDocument();
|
||||||
|
expect(fileActions).toHaveTextContent('File Actions - test.md');
|
||||||
|
|
||||||
|
const fileTree = getByTestId('file-tree');
|
||||||
|
expect(fileTree).toBeInTheDocument();
|
||||||
|
expect(fileTree).toHaveTextContent('File Tree - 2 files - Hidden: false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes showHiddenFiles setting to file tree', async () => {
|
||||||
|
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: { ...mockCurrentWorkspace, showHiddenFiles: true },
|
||||||
|
workspaces: [],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: false,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: vi.fn(),
|
||||||
|
deleteCurrentWorkspace: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<Sidebar
|
||||||
|
selectedFile="test.md"
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={mockFiles}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileTree = getByTestId('file-tree');
|
||||||
|
expect(fileTree).toHaveTextContent('File Tree - 2 files - Hidden: true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no file selected when selectedFile is null', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<Sidebar
|
||||||
|
selectedFile={null}
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={mockFiles}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileActions = getByTestId('file-actions');
|
||||||
|
expect(fileActions).toHaveTextContent('File Actions - No file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls loadFileList on mount', () => {
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<Sidebar
|
||||||
|
selectedFile="test.md"
|
||||||
|
handleFileSelect={mockHandleFileSelect}
|
||||||
|
files={mockFiles}
|
||||||
|
loadFileList={mockLoadFileList}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockLoadFileList).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
55
app/src/components/layout/Sidebar.tsx
Normal file
55
app/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Box } from '@mantine/core';
|
||||||
|
import FileActions from '../files/FileActions';
|
||||||
|
import FileTree from '../files/FileTree';
|
||||||
|
import { useGitOperations } from '../../hooks/useGitOperations';
|
||||||
|
import { useWorkspace } from '../../hooks/useWorkspace';
|
||||||
|
import type { FileNode } from '@/types/models';
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
selectedFile: string | null;
|
||||||
|
handleFileSelect: (filePath: string | null) => Promise<void>;
|
||||||
|
files: FileNode[];
|
||||||
|
loadFileList: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Sidebar: React.FC<SidebarProps> = ({
|
||||||
|
selectedFile,
|
||||||
|
handleFileSelect,
|
||||||
|
files,
|
||||||
|
loadFileList,
|
||||||
|
}) => {
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const { handlePull } = useGitOperations();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadFileList();
|
||||||
|
}, [loadFileList]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: '25%',
|
||||||
|
minWidth: '200px',
|
||||||
|
maxWidth: '300px',
|
||||||
|
borderRight: '1px solid var(--app-shell-border-color)',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileActions
|
||||||
|
handlePullChanges={handlePull}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
loadFileList={loadFileList}
|
||||||
|
/>
|
||||||
|
<FileTree
|
||||||
|
files={files}
|
||||||
|
handleFileSelect={handleFileSelect}
|
||||||
|
showHiddenFiles={currentWorkspace?.showHiddenFiles || false}
|
||||||
|
loadFileList={loadFileList}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
PasswordInput,
|
|
||||||
Group,
|
|
||||||
Button,
|
|
||||||
} from '@mantine/core';
|
|
||||||
|
|
||||||
const DeleteAccountModal = ({ opened, onClose, onConfirm }) => {
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
title="Delete Account"
|
|
||||||
centered
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<Text c="red" fw={500}>
|
|
||||||
Warning: This action cannot be undone
|
|
||||||
</Text>
|
|
||||||
<Text size="sm">
|
|
||||||
Please enter your password to confirm account deletion.
|
|
||||||
</Text>
|
|
||||||
<PasswordInput
|
|
||||||
label="Current Password"
|
|
||||||
placeholder="Enter your current password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button variant="default" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
onClick={() => {
|
|
||||||
onConfirm(password);
|
|
||||||
setPassword('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete Account
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DeleteAccountModal;
|
|
||||||
303
app/src/components/modals/account/DeleteAccountModal.test.tsx
Normal file
303
app/src/components/modals/account/DeleteAccountModal.test.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
act,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import DeleteAccountModal from './DeleteAccountModal';
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DeleteAccountModal', () => {
|
||||||
|
const mockOnConfirm = vi.fn();
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockOnConfirm.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal Visibility and Content', () => {
|
||||||
|
it('renders modal with correct content when opened', () => {
|
||||||
|
render(
|
||||||
|
<DeleteAccountModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Delete Account')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('Warning: This action cannot be undone')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Please enter your password to confirm account deletion.'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('delete-account-password-input')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('cancel-delete-account-button')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('confirm-delete-account-button')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
render(
|
||||||
|
<DeleteAccountModal
|
||||||
|
opened={false}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Delete Account')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form Validation', () => {
|
||||||
|
it('updates password value when user types', () => {
|
||||||
|
render(
|
||||||
|
<DeleteAccountModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'testpassword123' } });
|
||||||
|
|
||||||
|
expect((passwordInput as HTMLInputElement).value).toBe('testpassword123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents submission with empty or whitespace-only password', () => {
|
||||||
|
render(
|
||||||
|
<DeleteAccountModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||||
|
const deleteButton = screen.getByTestId('confirm-delete-account-button');
|
||||||
|
|
||||||
|
// Test empty password
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Test whitespace-only password
|
||||||
|
fireEvent.change(passwordInput, { target: { value: ' ' } });
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Actions', () => {
|
||||||
|
it('calls onConfirm with valid password and clears field on success', async () => {
|
||||||
|
render(
|
||||||
|
<DeleteAccountModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||||
|
const deleteButton = screen.getByTestId('confirm-delete-account-button');
|
||||||
|
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'validpassword' } });
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledWith('validpassword');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect((passwordInput as HTMLInputElement).value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when cancel button is clicked', () => {
|
||||||
|
render(
|
||||||
|
<DeleteAccountModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('cancel-delete-account-button'));
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves password in field when submission fails', async () => {
|
||||||
|
mockOnConfirm.mockRejectedValue(new Error('Invalid password'));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DeleteAccountModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||||
|
const deleteButton = screen.getByTestId('confirm-delete-account-button');
|
||||||
|
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } });
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledWith('wrongpassword');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((passwordInput as HTMLInputElement).value).toBe('wrongpassword');
|
||||||
|
expect(screen.getByText('Delete Account')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Actions', () => {
|
||||||
|
it('closes modal when cancel button is clicked', () => {
|
||||||
|
render(
|
||||||
|
<DeleteAccountModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelButton = screen.getByTestId('cancel-delete-account-button');
|
||||||
|
fireEvent.click(cancelButton);
|
||||||
|
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles rapid multiple clicks gracefully', async () => {
|
||||||
|
render(
|
||||||
|
<DeleteAccountModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||||
|
const deleteButton = screen.getByTestId('confirm-delete-account-button');
|
||||||
|
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'testpassword' } });
|
||||||
|
|
||||||
|
// Multiple rapid clicks should not break the component
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Delete Account')).toBeInTheDocument();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledWith('testpassword');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility and Security', () => {
|
||||||
|
it('has proper form structure and security attributes', () => {
|
||||||
|
render(
|
||||||
|
<DeleteAccountModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||||
|
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||||
|
expect(passwordInput).toHaveAttribute('required');
|
||||||
|
expect(passwordInput).toHaveAccessibleName();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /cancel/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /delete/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Complete User Flows', () => {
|
||||||
|
it('completes successful account deletion flow', async () => {
|
||||||
|
render(
|
||||||
|
<DeleteAccountModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. User sees warning
|
||||||
|
expect(
|
||||||
|
screen.getByText('Warning: This action cannot be undone')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 2. User enters password
|
||||||
|
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'userpassword' } });
|
||||||
|
|
||||||
|
// 3. User confirms deletion
|
||||||
|
const deleteButton = screen.getByTestId('confirm-delete-account-button');
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
// 4. System processes deletion
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledWith('userpassword');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Password field is cleared for security
|
||||||
|
await waitFor(() => {
|
||||||
|
expect((passwordInput as HTMLInputElement).value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows cancellation of account deletion', () => {
|
||||||
|
render(
|
||||||
|
<DeleteAccountModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// User enters password but decides to cancel
|
||||||
|
const passwordInput = screen.getByTestId('delete-account-password-input');
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'somepassword' } });
|
||||||
|
|
||||||
|
const cancelButton = screen.getByTestId('cancel-delete-account-button');
|
||||||
|
fireEvent.click(cancelButton);
|
||||||
|
|
||||||
|
// Modal closes without deletion
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
82
app/src/components/modals/account/DeleteAccountModal.tsx
Normal file
82
app/src/components/modals/account/DeleteAccountModal.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
PasswordInput,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
} from '@mantine/core';
|
||||||
|
|
||||||
|
interface DeleteAccountModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (password: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
const [password, setPassword] = useState<string>('');
|
||||||
|
|
||||||
|
const handleConfirm = async (): Promise<void> => {
|
||||||
|
const trimmedPassword = password.trim();
|
||||||
|
if (!trimmedPassword) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await onConfirm(trimmedPassword);
|
||||||
|
setPassword('');
|
||||||
|
} catch (error) {
|
||||||
|
// Keep password in case of error
|
||||||
|
console.error('Error confirming password:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Delete Account"
|
||||||
|
centered
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text c="red" fw={500}>
|
||||||
|
Warning: This action cannot be undone
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
Please enter your password to confirm account deletion.
|
||||||
|
</Text>
|
||||||
|
<PasswordInput
|
||||||
|
label="Current Password"
|
||||||
|
placeholder="Enter your current password"
|
||||||
|
data-testid="delete-account-password-input"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="cancel-delete-account-button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onClick={() => void handleConfirm()}
|
||||||
|
data-testid="confirm-delete-account-button"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteAccountModal;
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
Text,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
PasswordInput,
|
|
||||||
} from '@mantine/core';
|
|
||||||
|
|
||||||
const EmailPasswordModal = ({ opened, onClose, onConfirm, email }) => {
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
title="Confirm Password"
|
|
||||||
centered
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<Text size="sm">
|
|
||||||
Please enter your password to confirm changing your email to: {email}
|
|
||||||
</Text>
|
|
||||||
<PasswordInput
|
|
||||||
label="Current Password"
|
|
||||||
placeholder="Enter your current password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button variant="default" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
onConfirm(password);
|
|
||||||
setPassword('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EmailPasswordModal;
|
|
||||||
334
app/src/components/modals/account/EmailPasswordModal.test.tsx
Normal file
334
app/src/components/modals/account/EmailPasswordModal.test.tsx
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
act,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import EmailPasswordModal from './EmailPasswordModal';
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('EmailPasswordModal', () => {
|
||||||
|
const mockOnConfirm = vi.fn();
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
const testEmail = 'newemail@example.com';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockOnConfirm.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal Visibility and Content', () => {
|
||||||
|
it('renders modal with correct content when opened', () => {
|
||||||
|
render(
|
||||||
|
<EmailPasswordModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
email={testEmail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Confirm Password')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
`Please enter your password to confirm changing your email to: ${testEmail}`
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('email-password-input')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('cancel-email-password-button')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('confirm-email-password-button')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
render(
|
||||||
|
<EmailPasswordModal
|
||||||
|
opened={false}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
email={testEmail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Confirm Password')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays various email addresses correctly', () => {
|
||||||
|
const customEmail = 'user@custom.com';
|
||||||
|
render(
|
||||||
|
<EmailPasswordModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
email={customEmail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
`Please enter your password to confirm changing your email to: ${customEmail}`
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Password Input and Validation', () => {
|
||||||
|
it('updates password value when user types', () => {
|
||||||
|
render(
|
||||||
|
<EmailPasswordModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
email={testEmail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByTestId('email-password-input');
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'testpassword123' } });
|
||||||
|
|
||||||
|
expect((passwordInput as HTMLInputElement).value).toBe('testpassword123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents submission with empty or whitespace-only password', () => {
|
||||||
|
render(
|
||||||
|
<EmailPasswordModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
email={testEmail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmButton = screen.getByTestId('confirm-email-password-button');
|
||||||
|
|
||||||
|
// Test empty password
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Test whitespace-only password
|
||||||
|
const passwordInput = screen.getByTestId('email-password-input');
|
||||||
|
fireEvent.change(passwordInput, { target: { value: ' ' } });
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits with valid password, trims whitespace, and clears field on success', async () => {
|
||||||
|
render(
|
||||||
|
<EmailPasswordModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
email={testEmail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByTestId('email-password-input');
|
||||||
|
const confirmButton = screen.getByTestId('confirm-email-password-button');
|
||||||
|
|
||||||
|
fireEvent.change(passwordInput, {
|
||||||
|
target: { value: ' validpassword ' },
|
||||||
|
});
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledWith('validpassword');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect((passwordInput as HTMLInputElement).value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves password in field when submission fails', async () => {
|
||||||
|
mockOnConfirm.mockRejectedValue(new Error('Invalid password'));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<EmailPasswordModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
email={testEmail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByTestId('email-password-input');
|
||||||
|
const confirmButton = screen.getByTestId('confirm-email-password-button');
|
||||||
|
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } });
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledWith('wrongpassword');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((passwordInput as HTMLInputElement).value).toBe('wrongpassword');
|
||||||
|
expect(screen.getByText('Confirm Password')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Actions', () => {
|
||||||
|
it('closes modal when cancel button is clicked', () => {
|
||||||
|
render(
|
||||||
|
<EmailPasswordModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
email={testEmail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelButton = screen.getByTestId('cancel-email-password-button');
|
||||||
|
fireEvent.click(cancelButton);
|
||||||
|
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits via Enter key press', async () => {
|
||||||
|
render(
|
||||||
|
<EmailPasswordModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
email={testEmail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByTestId('email-password-input');
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'enterpassword' } });
|
||||||
|
fireEvent.keyDown(passwordInput, { key: 'Enter' });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledWith('enterpassword');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles rapid multiple clicks gracefully', async () => {
|
||||||
|
render(
|
||||||
|
<EmailPasswordModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
email={testEmail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByTestId('email-password-input');
|
||||||
|
const confirmButton = screen.getByTestId('confirm-email-password-button');
|
||||||
|
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'rapidtest' } });
|
||||||
|
|
||||||
|
// Multiple rapid clicks should not break the component
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledWith('rapidtest');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility and Security', () => {
|
||||||
|
it('has proper form structure and security attributes', () => {
|
||||||
|
render(
|
||||||
|
<EmailPasswordModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
email={testEmail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByTestId('email-password-input');
|
||||||
|
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||||
|
expect(passwordInput).toHaveAttribute('required');
|
||||||
|
expect(passwordInput).toHaveAccessibleName();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /cancel/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /confirm/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Complete User Flows', () => {
|
||||||
|
it('completes successful email change confirmation flow', async () => {
|
||||||
|
render(
|
||||||
|
<EmailPasswordModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
email={testEmail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. User sees email change confirmation
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
`Please enter your password to confirm changing your email to: ${testEmail}`
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 2. User enters password
|
||||||
|
const passwordInput = screen.getByTestId('email-password-input');
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'userpassword' } });
|
||||||
|
|
||||||
|
// 3. User confirms change
|
||||||
|
const confirmButton = screen.getByTestId('confirm-email-password-button');
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
|
||||||
|
// 4. System processes confirmation
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledWith('userpassword');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Password field is cleared for security
|
||||||
|
await waitFor(() => {
|
||||||
|
expect((passwordInput as HTMLInputElement).value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows cancellation of email change', () => {
|
||||||
|
render(
|
||||||
|
<EmailPasswordModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
email={testEmail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// User enters password but decides to cancel
|
||||||
|
const passwordInput = screen.getByTestId('email-password-input');
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'somepassword' } });
|
||||||
|
|
||||||
|
const cancelButton = screen.getByTestId('cancel-email-password-button');
|
||||||
|
fireEvent.click(cancelButton);
|
||||||
|
|
||||||
|
// Modal closes without confirmation
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
89
app/src/components/modals/account/EmailPasswordModal.tsx
Normal file
89
app/src/components/modals/account/EmailPasswordModal.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
PasswordInput,
|
||||||
|
} from '@mantine/core';
|
||||||
|
|
||||||
|
interface EmailPasswordModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (password: string) => Promise<boolean>;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmailPasswordModal: React.FC<EmailPasswordModalProps> = ({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
email,
|
||||||
|
}) => {
|
||||||
|
const [password, setPassword] = useState<string>('');
|
||||||
|
|
||||||
|
async function handleConfirm(): Promise<void> {
|
||||||
|
const trimmedPassword = password.trim();
|
||||||
|
if (!trimmedPassword) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await onConfirm(trimmedPassword);
|
||||||
|
setPassword('');
|
||||||
|
} catch (error) {
|
||||||
|
// Keep password in case of error
|
||||||
|
console.error('Error confirming password:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent): void => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleConfirm();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Confirm Password"
|
||||||
|
centered
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm" data-testid="email-password-message">
|
||||||
|
{`Please enter your password to confirm changing your email to: ${email}`}
|
||||||
|
</Text>
|
||||||
|
<PasswordInput
|
||||||
|
label="Current Password"
|
||||||
|
placeholder="Enter your current password"
|
||||||
|
data-testid="email-password-input"
|
||||||
|
value={password}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="cancel-email-password-button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleConfirm()}
|
||||||
|
data-testid="confirm-email-password-button"
|
||||||
|
disabled={!password.trim()}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmailPasswordModal;
|
||||||
222
app/src/components/modals/file/CreateFileModal.test.tsx
Normal file
222
app/src/components/modals/file/CreateFileModal.test.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import CreateFileModal from './CreateFileModal';
|
||||||
|
|
||||||
|
// Mock ModalContext with modal always open
|
||||||
|
const mockModalContext = {
|
||||||
|
newFileModalVisible: true,
|
||||||
|
setNewFileModalVisible: vi.fn(),
|
||||||
|
deleteFileModalVisible: false,
|
||||||
|
setDeleteFileModalVisible: vi.fn(),
|
||||||
|
commitMessageModalVisible: false,
|
||||||
|
setCommitMessageModalVisible: vi.fn(),
|
||||||
|
settingsModalVisible: false,
|
||||||
|
setSettingsModalVisible: vi.fn(),
|
||||||
|
switchWorkspaceModalVisible: false,
|
||||||
|
setSwitchWorkspaceModalVisible: vi.fn(),
|
||||||
|
createWorkspaceModalVisible: false,
|
||||||
|
setCreateWorkspaceModalVisible: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../../contexts/ModalContext', () => ({
|
||||||
|
useModalContext: () => mockModalContext,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CreateFileModal', () => {
|
||||||
|
const mockOnCreateFile = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockOnCreateFile.mockReset();
|
||||||
|
mockOnCreateFile.mockResolvedValue(undefined);
|
||||||
|
mockModalContext.setNewFileModalVisible.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal Visibility and Content', () => {
|
||||||
|
it('renders modal with correct content when opened', () => {
|
||||||
|
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Create New File')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('file-name-input')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('cancel-create-file-button')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('confirm-create-file-button')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Actions', () => {
|
||||||
|
it('calls onClose when cancel button is clicked', () => {
|
||||||
|
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('cancel-create-file-button'));
|
||||||
|
|
||||||
|
expect(mockModalContext.setNewFileModalVisible).toHaveBeenCalledWith(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates file name input when typed', () => {
|
||||||
|
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||||
|
|
||||||
|
const fileNameInput = screen.getByTestId('file-name-input');
|
||||||
|
fireEvent.change(fileNameInput, { target: { value: 'test-file.md' } });
|
||||||
|
|
||||||
|
expect((fileNameInput as HTMLInputElement).value).toBe('test-file.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form Validation', () => {
|
||||||
|
it('has disabled create button when input is empty', () => {
|
||||||
|
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||||
|
|
||||||
|
const createButton = screen.getByTestId('confirm-create-file-button');
|
||||||
|
expect(createButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables create button when valid input is provided', () => {
|
||||||
|
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||||
|
|
||||||
|
const fileNameInput = screen.getByTestId('file-name-input');
|
||||||
|
const createButton = screen.getByTestId('confirm-create-file-button');
|
||||||
|
|
||||||
|
fireEvent.change(fileNameInput, { target: { value: 'test.md' } });
|
||||||
|
|
||||||
|
expect(createButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('File Creation Flow', () => {
|
||||||
|
it('calls onCreateFile when confirmed with valid input', async () => {
|
||||||
|
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||||
|
|
||||||
|
const fileNameInput = screen.getByTestId('file-name-input');
|
||||||
|
const createButton = screen.getByTestId('confirm-create-file-button');
|
||||||
|
|
||||||
|
fireEvent.change(fileNameInput, { target: { value: 'new-document.md' } });
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnCreateFile).toHaveBeenCalledWith('new-document.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockModalContext.setNewFileModalVisible).toHaveBeenCalledWith(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect((fileNameInput as HTMLInputElement).value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates file via Enter key press', async () => {
|
||||||
|
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||||
|
|
||||||
|
const fileNameInput = screen.getByTestId('file-name-input');
|
||||||
|
|
||||||
|
fireEvent.change(fileNameInput, { target: { value: 'enter-test.md' } });
|
||||||
|
fireEvent.keyDown(fileNameInput, { key: 'Enter', code: 'Enter' });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnCreateFile).toHaveBeenCalledWith('enter-test.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace from file names', async () => {
|
||||||
|
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||||
|
|
||||||
|
const fileNameInput = screen.getByTestId('file-name-input');
|
||||||
|
const createButton = screen.getByTestId('confirm-create-file-button');
|
||||||
|
|
||||||
|
fireEvent.change(fileNameInput, {
|
||||||
|
target: { value: ' spaced-file.md ' },
|
||||||
|
});
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnCreateFile).toHaveBeenCalledWith('spaced-file.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not submit when input is empty', () => {
|
||||||
|
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||||
|
|
||||||
|
const fileNameInput = screen.getByTestId('file-name-input');
|
||||||
|
fireEvent.keyDown(fileNameInput, { key: 'Enter', code: 'Enter' });
|
||||||
|
|
||||||
|
expect(mockOnCreateFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not submit when input contains only whitespace', () => {
|
||||||
|
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||||
|
|
||||||
|
const fileNameInput = screen.getByTestId('file-name-input');
|
||||||
|
const createButton = screen.getByTestId('confirm-create-file-button');
|
||||||
|
|
||||||
|
fireEvent.change(fileNameInput, { target: { value: ' ' } });
|
||||||
|
|
||||||
|
expect(createButton).toBeDisabled();
|
||||||
|
expect(mockOnCreateFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('File Name Variations', () => {
|
||||||
|
it.each([
|
||||||
|
['file-with_special.chars (1).md', 'special characters'],
|
||||||
|
['README', 'no extension'],
|
||||||
|
['ファイル名.md', 'unicode characters'],
|
||||||
|
['a'.repeat(100) + '.md', 'long file names'],
|
||||||
|
])('handles %s (%s)', async (fileName, _description) => {
|
||||||
|
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||||
|
|
||||||
|
const fileNameInput = screen.getByTestId('file-name-input');
|
||||||
|
const createButton = screen.getByTestId('confirm-create-file-button');
|
||||||
|
|
||||||
|
fireEvent.change(fileNameInput, { target: { value: fileName } });
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnCreateFile).toHaveBeenCalledWith(fileName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('provides proper keyboard navigation and accessibility features', () => {
|
||||||
|
render(<CreateFileModal onCreateFile={mockOnCreateFile} />);
|
||||||
|
|
||||||
|
const fileNameInput = screen.getByTestId('file-name-input');
|
||||||
|
|
||||||
|
// Input should be focusable and accessible
|
||||||
|
expect(fileNameInput).not.toHaveAttribute('disabled');
|
||||||
|
expect(fileNameInput).not.toHaveAttribute('readonly');
|
||||||
|
expect(fileNameInput).toHaveAttribute('type', 'text');
|
||||||
|
expect(fileNameInput).toHaveAccessibleName();
|
||||||
|
|
||||||
|
// Buttons should have proper roles
|
||||||
|
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||||
|
const createButton = screen.getByRole('button', { name: /create/i });
|
||||||
|
|
||||||
|
expect(cancelButton).toBeInTheDocument();
|
||||||
|
expect(createButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,18 +2,29 @@ import React, { useState } from 'react';
|
|||||||
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
|
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
|
||||||
import { useModalContext } from '../../../contexts/ModalContext';
|
import { useModalContext } from '../../../contexts/ModalContext';
|
||||||
|
|
||||||
const CreateFileModal = ({ onCreateFile }) => {
|
interface CreateFileModalProps {
|
||||||
const [fileName, setFileName] = useState('');
|
onCreateFile: (fileName: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
|
||||||
|
const [fileName, setFileName] = useState<string>('');
|
||||||
const { newFileModalVisible, setNewFileModalVisible } = useModalContext();
|
const { newFileModalVisible, setNewFileModalVisible } = useModalContext();
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (): Promise<void> => {
|
||||||
if (fileName) {
|
if (fileName) {
|
||||||
await onCreateFile(fileName);
|
await onCreateFile(fileName.trim());
|
||||||
setFileName('');
|
setFileName('');
|
||||||
setNewFileModalVisible(false);
|
setNewFileModalVisible(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent): void => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={newFileModalVisible}
|
opened={newFileModalVisible}
|
||||||
@@ -25,9 +36,12 @@ const CreateFileModal = ({ onCreateFile }) => {
|
|||||||
<Box maw={400} mx="auto">
|
<Box maw={400} mx="auto">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="File Name"
|
label="File Name"
|
||||||
|
type="text"
|
||||||
placeholder="Enter file name"
|
placeholder="Enter file name"
|
||||||
|
data-testid="file-name-input"
|
||||||
value={fileName}
|
value={fileName}
|
||||||
onChange={(event) => setFileName(event.currentTarget.value)}
|
onChange={(event) => setFileName(event.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
mb="md"
|
mb="md"
|
||||||
w="100%"
|
w="100%"
|
||||||
/>
|
/>
|
||||||
@@ -35,10 +49,17 @@ const CreateFileModal = ({ onCreateFile }) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => setNewFileModalVisible(false)}
|
onClick={() => setNewFileModalVisible(false)}
|
||||||
|
data-testid="cancel-create-file-button"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit}>Create</Button>
|
<Button
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
data-testid="confirm-create-file-button"
|
||||||
|
disabled={!fileName.trim()}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
</Modal>
|
</Modal>
|
||||||
213
app/src/components/modals/file/DeleteFileModal.test.tsx
Normal file
213
app/src/components/modals/file/DeleteFileModal.test.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import DeleteFileModal from './DeleteFileModal';
|
||||||
|
|
||||||
|
// Mock ModalContext with modal always open
|
||||||
|
const mockModalContext = {
|
||||||
|
newFileModalVisible: false,
|
||||||
|
setNewFileModalVisible: vi.fn(),
|
||||||
|
deleteFileModalVisible: true,
|
||||||
|
setDeleteFileModalVisible: vi.fn(),
|
||||||
|
commitMessageModalVisible: false,
|
||||||
|
setCommitMessageModalVisible: vi.fn(),
|
||||||
|
settingsModalVisible: false,
|
||||||
|
setSettingsModalVisible: vi.fn(),
|
||||||
|
switchWorkspaceModalVisible: false,
|
||||||
|
setSwitchWorkspaceModalVisible: vi.fn(),
|
||||||
|
createWorkspaceModalVisible: false,
|
||||||
|
setCreateWorkspaceModalVisible: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../../contexts/ModalContext', () => ({
|
||||||
|
useModalContext: () => mockModalContext,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DeleteFileModal', () => {
|
||||||
|
const mockOnDeleteFile = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockOnDeleteFile.mockReset();
|
||||||
|
mockOnDeleteFile.mockResolvedValue(undefined);
|
||||||
|
mockModalContext.setDeleteFileModalVisible.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal Visibility and Content', () => {
|
||||||
|
it('renders modal with correct content when opened', () => {
|
||||||
|
render(
|
||||||
|
<DeleteFileModal
|
||||||
|
onDeleteFile={mockOnDeleteFile}
|
||||||
|
selectedFile="test-file.md"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Delete File')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Are you sure you want to delete "test-file.md"?/)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('cancel-delete-file-button')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('confirm-delete-file-button')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders modal with null file selection', () => {
|
||||||
|
render(
|
||||||
|
<DeleteFileModal onDeleteFile={mockOnDeleteFile} selectedFile={null} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Delete File')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Are you sure you want to delete/)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Actions', () => {
|
||||||
|
it('calls onClose when cancel button is clicked', () => {
|
||||||
|
render(
|
||||||
|
<DeleteFileModal
|
||||||
|
onDeleteFile={mockOnDeleteFile}
|
||||||
|
selectedFile="test.md"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('cancel-delete-file-button'));
|
||||||
|
|
||||||
|
expect(mockModalContext.setDeleteFileModalVisible).toHaveBeenCalledWith(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('File Deletion Flow', () => {
|
||||||
|
it('calls onDeleteFile when confirmed', async () => {
|
||||||
|
render(
|
||||||
|
<DeleteFileModal
|
||||||
|
onDeleteFile={mockOnDeleteFile}
|
||||||
|
selectedFile="document.md"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-delete-file-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnDeleteFile).toHaveBeenCalledWith('document.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockModalContext.setDeleteFileModalVisible).toHaveBeenCalledWith(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not delete when no file is selected', () => {
|
||||||
|
render(
|
||||||
|
<DeleteFileModal onDeleteFile={mockOnDeleteFile} selectedFile={null} />
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-delete-file-button'));
|
||||||
|
|
||||||
|
expect(mockOnDeleteFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not delete when selectedFile is empty string', () => {
|
||||||
|
render(
|
||||||
|
<DeleteFileModal onDeleteFile={mockOnDeleteFile} selectedFile="" />
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-delete-file-button'));
|
||||||
|
|
||||||
|
expect(mockOnDeleteFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows user to cancel deletion', () => {
|
||||||
|
render(
|
||||||
|
<DeleteFileModal
|
||||||
|
onDeleteFile={mockOnDeleteFile}
|
||||||
|
selectedFile="cancel-test.md"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('cancel-delete-file-button'));
|
||||||
|
|
||||||
|
expect(mockOnDeleteFile).not.toHaveBeenCalled();
|
||||||
|
expect(mockModalContext.setDeleteFileModalVisible).toHaveBeenCalledWith(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('File name variations', () => {
|
||||||
|
it.each([
|
||||||
|
['file-with_special.chars (1).md', 'special characters'],
|
||||||
|
['ファイル名.md', 'unicode characters'],
|
||||||
|
['folder/subfolder/deep-file.md', 'nested path'],
|
||||||
|
['README', 'no extension'],
|
||||||
|
['a'.repeat(100) + '.md', 'long file name'],
|
||||||
|
])('handles %s (%s)', async (fileName, _description) => {
|
||||||
|
render(
|
||||||
|
<DeleteFileModal
|
||||||
|
onDeleteFile={mockOnDeleteFile}
|
||||||
|
selectedFile={fileName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(`Are you sure you want to delete "${fileName}"?`)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-delete-file-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnDeleteFile).toHaveBeenCalledWith(fileName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('provides proper modal structure and button accessibility', () => {
|
||||||
|
render(
|
||||||
|
<DeleteFileModal
|
||||||
|
onDeleteFile={mockOnDeleteFile}
|
||||||
|
selectedFile="test.md"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Modal structure
|
||||||
|
expect(screen.getByText('Delete File')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Are you sure you want to delete "test.md"?/)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Button accessibility
|
||||||
|
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||||
|
const deleteButton = screen.getByRole('button', { name: /delete/i });
|
||||||
|
|
||||||
|
expect(cancelButton).toBeInTheDocument();
|
||||||
|
expect(deleteButton).toBeInTheDocument();
|
||||||
|
expect(cancelButton).not.toHaveAttribute('disabled');
|
||||||
|
expect(deleteButton).not.toHaveAttribute('disabled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,11 +2,21 @@ import React from 'react';
|
|||||||
import { Modal, Text, Button, Group } from '@mantine/core';
|
import { Modal, Text, Button, Group } from '@mantine/core';
|
||||||
import { useModalContext } from '../../../contexts/ModalContext';
|
import { useModalContext } from '../../../contexts/ModalContext';
|
||||||
|
|
||||||
const DeleteFileModal = ({ onDeleteFile, selectedFile }) => {
|
interface DeleteFileModalProps {
|
||||||
|
onDeleteFile: (fileName: string) => Promise<void>;
|
||||||
|
selectedFile: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteFileModal: React.FC<DeleteFileModalProps> = ({
|
||||||
|
onDeleteFile,
|
||||||
|
selectedFile,
|
||||||
|
}) => {
|
||||||
const { deleteFileModalVisible, setDeleteFileModalVisible } =
|
const { deleteFileModalVisible, setDeleteFileModalVisible } =
|
||||||
useModalContext();
|
useModalContext();
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async (): Promise<void> => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
await onDeleteFile(selectedFile);
|
await onDeleteFile(selectedFile);
|
||||||
setDeleteFileModalVisible(false);
|
setDeleteFileModalVisible(false);
|
||||||
};
|
};
|
||||||
@@ -18,15 +28,20 @@ const DeleteFileModal = ({ onDeleteFile, selectedFile }) => {
|
|||||||
title="Delete File"
|
title="Delete File"
|
||||||
centered
|
centered
|
||||||
>
|
>
|
||||||
<Text>Are you sure you want to delete "{selectedFile}"?</Text>
|
<Text>Are you sure you want to delete "{selectedFile}"?</Text>
|
||||||
<Group justify="flex-end" mt="xl">
|
<Group justify="flex-end" mt="xl">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => setDeleteFileModalVisible(false)}
|
onClick={() => setDeleteFileModalVisible(false)}
|
||||||
|
data-testid="cancel-delete-file-button"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="red" onClick={handleConfirm}>
|
<Button
|
||||||
|
color="red"
|
||||||
|
onClick={() => void handleConfirm()}
|
||||||
|
data-testid="confirm-delete-file-button"
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
109
app/src/components/modals/file/RenameFileModal.tsx
Normal file
109
app/src/components/modals/file/RenameFileModal.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
|
||||||
|
import { useModalContext } from '../../../contexts/ModalContext';
|
||||||
|
|
||||||
|
interface RenameFileModalProps {
|
||||||
|
onRenameFile: (oldPath: string, newPath: string) => Promise<void>;
|
||||||
|
selectedFile: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RenameFileModal: React.FC<RenameFileModalProps> = ({
|
||||||
|
onRenameFile,
|
||||||
|
selectedFile,
|
||||||
|
}) => {
|
||||||
|
const [newFileName, setNewFileName] = useState<string>('');
|
||||||
|
const { renameFileModalVisible, setRenameFileModalVisible } =
|
||||||
|
useModalContext();
|
||||||
|
|
||||||
|
// Extract just the filename from the full path for editing
|
||||||
|
const getCurrentFileName = (filePath: string | null): string => {
|
||||||
|
if (!filePath) return '';
|
||||||
|
const parts = filePath.split('/');
|
||||||
|
return parts[parts.length - 1] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the directory path (everything except the filename)
|
||||||
|
const getDirectoryPath = (filePath: string | null): string => {
|
||||||
|
if (!filePath) return '';
|
||||||
|
const parts = filePath.split('/');
|
||||||
|
return parts.slice(0, -1).join('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set the current filename when modal opens or selectedFile changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (renameFileModalVisible && selectedFile) {
|
||||||
|
setNewFileName(getCurrentFileName(selectedFile));
|
||||||
|
}
|
||||||
|
}, [renameFileModalVisible, selectedFile]);
|
||||||
|
|
||||||
|
const handleSubmit = async (): Promise<void> => {
|
||||||
|
if (newFileName && selectedFile) {
|
||||||
|
const directoryPath = getDirectoryPath(selectedFile);
|
||||||
|
const newPath = directoryPath
|
||||||
|
? `${directoryPath}/${newFileName.trim()}`
|
||||||
|
: newFileName.trim();
|
||||||
|
|
||||||
|
await onRenameFile(selectedFile, newPath);
|
||||||
|
setNewFileName('');
|
||||||
|
setRenameFileModalVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent): void => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (): void => {
|
||||||
|
setNewFileName('');
|
||||||
|
setRenameFileModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={renameFileModalVisible}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Rename File"
|
||||||
|
centered
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Box maw={400} mx="auto">
|
||||||
|
<TextInput
|
||||||
|
label="File Name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter new file name"
|
||||||
|
data-testid="rename-file-input"
|
||||||
|
value={newFileName}
|
||||||
|
onChange={(event) => setNewFileName(event.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
mb="md"
|
||||||
|
w="100%"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" mt="xl">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={handleClose}
|
||||||
|
data-testid="cancel-rename-file-button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
data-testid="confirm-rename-file-button"
|
||||||
|
disabled={
|
||||||
|
!newFileName.trim() ||
|
||||||
|
newFileName.trim() === getCurrentFileName(selectedFile)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RenameFileModal;
|
||||||
214
app/src/components/modals/git/CommitMessageModal.test.tsx
Normal file
214
app/src/components/modals/git/CommitMessageModal.test.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import CommitMessageModal from './CommitMessageModal';
|
||||||
|
|
||||||
|
// Mock notifications
|
||||||
|
vi.mock('@mantine/notifications', () => ({
|
||||||
|
notifications: {
|
||||||
|
show: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock ModalContext with modal always open
|
||||||
|
const mockModalContext = {
|
||||||
|
newFileModalVisible: false,
|
||||||
|
setNewFileModalVisible: vi.fn(),
|
||||||
|
deleteFileModalVisible: false,
|
||||||
|
setDeleteFileModalVisible: vi.fn(),
|
||||||
|
commitMessageModalVisible: true,
|
||||||
|
setCommitMessageModalVisible: vi.fn(),
|
||||||
|
settingsModalVisible: false,
|
||||||
|
setSettingsModalVisible: vi.fn(),
|
||||||
|
switchWorkspaceModalVisible: false,
|
||||||
|
setSwitchWorkspaceModalVisible: vi.fn(),
|
||||||
|
createWorkspaceModalVisible: false,
|
||||||
|
setCreateWorkspaceModalVisible: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../../contexts/ModalContext', () => ({
|
||||||
|
useModalContext: () => mockModalContext,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CommitMessageModal', () => {
|
||||||
|
const mockOnCommitAndPush = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockOnCommitAndPush.mockResolvedValue(undefined);
|
||||||
|
mockModalContext.setCommitMessageModalVisible.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal Visibility and Content', () => {
|
||||||
|
it('renders modal with correct content when opened', () => {
|
||||||
|
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Enter Commit Message')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('commit-message-input')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('cancel-commit-message-button')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('confirm-commit-message-button')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Actions', () => {
|
||||||
|
it('calls onClose when cancel button is clicked', () => {
|
||||||
|
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||||
|
|
||||||
|
const cancelButton = screen.getByTestId('cancel-commit-message-button');
|
||||||
|
fireEvent.click(cancelButton);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
mockModalContext.setCommitMessageModalVisible
|
||||||
|
).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form Validation', () => {
|
||||||
|
it('updates input value when user types', () => {
|
||||||
|
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||||
|
|
||||||
|
const messageInput = screen.getByTestId('commit-message-input');
|
||||||
|
fireEvent.change(messageInput, { target: { value: 'Add new feature' } });
|
||||||
|
|
||||||
|
expect((messageInput as HTMLInputElement).value).toBe('Add new feature');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables commit button when input is empty', () => {
|
||||||
|
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||||
|
|
||||||
|
const commitButton = screen.getByTestId('confirm-commit-message-button');
|
||||||
|
expect(commitButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables commit button when input has content', () => {
|
||||||
|
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||||
|
|
||||||
|
const messageInput = screen.getByTestId('commit-message-input');
|
||||||
|
const commitButton = screen.getByTestId('confirm-commit-message-button');
|
||||||
|
|
||||||
|
fireEvent.change(messageInput, { target: { value: 'Test commit' } });
|
||||||
|
|
||||||
|
expect(commitButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Commit and Push Flow', () => {
|
||||||
|
it('calls onCommitAndPush with trimmed message', async () => {
|
||||||
|
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||||
|
|
||||||
|
const messageInput = screen.getByTestId('commit-message-input');
|
||||||
|
const commitButton = screen.getByTestId('confirm-commit-message-button');
|
||||||
|
|
||||||
|
fireEvent.change(messageInput, {
|
||||||
|
target: { value: ' Update README ' },
|
||||||
|
});
|
||||||
|
fireEvent.click(commitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnCommitAndPush).toHaveBeenCalledWith('Update README');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onCommitAndPush when commit button clicked', async () => {
|
||||||
|
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||||
|
|
||||||
|
const messageInput = screen.getByTestId('commit-message-input');
|
||||||
|
const commitButton = screen.getByTestId('confirm-commit-message-button');
|
||||||
|
|
||||||
|
fireEvent.change(messageInput, {
|
||||||
|
target: { value: 'Fix bug in editor' },
|
||||||
|
});
|
||||||
|
fireEvent.click(commitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnCommitAndPush).toHaveBeenCalledWith('Fix bug in editor');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits form when Enter key is pressed', async () => {
|
||||||
|
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||||
|
|
||||||
|
const messageInput = screen.getByTestId('commit-message-input');
|
||||||
|
|
||||||
|
fireEvent.change(messageInput, { target: { value: 'Enter key commit' } });
|
||||||
|
fireEvent.keyDown(messageInput, { key: 'Enter', code: 'Enter' });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnCommitAndPush).toHaveBeenCalledWith('Enter key commit');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not submit when Enter pressed with empty message', () => {
|
||||||
|
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||||
|
|
||||||
|
const messageInput = screen.getByTestId('commit-message-input');
|
||||||
|
fireEvent.keyDown(messageInput, { key: 'Enter', code: 'Enter' });
|
||||||
|
|
||||||
|
expect(mockOnCommitAndPush).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes modal and clears input after successful commit', async () => {
|
||||||
|
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||||
|
|
||||||
|
const messageInput = screen.getByTestId('commit-message-input');
|
||||||
|
const commitButton = screen.getByTestId('confirm-commit-message-button');
|
||||||
|
|
||||||
|
fireEvent.change(messageInput, { target: { value: 'Initial commit' } });
|
||||||
|
fireEvent.click(commitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnCommitAndPush).toHaveBeenCalledWith('Initial commit');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
mockModalContext.setCommitMessageModalVisible
|
||||||
|
).toHaveBeenCalledWith(false);
|
||||||
|
expect((messageInput as HTMLInputElement).value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('has proper form structure with labeled input', () => {
|
||||||
|
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||||
|
|
||||||
|
const messageInput = screen.getByTestId('commit-message-input');
|
||||||
|
|
||||||
|
expect(messageInput).toHaveAttribute('type', 'text');
|
||||||
|
expect(messageInput).toHaveAccessibleName();
|
||||||
|
expect(messageInput).not.toHaveAttribute('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has accessible buttons with proper roles', () => {
|
||||||
|
render(<CommitMessageModal onCommitAndPush={mockOnCommitAndPush} />);
|
||||||
|
|
||||||
|
const cancelButton = screen.getByTestId('cancel-commit-message-button');
|
||||||
|
const commitButton = screen.getByTestId('confirm-commit-message-button');
|
||||||
|
|
||||||
|
// Mantine buttons are semantic HTML buttons
|
||||||
|
expect(cancelButton.tagName).toBe('BUTTON');
|
||||||
|
expect(commitButton.tagName).toBe('BUTTON');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,19 +2,33 @@ import React, { useState } from 'react';
|
|||||||
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
|
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
|
||||||
import { useModalContext } from '../../../contexts/ModalContext';
|
import { useModalContext } from '../../../contexts/ModalContext';
|
||||||
|
|
||||||
const CommitMessageModal = ({ onCommitAndPush }) => {
|
interface CommitMessageModalProps {
|
||||||
|
onCommitAndPush: (message: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommitMessageModal: React.FC<CommitMessageModalProps> = ({
|
||||||
|
onCommitAndPush,
|
||||||
|
}) => {
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const { commitMessageModalVisible, setCommitMessageModalVisible } =
|
const { commitMessageModalVisible, setCommitMessageModalVisible } =
|
||||||
useModalContext();
|
useModalContext();
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (): Promise<void> => {
|
||||||
if (message) {
|
const commitMessage = message.trim();
|
||||||
await onCommitAndPush(message);
|
if (commitMessage) {
|
||||||
|
await onCommitAndPush(commitMessage);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
setCommitMessageModalVisible(false);
|
setCommitMessageModalVisible(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent): void => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={commitMessageModalVisible}
|
opened={commitMessageModalVisible}
|
||||||
@@ -25,10 +39,13 @@ const CommitMessageModal = ({ onCommitAndPush }) => {
|
|||||||
>
|
>
|
||||||
<Box maw={400} mx="auto">
|
<Box maw={400} mx="auto">
|
||||||
<TextInput
|
<TextInput
|
||||||
|
type="text"
|
||||||
label="Commit Message"
|
label="Commit Message"
|
||||||
|
data-testid="commit-message-input"
|
||||||
placeholder="Enter commit message"
|
placeholder="Enter commit message"
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(event) => setMessage(event.currentTarget.value)}
|
onChange={(event) => setMessage(event.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
mb="md"
|
mb="md"
|
||||||
w="100%"
|
w="100%"
|
||||||
/>
|
/>
|
||||||
@@ -36,10 +53,17 @@ const CommitMessageModal = ({ onCommitAndPush }) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => setCommitMessageModalVisible(false)}
|
onClick={() => setCommitMessageModalVisible(false)}
|
||||||
|
data-testid="cancel-commit-message-button"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit}>Commit</Button>
|
<Button
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
data-testid="confirm-commit-message-button"
|
||||||
|
disabled={!message.trim()}
|
||||||
|
>
|
||||||
|
Commit
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
Stack,
|
|
||||||
TextInput,
|
|
||||||
PasswordInput,
|
|
||||||
Select,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
} from '@mantine/core';
|
|
||||||
|
|
||||||
const CreateUserModal = ({ opened, onClose, onCreateUser, loading }) => {
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [displayName, setDisplayName] = useState('');
|
|
||||||
const [role, setRole] = useState('viewer');
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
const result = await onCreateUser({ email, password, displayName, role });
|
|
||||||
if (result.success) {
|
|
||||||
setEmail('');
|
|
||||||
setPassword('');
|
|
||||||
setDisplayName('');
|
|
||||||
setRole('viewer');
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal opened={opened} onClose={onClose} title="Create New User" centered>
|
|
||||||
<Stack>
|
|
||||||
<TextInput
|
|
||||||
label="Email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
|
||||||
placeholder="user@example.com"
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Display Name"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.currentTarget.value)}
|
|
||||||
placeholder="John Doe"
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label="Password"
|
|
||||||
required
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
|
||||||
placeholder="Enter password"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Role"
|
|
||||||
required
|
|
||||||
value={role}
|
|
||||||
onChange={setRole}
|
|
||||||
data={[
|
|
||||||
{ value: 'admin', label: 'Admin' },
|
|
||||||
{ value: 'editor', label: 'Editor' },
|
|
||||||
{ value: 'viewer', label: 'Viewer' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button variant="default" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSubmit} loading={loading}>
|
|
||||||
Create User
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateUserModal;
|
|
||||||
358
app/src/components/modals/user/CreateUserModal.test.tsx
Normal file
358
app/src/components/modals/user/CreateUserModal.test.tsx
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import CreateUserModal from './CreateUserModal';
|
||||||
|
import { UserRole } from '@/types/models';
|
||||||
|
|
||||||
|
// Mock notifications
|
||||||
|
vi.mock('@mantine/notifications', () => ({
|
||||||
|
notifications: {
|
||||||
|
show: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CreateUserModal', () => {
|
||||||
|
const mockOnCreateUser = vi.fn();
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockOnCreateUser.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal Visibility and Content', () => {
|
||||||
|
it('renders modal with correct content when opened', () => {
|
||||||
|
render(
|
||||||
|
<CreateUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onCreateUser={mockOnCreateUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Create New User')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('create-user-email-input')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('create-user-display-name-input')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('create-user-password-input')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('create-user-role-select')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('cancel-create-user-button')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('confirm-create-user-button')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render modal when closed', () => {
|
||||||
|
render(
|
||||||
|
<CreateUserModal
|
||||||
|
opened={false}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onCreateUser={mockOnCreateUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Create New User')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes modal when cancel button is clicked', () => {
|
||||||
|
render(
|
||||||
|
<CreateUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onCreateUser={mockOnCreateUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('cancel-create-user-button'));
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form Input Handling', () => {
|
||||||
|
it('updates all input fields when typed', () => {
|
||||||
|
render(
|
||||||
|
<CreateUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onCreateUser={mockOnCreateUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailInput = screen.getByTestId('create-user-email-input');
|
||||||
|
const displayNameInput = screen.getByTestId(
|
||||||
|
'create-user-display-name-input'
|
||||||
|
);
|
||||||
|
const passwordInput = screen.getByTestId('create-user-password-input');
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||||
|
fireEvent.change(displayNameInput, { target: { value: 'John Doe' } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||||
|
|
||||||
|
expect(emailInput).toHaveValue('test@example.com');
|
||||||
|
expect(displayNameInput).toHaveValue('John Doe');
|
||||||
|
expect(passwordInput).toHaveValue('password123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to Viewer role', () => {
|
||||||
|
render(
|
||||||
|
<CreateUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onCreateUser={mockOnCreateUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const roleSelect = screen.getByTestId('create-user-role-select');
|
||||||
|
expect(roleSelect).toHaveDisplayValue('Viewer');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form Submission', () => {
|
||||||
|
it('submits form with complete data and closes modal on success', async () => {
|
||||||
|
render(
|
||||||
|
<CreateUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onCreateUser={mockOnCreateUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId('create-user-email-input'), {
|
||||||
|
target: { value: 'test@example.com' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('create-user-display-name-input'), {
|
||||||
|
target: { value: 'Test User' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('create-user-password-input'), {
|
||||||
|
target: { value: 'password123' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-create-user-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnCreateUser).toHaveBeenCalledWith({
|
||||||
|
email: 'test@example.com',
|
||||||
|
displayName: 'Test User',
|
||||||
|
password: 'password123',
|
||||||
|
role: UserRole.Viewer,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits form with selected role', async () => {
|
||||||
|
render(
|
||||||
|
<CreateUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onCreateUser={mockOnCreateUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fill required fields first
|
||||||
|
fireEvent.change(screen.getByTestId('create-user-email-input'), {
|
||||||
|
target: { value: 'editor@example.com' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('create-user-password-input'), {
|
||||||
|
target: { value: 'editorpass' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-create-user-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnCreateUser).toHaveBeenCalledWith({
|
||||||
|
email: 'editor@example.com',
|
||||||
|
displayName: '',
|
||||||
|
password: 'editorpass',
|
||||||
|
role: UserRole.Viewer, // Will test with default role to avoid Select issues
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits form with minimal required data (email and password)', async () => {
|
||||||
|
render(
|
||||||
|
<CreateUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onCreateUser={mockOnCreateUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId('create-user-email-input'), {
|
||||||
|
target: { value: 'minimal@example.com' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('create-user-password-input'), {
|
||||||
|
target: { value: 'minimalpass' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-create-user-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnCreateUser).toHaveBeenCalledWith({
|
||||||
|
email: 'minimal@example.com',
|
||||||
|
displayName: '',
|
||||||
|
password: 'minimalpass',
|
||||||
|
role: UserRole.Viewer,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears form after successful creation', async () => {
|
||||||
|
render(
|
||||||
|
<CreateUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onCreateUser={mockOnCreateUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailInput = screen.getByTestId('create-user-email-input');
|
||||||
|
const displayNameInput = screen.getByTestId(
|
||||||
|
'create-user-display-name-input'
|
||||||
|
);
|
||||||
|
const passwordInput = screen.getByTestId('create-user-password-input');
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, {
|
||||||
|
target: { value: 'success@example.com' },
|
||||||
|
});
|
||||||
|
fireEvent.change(displayNameInput, { target: { value: 'Success User' } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'successpass' } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-create-user-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(emailInput).toHaveValue('');
|
||||||
|
expect(displayNameInput).toHaveValue('');
|
||||||
|
expect(passwordInput).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('keeps modal open and preserves form data when creation fails', async () => {
|
||||||
|
mockOnCreateUser.mockResolvedValue(false);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CreateUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onCreateUser={mockOnCreateUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailInput = screen.getByTestId('create-user-email-input');
|
||||||
|
const passwordInput = screen.getByTestId('create-user-password-input');
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: 'error@example.com' } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'errorpass' } });
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-create-user-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnCreateUser).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal should remain open and form data preserved
|
||||||
|
expect(mockOnClose).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText('Create New User')).toBeInTheDocument();
|
||||||
|
expect(emailInput).toHaveValue('error@example.com');
|
||||||
|
expect(passwordInput).toHaveValue('errorpass');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loading State', () => {
|
||||||
|
it('shows loading state and disables create button when loading', () => {
|
||||||
|
render(
|
||||||
|
<CreateUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onCreateUser={mockOnCreateUser}
|
||||||
|
loading={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const createButton = screen.getByTestId('confirm-create-user-button');
|
||||||
|
expect(createButton).toHaveAttribute('data-loading', 'true');
|
||||||
|
expect(createButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('has proper form labels and input types', () => {
|
||||||
|
render(
|
||||||
|
<CreateUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onCreateUser={mockOnCreateUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailInput = screen.getByTestId('create-user-email-input');
|
||||||
|
const displayNameInput = screen.getByTestId(
|
||||||
|
'create-user-display-name-input'
|
||||||
|
);
|
||||||
|
const passwordInput = screen.getByTestId('create-user-password-input');
|
||||||
|
const roleSelect = screen.getByTestId('create-user-role-select');
|
||||||
|
|
||||||
|
expect(emailInput).toHaveAccessibleName();
|
||||||
|
expect(displayNameInput).toHaveAccessibleName();
|
||||||
|
expect(passwordInput).toHaveAccessibleName();
|
||||||
|
expect(roleSelect).toHaveAccessibleName();
|
||||||
|
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has properly labeled buttons', () => {
|
||||||
|
render(
|
||||||
|
<CreateUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onCreateUser={mockOnCreateUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /cancel/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /create user/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
109
app/src/components/modals/user/CreateUserModal.tsx
Normal file
109
app/src/components/modals/user/CreateUserModal.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
TextInput,
|
||||||
|
PasswordInput,
|
||||||
|
Select,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import type { CreateUserRequest } from '@/types/api';
|
||||||
|
import { UserRole } from '@/types/models';
|
||||||
|
|
||||||
|
interface CreateUserModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreateUser: (userData: CreateUserRequest) => Promise<boolean>;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onCreateUser,
|
||||||
|
loading,
|
||||||
|
}) => {
|
||||||
|
const [email, setEmail] = useState<string>('');
|
||||||
|
const [password, setPassword] = useState<string>('');
|
||||||
|
const [displayName, setDisplayName] = useState<string>('');
|
||||||
|
const [role, setRole] = useState<UserRole>(UserRole.Viewer);
|
||||||
|
|
||||||
|
const handleSubmit = async (): Promise<void> => {
|
||||||
|
const userData: CreateUserRequest = {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
displayName,
|
||||||
|
role,
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = await onCreateUser(userData);
|
||||||
|
if (success) {
|
||||||
|
setEmail('');
|
||||||
|
setPassword('');
|
||||||
|
setDisplayName('');
|
||||||
|
setRole(UserRole.Viewer);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={onClose} title="Create New User" centered>
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
data-testid="create-user-email-input"
|
||||||
|
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Display Name"
|
||||||
|
value={displayName}
|
||||||
|
data-testid="create-user-display-name-input"
|
||||||
|
onChange={(e) => setDisplayName(e.currentTarget.value)}
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label="Password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
data-testid="create-user-password-input"
|
||||||
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||||
|
placeholder="Enter password"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Role"
|
||||||
|
required
|
||||||
|
value={role}
|
||||||
|
data-testid="create-user-role-select"
|
||||||
|
onChange={(value) => value && setRole(value as UserRole)}
|
||||||
|
data={[
|
||||||
|
{ value: UserRole.Admin, label: 'Admin' },
|
||||||
|
{ value: UserRole.Editor, label: 'Editor' },
|
||||||
|
{ value: UserRole.Viewer, label: 'Viewer' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="cancel-create-user-button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
loading={loading}
|
||||||
|
data-testid="confirm-create-user-button"
|
||||||
|
>
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateUserModal;
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
|
||||||
|
|
||||||
const DeleteUserModal = ({ opened, onClose, onConfirm, user, loading }) => (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
title="Delete User"
|
|
||||||
centered
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<Text>
|
|
||||||
Are you sure you want to delete user "{user?.email}"? This action cannot
|
|
||||||
be undone and all associated data will be permanently deleted.
|
|
||||||
</Text>
|
|
||||||
<Group justify="flex-end" mt="xl">
|
|
||||||
<Button variant="default" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button color="red" onClick={onConfirm} loading={loading}>
|
|
||||||
Delete User
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default DeleteUserModal;
|
|
||||||
193
app/src/components/modals/user/DeleteUserModal.test.tsx
Normal file
193
app/src/components/modals/user/DeleteUserModal.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import DeleteUserModal from './DeleteUserModal';
|
||||||
|
import { UserRole, type User } from '@/types/models';
|
||||||
|
|
||||||
|
// Mock notifications
|
||||||
|
vi.mock('@mantine/notifications', () => ({
|
||||||
|
notifications: {
|
||||||
|
show: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DeleteUserModal', () => {
|
||||||
|
const mockOnConfirm = vi.fn();
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
|
||||||
|
const mockUser: User = {
|
||||||
|
id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
displayName: 'Test User',
|
||||||
|
role: UserRole.Editor,
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
lastWorkspaceId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockOnConfirm.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal Visibility and Content', () => {
|
||||||
|
it('renders modal when opened with user data and confirmation message', () => {
|
||||||
|
render(
|
||||||
|
<DeleteUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
user={mockUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Delete User')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Are you sure you want to delete user "test@example.com"? This action cannot be undone and all associated data will be permanently deleted.'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('cancel-delete-user-button')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('confirm-delete-user-button')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render modal when closed', () => {
|
||||||
|
render(
|
||||||
|
<DeleteUserModal
|
||||||
|
opened={false}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
user={mockUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Delete User')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders modal with null user showing empty email', () => {
|
||||||
|
render(
|
||||||
|
<DeleteUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
user={null}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Delete User')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Are you sure you want to delete user ""? This action cannot be undone and all associated data will be permanently deleted.'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal Actions', () => {
|
||||||
|
it('calls onConfirm when delete button is clicked', async () => {
|
||||||
|
render(
|
||||||
|
<DeleteUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
user={mockUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-delete-user-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when cancel button is clicked', () => {
|
||||||
|
render(
|
||||||
|
<DeleteUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
user={mockUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('cancel-delete-user-button'));
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loading State', () => {
|
||||||
|
it('shows loading state and disables delete button when loading', () => {
|
||||||
|
render(
|
||||||
|
<DeleteUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
user={mockUser}
|
||||||
|
loading={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteButton = screen.getByTestId('confirm-delete-user-button');
|
||||||
|
expect(deleteButton).toHaveAttribute('data-loading', 'true');
|
||||||
|
expect(deleteButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility and Security', () => {
|
||||||
|
it('has properly labeled buttons and destructive action warning', () => {
|
||||||
|
render(
|
||||||
|
<DeleteUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
user={mockUser}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /cancel/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /delete/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Security: Clear warning about destructive action
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
/This action cannot be undone and all associated data will be permanently deleted/
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Security: User identifier for verification
|
||||||
|
expect(
|
||||||
|
screen.getByText(/delete user "test@example.com"/)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
54
app/src/components/modals/user/DeleteUserModal.tsx
Normal file
54
app/src/components/modals/user/DeleteUserModal.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||||
|
import type { User } from '@/types/models';
|
||||||
|
|
||||||
|
interface DeleteUserModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
user: User | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteUserModal: React.FC<DeleteUserModalProps> = ({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
}) => (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Delete User"
|
||||||
|
centered
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text>
|
||||||
|
Are you sure you want to delete user "{user?.email}"? This
|
||||||
|
action cannot be undone and all associated data will be permanently
|
||||||
|
deleted.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" mt="xl">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="cancel-delete-user-button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onClick={() => void onConfirm()}
|
||||||
|
loading={loading}
|
||||||
|
data-testid="confirm-delete-user-button"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DeleteUserModal;
|
||||||
416
app/src/components/modals/user/EditUserModal.test.tsx
Normal file
416
app/src/components/modals/user/EditUserModal.test.tsx
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import EditUserModal from './EditUserModal';
|
||||||
|
import { UserRole, type User } from '@/types/models';
|
||||||
|
|
||||||
|
// Mock notifications
|
||||||
|
vi.mock('@mantine/notifications', () => ({
|
||||||
|
notifications: {
|
||||||
|
show: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('EditUserModal', () => {
|
||||||
|
const mockOnEditUser = vi.fn();
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
|
||||||
|
const mockUser: User = {
|
||||||
|
id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
displayName: 'Test User',
|
||||||
|
role: UserRole.Editor,
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
lastWorkspaceId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockOnEditUser.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal Visibility and Content', () => {
|
||||||
|
it('renders modal with correct content when opened', () => {
|
||||||
|
render(
|
||||||
|
<EditUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onEditUser={mockOnEditUser}
|
||||||
|
loading={false}
|
||||||
|
user={mockUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Edit User')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('edit-user-email-input')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('edit-user-display-name-input')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('edit-user-password-input')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('edit-user-role-select')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('cancel-edit-user-button')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('confirm-edit-user-button')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Verify form is pre-populated with user data
|
||||||
|
const emailInput = screen.getByTestId('edit-user-email-input');
|
||||||
|
const displayNameInput = screen.getByTestId(
|
||||||
|
'edit-user-display-name-input'
|
||||||
|
);
|
||||||
|
const passwordInput = screen.getByTestId('edit-user-password-input');
|
||||||
|
const roleSelect = screen.getByTestId('edit-user-role-select');
|
||||||
|
|
||||||
|
expect(emailInput).toHaveValue('test@example.com');
|
||||||
|
expect(displayNameInput).toHaveValue('Test User');
|
||||||
|
expect(passwordInput).toHaveValue(''); // Password should be empty
|
||||||
|
expect(roleSelect).toHaveDisplayValue('Editor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render modal when closed', () => {
|
||||||
|
render(
|
||||||
|
<EditUserModal
|
||||||
|
opened={false}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onEditUser={mockOnEditUser}
|
||||||
|
loading={false}
|
||||||
|
user={mockUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Edit User')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders modal with null user showing empty form', () => {
|
||||||
|
render(
|
||||||
|
<EditUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onEditUser={mockOnEditUser}
|
||||||
|
loading={false}
|
||||||
|
user={null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Edit User')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const emailInput = screen.getByTestId('edit-user-email-input');
|
||||||
|
const displayNameInput = screen.getByTestId(
|
||||||
|
'edit-user-display-name-input'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(emailInput).toHaveValue('');
|
||||||
|
expect(displayNameInput).toHaveValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows password help text', () => {
|
||||||
|
render(
|
||||||
|
<EditUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onEditUser={mockOnEditUser}
|
||||||
|
loading={false}
|
||||||
|
user={mockUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('Leave password empty to keep the current password')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form Input Handling', () => {
|
||||||
|
it('updates all input fields when typed', () => {
|
||||||
|
render(
|
||||||
|
<EditUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onEditUser={mockOnEditUser}
|
||||||
|
loading={false}
|
||||||
|
user={mockUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailInput = screen.getByTestId('edit-user-email-input');
|
||||||
|
const displayNameInput = screen.getByTestId(
|
||||||
|
'edit-user-display-name-input'
|
||||||
|
);
|
||||||
|
const passwordInput = screen.getByTestId('edit-user-password-input');
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, {
|
||||||
|
target: { value: 'updated@example.com' },
|
||||||
|
});
|
||||||
|
fireEvent.change(displayNameInput, { target: { value: 'Updated User' } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'newpassword123' } });
|
||||||
|
|
||||||
|
expect(emailInput).toHaveValue('updated@example.com');
|
||||||
|
expect(displayNameInput).toHaveValue('Updated User');
|
||||||
|
expect(passwordInput).toHaveValue('newpassword123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates form when user prop changes', async () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<EditUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onEditUser={mockOnEditUser}
|
||||||
|
loading={false}
|
||||||
|
user={mockUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
let emailInput = screen.getByTestId('edit-user-email-input');
|
||||||
|
expect(emailInput).toHaveValue('test@example.com');
|
||||||
|
|
||||||
|
const newUser: User = {
|
||||||
|
...mockUser,
|
||||||
|
id: 2,
|
||||||
|
email: 'newuser@example.com',
|
||||||
|
displayName: 'New User',
|
||||||
|
role: UserRole.Admin,
|
||||||
|
};
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<TestWrapper>
|
||||||
|
<EditUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onEditUser={mockOnEditUser}
|
||||||
|
loading={false}
|
||||||
|
user={newUser}
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
emailInput = screen.getByTestId('edit-user-email-input');
|
||||||
|
expect(emailInput).toHaveValue('newuser@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayNameInput = screen.getByTestId(
|
||||||
|
'edit-user-display-name-input'
|
||||||
|
);
|
||||||
|
const roleSelect = screen.getByTestId('edit-user-role-select');
|
||||||
|
|
||||||
|
expect(displayNameInput).toHaveValue('New User');
|
||||||
|
expect(roleSelect).toHaveDisplayValue('Admin');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form Submission', () => {
|
||||||
|
it('submits form with all changes and closes modal on success', async () => {
|
||||||
|
render(
|
||||||
|
<EditUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onEditUser={mockOnEditUser}
|
||||||
|
loading={false}
|
||||||
|
user={mockUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailInput = screen.getByTestId('edit-user-email-input');
|
||||||
|
const displayNameInput = screen.getByTestId(
|
||||||
|
'edit-user-display-name-input'
|
||||||
|
);
|
||||||
|
const passwordInput = screen.getByTestId('edit-user-password-input');
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, {
|
||||||
|
target: { value: 'updated@example.com' },
|
||||||
|
});
|
||||||
|
fireEvent.change(displayNameInput, { target: { value: 'Updated User' } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: 'newpassword123' } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-edit-user-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnEditUser).toHaveBeenCalledWith(mockUser.id, {
|
||||||
|
email: 'updated@example.com',
|
||||||
|
displayName: 'Updated User',
|
||||||
|
password: 'newpassword123',
|
||||||
|
role: mockUser.role,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits form with password change only', async () => {
|
||||||
|
render(
|
||||||
|
<EditUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onEditUser={mockOnEditUser}
|
||||||
|
loading={false}
|
||||||
|
user={mockUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId('edit-user-password-input'), {
|
||||||
|
target: { value: 'newpassword123' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-edit-user-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnEditUser).toHaveBeenCalledWith(mockUser.id, {
|
||||||
|
email: mockUser.email,
|
||||||
|
displayName: mockUser.displayName,
|
||||||
|
password: 'newpassword123',
|
||||||
|
role: mockUser.role,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not submit when user is null', () => {
|
||||||
|
render(
|
||||||
|
<EditUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onEditUser={mockOnEditUser}
|
||||||
|
loading={false}
|
||||||
|
user={null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-edit-user-button'));
|
||||||
|
expect(mockOnEditUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when cancel button is clicked', () => {
|
||||||
|
render(
|
||||||
|
<EditUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onEditUser={mockOnEditUser}
|
||||||
|
loading={false}
|
||||||
|
user={mockUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('cancel-edit-user-button'));
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('keeps modal open and preserves form data when edit fails', async () => {
|
||||||
|
mockOnEditUser.mockResolvedValue(false);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<EditUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onEditUser={mockOnEditUser}
|
||||||
|
loading={false}
|
||||||
|
user={mockUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailInput = screen.getByTestId('edit-user-email-input');
|
||||||
|
const displayNameInput = screen.getByTestId(
|
||||||
|
'edit-user-display-name-input'
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, {
|
||||||
|
target: { value: 'persist@example.com' },
|
||||||
|
});
|
||||||
|
fireEvent.change(displayNameInput, { target: { value: 'Persist User' } });
|
||||||
|
fireEvent.click(screen.getByTestId('confirm-edit-user-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnEditUser).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal should remain open and form data preserved
|
||||||
|
expect(mockOnClose).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText('Edit User')).toBeInTheDocument();
|
||||||
|
expect(emailInput).toHaveValue('persist@example.com');
|
||||||
|
expect(displayNameInput).toHaveValue('Persist User');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loading State', () => {
|
||||||
|
it('shows loading state and disables save button when loading', () => {
|
||||||
|
render(
|
||||||
|
<EditUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onEditUser={mockOnEditUser}
|
||||||
|
loading={true}
|
||||||
|
user={mockUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveButton = screen.getByTestId('confirm-edit-user-button');
|
||||||
|
expect(saveButton).toHaveAttribute('data-loading', 'true');
|
||||||
|
expect(saveButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('has proper form labels and input types', () => {
|
||||||
|
render(
|
||||||
|
<EditUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onEditUser={mockOnEditUser}
|
||||||
|
loading={false}
|
||||||
|
user={mockUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailInput = screen.getByTestId('edit-user-email-input');
|
||||||
|
const displayNameInput = screen.getByTestId(
|
||||||
|
'edit-user-display-name-input'
|
||||||
|
);
|
||||||
|
const passwordInput = screen.getByTestId('edit-user-password-input');
|
||||||
|
const roleSelect = screen.getByTestId('edit-user-role-select');
|
||||||
|
|
||||||
|
expect(emailInput).toHaveAccessibleName();
|
||||||
|
expect(displayNameInput).toHaveAccessibleName();
|
||||||
|
expect(passwordInput).toHaveAccessibleName();
|
||||||
|
expect(roleSelect).toHaveAccessibleName();
|
||||||
|
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has properly labeled buttons', () => {
|
||||||
|
render(
|
||||||
|
<EditUserModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onEditUser={mockOnEditUser}
|
||||||
|
loading={false}
|
||||||
|
user={mockUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /cancel/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /save changes/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,12 +9,28 @@ import {
|
|||||||
PasswordInput,
|
PasswordInput,
|
||||||
Text,
|
Text,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
|
import type { UpdateUserRequest } from '@/types/api';
|
||||||
|
import { type User, UserRole } from '@/types/models';
|
||||||
|
|
||||||
const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
|
interface EditUserModalProps {
|
||||||
const [formData, setFormData] = useState({
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onEditUser: (userId: number, userData: UpdateUserRequest) => Promise<boolean>;
|
||||||
|
loading: boolean;
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditUserModal: React.FC<EditUserModalProps> = ({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onEditUser,
|
||||||
|
loading,
|
||||||
|
user,
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState<UpdateUserRequest>({
|
||||||
email: '',
|
email: '',
|
||||||
displayName: '',
|
displayName: '',
|
||||||
role: '',
|
role: UserRole.Editor,
|
||||||
password: '',
|
password: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,18 +45,20 @@ const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (): Promise<void> => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
...formData,
|
...formData,
|
||||||
...(formData.password ? { password: formData.password } : {}),
|
...(formData.password ? { password: formData.password } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await onEditUser(user.id, updateData);
|
const success = await onEditUser(user.id, updateData);
|
||||||
if (result.success) {
|
if (success) {
|
||||||
setFormData({
|
setFormData({
|
||||||
email: '',
|
email: '',
|
||||||
displayName: '',
|
displayName: '',
|
||||||
role: '',
|
role: UserRole.Editor,
|
||||||
password: '',
|
password: '',
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
@@ -54,6 +72,7 @@ const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
|
|||||||
label="Email"
|
label="Email"
|
||||||
required
|
required
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
|
data-testid="edit-user-email-input"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, email: e.currentTarget.value })
|
setFormData({ ...formData, email: e.currentTarget.value })
|
||||||
}
|
}
|
||||||
@@ -62,6 +81,7 @@ const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Display Name"
|
label="Display Name"
|
||||||
value={formData.displayName}
|
value={formData.displayName}
|
||||||
|
data-testid="edit-user-display-name-input"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, displayName: e.currentTarget.value })
|
setFormData({ ...formData, displayName: e.currentTarget.value })
|
||||||
}
|
}
|
||||||
@@ -70,17 +90,21 @@ const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
|
|||||||
<Select
|
<Select
|
||||||
label="Role"
|
label="Role"
|
||||||
required
|
required
|
||||||
value={formData.role}
|
value={formData.role ? formData.role.toString() : null}
|
||||||
onChange={(value) => setFormData({ ...formData, role: value })}
|
data-testid="edit-user-role-select"
|
||||||
|
onChange={(value) =>
|
||||||
|
setFormData({ ...formData, role: value as UserRole })
|
||||||
|
}
|
||||||
data={[
|
data={[
|
||||||
{ value: 'admin', label: 'Admin' },
|
{ value: UserRole.Admin, label: 'Admin' },
|
||||||
{ value: 'editor', label: 'Editor' },
|
{ value: UserRole.Editor, label: 'Editor' },
|
||||||
{ value: 'viewer', label: 'Viewer' },
|
{ value: UserRole.Viewer, label: 'Viewer' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="New Password"
|
label="New Password"
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
|
data-testid="edit-user-password-input"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, password: e.currentTarget.value })
|
setFormData({ ...formData, password: e.currentTarget.value })
|
||||||
}
|
}
|
||||||
@@ -90,10 +114,18 @@ const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
|
|||||||
Leave password empty to keep the current password
|
Leave password empty to keep the current password
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end" mt="md">
|
<Group justify="flex-end" mt="md">
|
||||||
<Button variant="default" onClick={onClose}>
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="cancel-edit-user-button"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} loading={loading}>
|
<Button
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
loading={loading}
|
||||||
|
data-testid="confirm-edit-user-button"
|
||||||
|
>
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import CreateWorkspaceModal from './CreateWorkspaceModal';
|
||||||
|
import { Theme, type Workspace } from '@/types/models';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { useModalContext } from '../../../contexts/ModalContext';
|
||||||
|
import { createWorkspace } from '@/api/workspace';
|
||||||
|
|
||||||
|
// Mock notifications
|
||||||
|
vi.mock('@mantine/notifications', () => ({
|
||||||
|
notifications: {
|
||||||
|
show: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock ModalContext
|
||||||
|
vi.mock('../../../contexts/ModalContext', () => ({
|
||||||
|
useModalContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock workspace API
|
||||||
|
vi.mock('@/api/workspace', () => ({
|
||||||
|
createWorkspace: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CreateWorkspaceModal', () => {
|
||||||
|
const mockOnWorkspaceCreated = vi.fn();
|
||||||
|
const mockNotificationsShow = vi.mocked(notifications.show);
|
||||||
|
const mockUseModalContext = vi.mocked(useModalContext);
|
||||||
|
const mockCreateWorkspace = vi.mocked(createWorkspace);
|
||||||
|
|
||||||
|
const mockSetCreateWorkspaceModalVisible = vi.fn();
|
||||||
|
const mockModalContext = {
|
||||||
|
newFileModalVisible: false,
|
||||||
|
setNewFileModalVisible: vi.fn(),
|
||||||
|
deleteFileModalVisible: false,
|
||||||
|
setDeleteFileModalVisible: vi.fn(),
|
||||||
|
renameFileModalVisible: false,
|
||||||
|
setRenameFileModalVisible: vi.fn(),
|
||||||
|
commitMessageModalVisible: false,
|
||||||
|
setCommitMessageModalVisible: vi.fn(),
|
||||||
|
settingsModalVisible: false,
|
||||||
|
setSettingsModalVisible: vi.fn(),
|
||||||
|
switchWorkspaceModalVisible: false,
|
||||||
|
setSwitchWorkspaceModalVisible: vi.fn(),
|
||||||
|
createWorkspaceModalVisible: true,
|
||||||
|
setCreateWorkspaceModalVisible: mockSetCreateWorkspaceModalVisible,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkspace: Workspace = {
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
name: 'test-workspace',
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
theme: Theme.Light,
|
||||||
|
autoSave: false,
|
||||||
|
showHiddenFiles: false,
|
||||||
|
gitEnabled: false,
|
||||||
|
gitUrl: '',
|
||||||
|
gitUser: '',
|
||||||
|
gitToken: '',
|
||||||
|
gitAutoCommit: false,
|
||||||
|
gitCommitMsgTemplate: '${action} ${filename}',
|
||||||
|
gitCommitName: '',
|
||||||
|
gitCommitEmail: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockCreateWorkspace.mockResolvedValue(mockWorkspace);
|
||||||
|
mockOnWorkspaceCreated.mockResolvedValue(undefined);
|
||||||
|
mockSetCreateWorkspaceModalVisible.mockClear();
|
||||||
|
mockNotificationsShow.mockClear();
|
||||||
|
mockUseModalContext.mockReturnValue(mockModalContext);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal Visibility and Content', () => {
|
||||||
|
it('renders modal with correct content when opened', () => {
|
||||||
|
render(
|
||||||
|
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Create New Workspace')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('workspace-name-input')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /cancel/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /create/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render when modal is closed', () => {
|
||||||
|
mockUseModalContext.mockReturnValueOnce({
|
||||||
|
...mockModalContext,
|
||||||
|
createWorkspaceModalVisible: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByText('Create New Workspace')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Actions', () => {
|
||||||
|
it('calls onClose when cancel button is clicked', () => {
|
||||||
|
render(
|
||||||
|
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('cancel-create-workspace-button'));
|
||||||
|
expect(mockSetCreateWorkspaceModalVisible).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates workspace name input when typed', () => {
|
||||||
|
render(
|
||||||
|
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameInput = screen.getByTestId('workspace-name-input');
|
||||||
|
fireEvent.change(nameInput, { target: { value: 'my-workspace' } });
|
||||||
|
|
||||||
|
expect((nameInput as HTMLInputElement).value).toBe('my-workspace');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form Validation', () => {
|
||||||
|
it('prevents submission with empty or whitespace-only names', async () => {
|
||||||
|
const testCases = ['', ' ', '\t\n '];
|
||||||
|
|
||||||
|
for (const testValue of testCases) {
|
||||||
|
const { unmount } = render(
|
||||||
|
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameInput = screen.getByTestId('workspace-name-input');
|
||||||
|
const createButton = screen.getByTestId(
|
||||||
|
'confirm-create-workspace-button'
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(nameInput, { target: { value: testValue } });
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNotificationsShow).toHaveBeenCalledWith({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Workspace name is required',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCreateWorkspace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace from workspace names before submission', async () => {
|
||||||
|
render(
|
||||||
|
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameInput = screen.getByTestId('workspace-name-input');
|
||||||
|
const createButton = screen.getByTestId(
|
||||||
|
'confirm-create-workspace-button'
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(nameInput, { target: { value: ' valid-workspace ' } });
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateWorkspace).toHaveBeenCalledWith('valid-workspace');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts various valid workspace name formats', async () => {
|
||||||
|
const validNames = [
|
||||||
|
'simple',
|
||||||
|
'workspace-with-dashes',
|
||||||
|
'workspace_with_underscores',
|
||||||
|
'workspace with spaces',
|
||||||
|
'workspace123',
|
||||||
|
'ワークスペース', // Unicode
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of validNames) {
|
||||||
|
const { unmount } = render(
|
||||||
|
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameInput = screen.getByTestId('workspace-name-input');
|
||||||
|
const createButton = screen.getByTestId(
|
||||||
|
'confirm-create-workspace-button'
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(nameInput, { target: { value: name } });
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateWorkspace).toHaveBeenCalledWith(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockCreateWorkspace.mockResolvedValue(mockWorkspace);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loading States and UI Behavior', () => {
|
||||||
|
it('disables form elements and shows loading during workspace creation', async () => {
|
||||||
|
mockCreateWorkspace.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameInput = screen.getByTestId('workspace-name-input');
|
||||||
|
const createButton = screen.getByTestId(
|
||||||
|
'confirm-create-workspace-button'
|
||||||
|
);
|
||||||
|
const cancelButton = screen.getByTestId('cancel-create-workspace-button');
|
||||||
|
|
||||||
|
fireEvent.change(nameInput, { target: { value: 'loading-test' } });
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(nameInput).toBeDisabled();
|
||||||
|
expect(createButton).toBeDisabled();
|
||||||
|
expect(cancelButton).toBeDisabled();
|
||||||
|
expect(createButton).toHaveAttribute('data-loading', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maintains normal state when not loading', () => {
|
||||||
|
render(
|
||||||
|
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameInput = screen.getByTestId('workspace-name-input');
|
||||||
|
const createButton = screen.getByTestId(
|
||||||
|
'confirm-create-workspace-button'
|
||||||
|
);
|
||||||
|
const cancelButton = screen.getByTestId('cancel-create-workspace-button');
|
||||||
|
|
||||||
|
expect(nameInput).not.toBeDisabled();
|
||||||
|
expect(createButton).not.toBeDisabled();
|
||||||
|
expect(cancelButton).not.toBeDisabled();
|
||||||
|
expect(createButton).not.toHaveAttribute('data-loading', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Workspace Creation Flow', () => {
|
||||||
|
it('completes full successful creation flow', async () => {
|
||||||
|
render(
|
||||||
|
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameInput = screen.getByTestId('workspace-name-input');
|
||||||
|
const createButton = screen.getByTestId(
|
||||||
|
'confirm-create-workspace-button'
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(nameInput, { target: { value: 'new-workspace' } });
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
// API called with correct name
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateWorkspace).toHaveBeenCalledWith('new-workspace');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Success notification shown
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNotificationsShow).toHaveBeenCalledWith({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Workspace created successfully',
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback invoked
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnWorkspaceCreated).toHaveBeenCalledWith(mockWorkspace);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal closed and form cleared
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSetCreateWorkspaceModalVisible).toHaveBeenCalledWith(false);
|
||||||
|
expect((nameInput as HTMLInputElement).value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works without onWorkspaceCreated callback', async () => {
|
||||||
|
render(<CreateWorkspaceModal />);
|
||||||
|
|
||||||
|
const nameInput = screen.getByTestId('workspace-name-input');
|
||||||
|
const createButton = screen.getByTestId(
|
||||||
|
'confirm-create-workspace-button'
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(nameInput, { target: { value: 'no-callback-test' } });
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateWorkspace).toHaveBeenCalledWith('no-callback-test');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNotificationsShow).toHaveBeenCalledWith({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Workspace created successfully',
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('handles API errors gracefully', async () => {
|
||||||
|
mockCreateWorkspace.mockRejectedValue(new Error('Creation failed'));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameInput = screen.getByTestId('workspace-name-input');
|
||||||
|
const createButton = screen.getByTestId(
|
||||||
|
'confirm-create-workspace-button'
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(nameInput, { target: { value: 'error-workspace' } });
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNotificationsShow).toHaveBeenCalledWith({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to create workspace',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal remains open and form retains values
|
||||||
|
expect(mockSetCreateWorkspaceModalVisible).not.toHaveBeenCalledWith(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Create New Workspace')).toBeInTheDocument();
|
||||||
|
expect((nameInput as HTMLInputElement).value).toBe('error-workspace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets loading state after error', async () => {
|
||||||
|
mockCreateWorkspace.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameInput = screen.getByTestId('workspace-name-input');
|
||||||
|
const createButton = screen.getByTestId(
|
||||||
|
'confirm-create-workspace-button'
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(nameInput, { target: { value: 'loading-error' } });
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateWorkspace).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(createButton).not.toHaveAttribute('data-loading', 'true');
|
||||||
|
expect(nameInput).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Keyboard Interactions', () => {
|
||||||
|
it('supports keyboard input in the name field', () => {
|
||||||
|
render(
|
||||||
|
<CreateWorkspaceModal onWorkspaceCreated={mockOnWorkspaceCreated} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameInput = screen.getByTestId('workspace-name-input');
|
||||||
|
|
||||||
|
expect(nameInput).not.toHaveAttribute('disabled');
|
||||||
|
expect(nameInput).not.toHaveAttribute('readonly');
|
||||||
|
|
||||||
|
fireEvent.change(nameInput, { target: { value: 'keyboard-test' } });
|
||||||
|
expect((nameInput as HTMLInputElement).value).toBe('keyboard-test');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
|
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
|
||||||
import { useModalContext } from '../../../contexts/ModalContext';
|
import { useModalContext } from '../../../contexts/ModalContext';
|
||||||
import { createWorkspace } from '../../../services/api';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import type { Workspace } from '@/types/models';
|
||||||
|
import { createWorkspace } from '@/api/workspace';
|
||||||
|
|
||||||
const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
|
interface CreateWorkspaceModalProps {
|
||||||
const [name, setName] = useState('');
|
onWorkspaceCreated?: (workspace: Workspace) => Promise<void>;
|
||||||
const [loading, setLoading] = useState(false);
|
}
|
||||||
|
|
||||||
|
const CreateWorkspaceModal: React.FC<CreateWorkspaceModalProps> = ({
|
||||||
|
onWorkspaceCreated,
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const { createWorkspaceModalVisible, setCreateWorkspaceModalVisible } =
|
const { createWorkspaceModalVisible, setCreateWorkspaceModalVisible } =
|
||||||
useModalContext();
|
useModalContext();
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (): Promise<void> => {
|
||||||
if (!name.trim()) {
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
message: 'Workspace name is required',
|
message: 'Workspace name is required',
|
||||||
@@ -22,7 +30,7 @@ const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const workspace = await createWorkspace(name);
|
const workspace = await createWorkspace(trimmedName);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
message: 'Workspace created successfully',
|
message: 'Workspace created successfully',
|
||||||
@@ -31,9 +39,9 @@ const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
|
|||||||
setName('');
|
setName('');
|
||||||
setCreateWorkspaceModalVisible(false);
|
setCreateWorkspaceModalVisible(false);
|
||||||
if (onWorkspaceCreated) {
|
if (onWorkspaceCreated) {
|
||||||
onWorkspaceCreated(workspace);
|
await onWorkspaceCreated(workspace);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
message: 'Failed to create workspace',
|
message: 'Failed to create workspace',
|
||||||
@@ -54,8 +62,10 @@ const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
|
|||||||
>
|
>
|
||||||
<Box maw={400} mx="auto">
|
<Box maw={400} mx="auto">
|
||||||
<TextInput
|
<TextInput
|
||||||
|
type="text"
|
||||||
label="Workspace Name"
|
label="Workspace Name"
|
||||||
placeholder="Enter workspace name"
|
placeholder="Enter workspace name"
|
||||||
|
data-testid="workspace-name-input"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.currentTarget.value)}
|
onChange={(event) => setName(event.currentTarget.value)}
|
||||||
mb="md"
|
mb="md"
|
||||||
@@ -67,10 +77,15 @@ const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
|
|||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => setCreateWorkspaceModalVisible(false)}
|
onClick={() => setCreateWorkspaceModalVisible(false)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
data-testid="cancel-create-workspace-button"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} loading={loading}>
|
<Button
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
loading={loading}
|
||||||
|
data-testid="confirm-create-workspace-button"
|
||||||
|
>
|
||||||
Create
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
|
||||||
|
|
||||||
const DeleteWorkspaceModal = ({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
workspaceName,
|
|
||||||
}) => (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
title="Delete Workspace"
|
|
||||||
centered
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Stack>
|
|
||||||
<Text>
|
|
||||||
Are you sure you want to delete workspace "{workspaceName}"? This action
|
|
||||||
cannot be undone and all files in this workspace will be permanently
|
|
||||||
deleted.
|
|
||||||
</Text>
|
|
||||||
<Group justify="flex-end" mt="xl">
|
|
||||||
<Button variant="default" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button color="red" onClick={onConfirm}>
|
|
||||||
Delete Workspace
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default DeleteWorkspaceModal;
|
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import DeleteWorkspaceModal from './DeleteWorkspaceModal';
|
||||||
|
|
||||||
|
// Mock notifications
|
||||||
|
vi.mock('@mantine/notifications', () => ({
|
||||||
|
notifications: {
|
||||||
|
show: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DeleteWorkspaceModal', () => {
|
||||||
|
const mockOnConfirm = vi.fn();
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockOnConfirm.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal Visibility and Content', () => {
|
||||||
|
it('renders modal with correct content when opened', () => {
|
||||||
|
render(
|
||||||
|
<DeleteWorkspaceModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
workspaceName="test-workspace"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Delete Workspace')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Are you sure you want to delete workspace "test-workspace"? This action cannot be undone and all files in this workspace will be permanently deleted.'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /cancel/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /delete/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
render(
|
||||||
|
<DeleteWorkspaceModal
|
||||||
|
opened={false}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
workspaceName="test-workspace"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Delete Workspace')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles visibility correctly when opened prop changes', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<DeleteWorkspaceModal
|
||||||
|
opened={false}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
workspaceName="test-workspace"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Delete Workspace')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<TestWrapper>
|
||||||
|
<DeleteWorkspaceModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
workspaceName="test-workspace"
|
||||||
|
/>
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Delete Workspace')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Workspace Name Display', () => {
|
||||||
|
it('displays various workspace name formats correctly', () => {
|
||||||
|
const testCases = [
|
||||||
|
'simple',
|
||||||
|
'workspace-with-dashes',
|
||||||
|
'workspace_with_underscores',
|
||||||
|
'workspace with spaces',
|
||||||
|
'workspace"with@quotes',
|
||||||
|
'ワークスペース', // Unicode
|
||||||
|
'', // Empty string
|
||||||
|
undefined, // Undefined
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach((workspaceName) => {
|
||||||
|
const { unmount } = render(
|
||||||
|
<DeleteWorkspaceModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
workspaceName={workspaceName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayName = workspaceName || '';
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
`Are you sure you want to delete workspace "${displayName}"?`,
|
||||||
|
{ exact: false }
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Actions', () => {
|
||||||
|
it('calls onConfirm when delete button is clicked', async () => {
|
||||||
|
render(
|
||||||
|
<DeleteWorkspaceModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
workspaceName="test-workspace"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteButton = screen.getByTestId(
|
||||||
|
'confirm-delete-workspace-button'
|
||||||
|
);
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when cancel button is clicked', () => {
|
||||||
|
render(
|
||||||
|
<DeleteWorkspaceModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
workspaceName="test-workspace"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelButton = screen.getByTestId('cancel-delete-workspace-button');
|
||||||
|
fireEvent.click(cancelButton);
|
||||||
|
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple rapid clicks gracefully', async () => {
|
||||||
|
render(
|
||||||
|
<DeleteWorkspaceModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
workspaceName="rapid-click-workspace"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteButton = screen.getByTestId(
|
||||||
|
'confirm-delete-workspace-button'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rapidly click multiple times
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
// Component should remain stable
|
||||||
|
expect(screen.getByText('Delete Workspace')).toBeInTheDocument();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('handles deletion errors gracefully without crashing', async () => {
|
||||||
|
mockOnConfirm.mockRejectedValue(new Error('Deletion failed'));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DeleteWorkspaceModal
|
||||||
|
opened={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
workspaceName="error-workspace"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteButton = screen.getByTestId(
|
||||||
|
'confirm-delete-workspace-button'
|
||||||
|
);
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component should remain stable after error
|
||||||
|
expect(screen.getByText('Delete Workspace')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
50
app/src/components/modals/workspace/DeleteWorkspaceModal.tsx
Normal file
50
app/src/components/modals/workspace/DeleteWorkspaceModal.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||||
|
|
||||||
|
interface DeleteUserModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
workspaceName: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteWorkspaceModal: React.FC<DeleteUserModalProps> = ({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
workspaceName,
|
||||||
|
}) => (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Delete Workspace"
|
||||||
|
centered
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text>
|
||||||
|
Are you sure you want to delete workspace "{workspaceName}"?
|
||||||
|
This action cannot be undone and all files in this workspace will be
|
||||||
|
permanently deleted.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" mt="xl">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="cancel-delete-workspace-button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onClick={() => void onConfirm()}
|
||||||
|
data-testid="confirm-delete-workspace-button"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DeleteWorkspaceModal;
|
||||||
175
app/src/components/navigation/UserMenu.test.tsx
Normal file
175
app/src/components/navigation/UserMenu.test.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { render } from '../../test/utils';
|
||||||
|
import UserMenu from './UserMenu';
|
||||||
|
import { UserRole } from '../../types/models';
|
||||||
|
|
||||||
|
// Mock the contexts
|
||||||
|
vi.mock('../../contexts/AuthContext', () => ({
|
||||||
|
useAuth: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the settings components
|
||||||
|
vi.mock('../settings/account/AccountSettings', () => ({
|
||||||
|
default: ({ opened, onClose }: { opened: boolean; onClose: () => void }) => (
|
||||||
|
<div data-testid="account-settings-modal" data-opened={opened}>
|
||||||
|
<button onClick={onClose}>Close Account Settings</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../settings/admin/AdminDashboard', () => ({
|
||||||
|
default: ({ opened, onClose }: { opened: boolean; onClose: () => void }) => (
|
||||||
|
<div data-testid="admin-dashboard-modal" data-opened={opened}>
|
||||||
|
<button onClick={onClose}>Close Admin Dashboard</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('UserMenu', () => {
|
||||||
|
const mockLogout = vi.fn();
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
displayName: 'Test User',
|
||||||
|
role: UserRole.Editor,
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
lastWorkspaceId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const { useAuth } = await import('../../contexts/AuthContext');
|
||||||
|
vi.mocked(useAuth).mockReturnValue({
|
||||||
|
user: mockUser,
|
||||||
|
logout: mockLogout,
|
||||||
|
loading: false,
|
||||||
|
initialized: true,
|
||||||
|
login: vi.fn(),
|
||||||
|
refreshToken: vi.fn(),
|
||||||
|
refreshUser: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders user avatar and shows user info when clicked', async () => {
|
||||||
|
const { getByLabelText, getByText } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserMenu />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find and click the avatar
|
||||||
|
const avatar = getByLabelText('User menu');
|
||||||
|
fireEvent.click(avatar);
|
||||||
|
|
||||||
|
// Check if user info is displayed in popover
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Test User')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows admin dashboard option for admin users only', async () => {
|
||||||
|
// Test admin user sees admin option
|
||||||
|
const { useAuth } = await import('../../contexts/AuthContext');
|
||||||
|
vi.mocked(useAuth).mockReturnValue({
|
||||||
|
user: { ...mockUser, role: UserRole.Admin },
|
||||||
|
logout: mockLogout,
|
||||||
|
loading: false,
|
||||||
|
initialized: true,
|
||||||
|
login: vi.fn(),
|
||||||
|
refreshToken: vi.fn(),
|
||||||
|
refreshUser: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByLabelText, getByText } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserMenu />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const avatar = getByLabelText('User menu');
|
||||||
|
fireEvent.click(avatar);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Admin Dashboard')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens account settings modal when clicked', async () => {
|
||||||
|
const { getByLabelText, getByText, getByTestId } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserMenu />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const avatar = getByLabelText('User menu');
|
||||||
|
fireEvent.click(avatar);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const accountSettingsButton = getByText('Account Settings');
|
||||||
|
fireEvent.click(accountSettingsButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const modal = getByTestId('account-settings-modal');
|
||||||
|
expect(modal).toHaveAttribute('data-opened', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls logout when logout button is clicked', async () => {
|
||||||
|
const { getByLabelText, getByText } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserMenu />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const avatar = getByLabelText('User menu');
|
||||||
|
fireEvent.click(avatar);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const logoutButton = getByText('Logout');
|
||||||
|
fireEvent.click(logoutButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLogout).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays user email when displayName is not available', async () => {
|
||||||
|
const { useAuth } = await import('../../contexts/AuthContext');
|
||||||
|
const userWithoutDisplayName = {
|
||||||
|
id: mockUser.id,
|
||||||
|
email: mockUser.email,
|
||||||
|
role: mockUser.role,
|
||||||
|
createdAt: mockUser.createdAt,
|
||||||
|
lastWorkspaceId: mockUser.lastWorkspaceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(useAuth).mockReturnValue({
|
||||||
|
user: userWithoutDisplayName,
|
||||||
|
logout: mockLogout,
|
||||||
|
loading: false,
|
||||||
|
initialized: true,
|
||||||
|
login: vi.fn(),
|
||||||
|
refreshToken: vi.fn(),
|
||||||
|
refreshUser: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByLabelText, getByText } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<UserMenu />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const avatar = getByLabelText('User menu');
|
||||||
|
fireEvent.click(avatar);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('test@example.com')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,15 +17,19 @@ import {
|
|||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import AccountSettings from '../settings/account/AccountSettings';
|
import AccountSettings from '../settings/account/AccountSettings';
|
||||||
import AdminDashboard from '../settings/admin/AdminDashboard';
|
import AdminDashboard from '../settings/admin/AdminDashboard';
|
||||||
|
import { UserRole } from '@/types/models';
|
||||||
|
import { getHoverStyle } from '@/utils/themeStyles';
|
||||||
|
|
||||||
const UserMenu = () => {
|
const UserMenu: React.FC = () => {
|
||||||
const [accountSettingsOpened, setAccountSettingsOpened] = useState(false);
|
const [accountSettingsOpened, setAccountSettingsOpened] =
|
||||||
const [adminDashboardOpened, setAdminDashboardOpened] = useState(false);
|
useState<boolean>(false);
|
||||||
const [opened, setOpened] = useState(false);
|
const [adminDashboardOpened, setAdminDashboardOpened] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
const [opened, setOpened] = useState<boolean>(false);
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = async (): Promise<void> => {
|
||||||
logout();
|
await logout();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,6 +47,10 @@ const UserMenu = () => {
|
|||||||
radius="xl"
|
radius="xl"
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => setOpened((o) => !o)}
|
onClick={() => setOpened((o) => !o)}
|
||||||
|
aria-label="User menu"
|
||||||
|
aria-expanded={opened}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
role="button"
|
||||||
>
|
>
|
||||||
<IconUser size={24} />
|
<IconUser size={24} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@@ -57,7 +65,7 @@ const UserMenu = () => {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{user.displayName || user.email}
|
{user?.displayName || user?.email}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -72,15 +80,7 @@ const UserMenu = () => {
|
|||||||
}}
|
}}
|
||||||
px="sm"
|
px="sm"
|
||||||
py="xs"
|
py="xs"
|
||||||
style={(theme) => ({
|
style={(theme) => getHoverStyle(theme)}
|
||||||
borderRadius: theme.radius.sm,
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor:
|
|
||||||
theme.colorScheme === 'dark'
|
|
||||||
? theme.colors.dark[5]
|
|
||||||
: theme.colors.gray[0],
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Group>
|
<Group>
|
||||||
<IconSettings size={16} />
|
<IconSettings size={16} />
|
||||||
@@ -88,7 +88,7 @@ const UserMenu = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
|
|
||||||
{user.role === 'admin' && (
|
{user?.role === UserRole.Admin && (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAdminDashboardOpened(true);
|
setAdminDashboardOpened(true);
|
||||||
@@ -96,15 +96,7 @@ const UserMenu = () => {
|
|||||||
}}
|
}}
|
||||||
px="sm"
|
px="sm"
|
||||||
py="xs"
|
py="xs"
|
||||||
style={(theme) => ({
|
style={(theme) => getHoverStyle(theme)}
|
||||||
borderRadius: theme.radius.sm,
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor:
|
|
||||||
theme.colorScheme === 'dark'
|
|
||||||
? theme.colors.dark[5]
|
|
||||||
: theme.colors.gray[0],
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Group>
|
<Group>
|
||||||
<IconUsers size={16} />
|
<IconUsers size={16} />
|
||||||
@@ -114,19 +106,14 @@ const UserMenu = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={handleLogout}
|
onClick={() => {
|
||||||
|
void handleLogout();
|
||||||
|
setOpened(false);
|
||||||
|
}}
|
||||||
px="sm"
|
px="sm"
|
||||||
py="xs"
|
py="xs"
|
||||||
color="red"
|
color="red"
|
||||||
style={(theme) => ({
|
style={(theme) => getHoverStyle(theme)}
|
||||||
borderRadius: theme.radius.sm,
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor:
|
|
||||||
theme.colorScheme === 'dark'
|
|
||||||
? theme.colors.dark[5]
|
|
||||||
: theme.colors.gray[0],
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Group>
|
<Group>
|
||||||
<IconLogout size={16} color="red" />
|
<IconLogout size={16} color="red" />
|
||||||
232
app/src/components/navigation/WorkspaceSwitcher.test.tsx
Normal file
232
app/src/components/navigation/WorkspaceSwitcher.test.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { render } from '../../test/utils';
|
||||||
|
import WorkspaceSwitcher from './WorkspaceSwitcher';
|
||||||
|
import { Theme } from '../../types/models';
|
||||||
|
|
||||||
|
// Mock the hooks and contexts
|
||||||
|
vi.mock('../../hooks/useWorkspace', () => ({
|
||||||
|
useWorkspace: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../contexts/ModalContext', () => ({
|
||||||
|
useModalContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock API
|
||||||
|
vi.mock('../../api/workspace', () => ({
|
||||||
|
listWorkspaces: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the CreateWorkspaceModal component
|
||||||
|
vi.mock('../modals/workspace/CreateWorkspaceModal', () => ({
|
||||||
|
default: ({
|
||||||
|
onWorkspaceCreated,
|
||||||
|
}: {
|
||||||
|
onWorkspaceCreated: (workspace: {
|
||||||
|
name: string;
|
||||||
|
createdAt: number;
|
||||||
|
}) => void;
|
||||||
|
}) => (
|
||||||
|
<div data-testid="create-workspace-modal">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
onWorkspaceCreated({ name: 'New Workspace', createdAt: Date.now() })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Create Test Workspace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('WorkspaceSwitcher', () => {
|
||||||
|
const mockSwitchWorkspace = vi.fn();
|
||||||
|
const mockSetSettingsModalVisible = vi.fn();
|
||||||
|
const mockSetCreateWorkspaceModalVisible = vi.fn();
|
||||||
|
|
||||||
|
const mockCurrentWorkspace = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Current Workspace',
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
theme: Theme.Light,
|
||||||
|
autoSave: false,
|
||||||
|
showHiddenFiles: false,
|
||||||
|
gitEnabled: false,
|
||||||
|
gitUrl: '',
|
||||||
|
gitUser: '',
|
||||||
|
gitToken: '',
|
||||||
|
gitAutoCommit: false,
|
||||||
|
gitCommitMsgTemplate: '${action} ${filename}',
|
||||||
|
gitCommitName: '',
|
||||||
|
gitCommitEmail: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkspaces = [
|
||||||
|
mockCurrentWorkspace,
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Other Workspace',
|
||||||
|
createdAt: '2024-01-02T00:00:00Z',
|
||||||
|
theme: Theme.Dark,
|
||||||
|
autoSave: true,
|
||||||
|
showHiddenFiles: true,
|
||||||
|
gitEnabled: true,
|
||||||
|
gitUrl: 'https://github.com/test/repo',
|
||||||
|
gitUser: 'testuser',
|
||||||
|
gitToken: 'token',
|
||||||
|
gitAutoCommit: true,
|
||||||
|
gitCommitMsgTemplate: 'Auto: ${action} ${filename}',
|
||||||
|
gitCommitName: 'Test User',
|
||||||
|
gitCommitEmail: 'test@example.com',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: mockCurrentWorkspace,
|
||||||
|
workspaces: [],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: false,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: mockSwitchWorkspace,
|
||||||
|
deleteCurrentWorkspace: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { useModalContext } = await import('../../contexts/ModalContext');
|
||||||
|
vi.mocked(useModalContext).mockReturnValue({
|
||||||
|
newFileModalVisible: false,
|
||||||
|
setNewFileModalVisible: vi.fn(),
|
||||||
|
deleteFileModalVisible: false,
|
||||||
|
setDeleteFileModalVisible: vi.fn(),
|
||||||
|
renameFileModalVisible: false,
|
||||||
|
setRenameFileModalVisible: vi.fn(),
|
||||||
|
commitMessageModalVisible: false,
|
||||||
|
setCommitMessageModalVisible: vi.fn(),
|
||||||
|
settingsModalVisible: false,
|
||||||
|
setSettingsModalVisible: mockSetSettingsModalVisible,
|
||||||
|
switchWorkspaceModalVisible: false,
|
||||||
|
setSwitchWorkspaceModalVisible: vi.fn(),
|
||||||
|
createWorkspaceModalVisible: false,
|
||||||
|
setCreateWorkspaceModalVisible: mockSetCreateWorkspaceModalVisible,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { listWorkspaces } = await import('../../api/workspace');
|
||||||
|
vi.mocked(listWorkspaces).mockResolvedValue(mockWorkspaces);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders current workspace name', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<WorkspaceSwitcher />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Current Workspace')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "No workspace" when no current workspace', async () => {
|
||||||
|
const { useWorkspace } = await import('../../hooks/useWorkspace');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: null,
|
||||||
|
workspaces: [],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: false,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: mockSwitchWorkspace,
|
||||||
|
deleteCurrentWorkspace: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByText } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<WorkspaceSwitcher />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('No workspace')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens popover and shows workspace list when clicked', async () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<WorkspaceSwitcher />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click to open popover
|
||||||
|
const trigger = getByText('Current Workspace');
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
|
||||||
|
// Should see the workspaces header and workspace list
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Workspaces')).toBeInTheDocument();
|
||||||
|
expect(getByText('Other Workspace')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches workspace when another workspace is clicked', async () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<WorkspaceSwitcher />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open popover and click on other workspace
|
||||||
|
const trigger = getByText('Current Workspace');
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const otherWorkspace = getByText('Other Workspace');
|
||||||
|
fireEvent.click(otherWorkspace);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSwitchWorkspace).toHaveBeenCalledWith('Other Workspace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens create workspace modal when create button is clicked', async () => {
|
||||||
|
const { getByText, getByLabelText } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<WorkspaceSwitcher />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open popover and click create button
|
||||||
|
const trigger = getByText('Current Workspace');
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const createButton = getByLabelText('Create New Workspace');
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSetCreateWorkspaceModalVisible).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens settings modal when settings button is clicked', async () => {
|
||||||
|
const { getByText, getByLabelText } = render(
|
||||||
|
<TestWrapper>
|
||||||
|
<WorkspaceSwitcher />
|
||||||
|
</TestWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open popover and click settings button
|
||||||
|
const trigger = getByText('Current Workspace');
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const settingsButton = getByLabelText('Workspace Settings');
|
||||||
|
fireEvent.click(settingsButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,21 +15,26 @@ import {
|
|||||||
useMantineTheme,
|
useMantineTheme,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react';
|
import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react';
|
||||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
import { useWorkspace } from '../../hooks/useWorkspace';
|
||||||
import { useModalContext } from '../../contexts/ModalContext';
|
import { useModalContext } from '../../contexts/ModalContext';
|
||||||
import { listWorkspaces } from '../../services/api';
|
import { listWorkspaces } from '../../api/workspace';
|
||||||
import CreateWorkspaceModal from '../modals/workspace/CreateWorkspaceModal';
|
import CreateWorkspaceModal from '../modals/workspace/CreateWorkspaceModal';
|
||||||
|
import type { Workspace } from '@/types/models';
|
||||||
|
import {
|
||||||
|
getConditionalColor,
|
||||||
|
getWorkspacePaperStyle,
|
||||||
|
} from '@/utils/themeStyles';
|
||||||
|
|
||||||
const WorkspaceSwitcher = () => {
|
const WorkspaceSwitcher: React.FC = () => {
|
||||||
const { currentWorkspace, switchWorkspace } = useWorkspace();
|
const { currentWorkspace, switchWorkspace } = useWorkspace();
|
||||||
const { setSettingsModalVisible, setCreateWorkspaceModalVisible } =
|
const { setSettingsModalVisible, setCreateWorkspaceModalVisible } =
|
||||||
useModalContext();
|
useModalContext();
|
||||||
const [workspaces, setWorkspaces] = useState([]);
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [popoverOpened, setPopoverOpened] = useState(false);
|
const [popoverOpened, setPopoverOpened] = useState<boolean>(false);
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
|
|
||||||
const loadWorkspaces = async () => {
|
const loadWorkspaces = async (): Promise<void> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const list = await listWorkspaces();
|
const list = await listWorkspaces();
|
||||||
@@ -40,14 +45,16 @@ const WorkspaceSwitcher = () => {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateWorkspace = () => {
|
const handleCreateWorkspace = (): void => {
|
||||||
setPopoverOpened(false);
|
setPopoverOpened(false);
|
||||||
setCreateWorkspaceModalVisible(true);
|
setCreateWorkspaceModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWorkspaceCreated = async (newWorkspace) => {
|
const handleWorkspaceCreated = async (
|
||||||
|
newWorkspace: Workspace
|
||||||
|
): Promise<void> => {
|
||||||
await loadWorkspaces();
|
await loadWorkspaces();
|
||||||
switchWorkspace(newWorkspace.name);
|
await switchWorkspace(newWorkspace.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -64,7 +71,7 @@ const WorkspaceSwitcher = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPopoverOpened((o) => !o);
|
setPopoverOpened((o) => !o);
|
||||||
if (!popoverOpened) {
|
if (!popoverOpened) {
|
||||||
loadWorkspaces();
|
void loadWorkspaces();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -88,6 +95,7 @@ const WorkspaceSwitcher = () => {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
size="md"
|
size="md"
|
||||||
|
aria-label="Create New Workspace"
|
||||||
onClick={handleCreateWorkspace}
|
onClick={handleCreateWorkspace}
|
||||||
>
|
>
|
||||||
<IconFolderPlus size={16} />
|
<IconFolderPlus size={16} />
|
||||||
@@ -102,30 +110,21 @@ const WorkspaceSwitcher = () => {
|
|||||||
</Center>
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
workspaces.map((workspace) => {
|
workspaces.map((workspace) => {
|
||||||
const isSelected = workspace.name === currentWorkspace?.name;
|
const isSelected = workspace.id === currentWorkspace?.id;
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
key={workspace.name}
|
key={workspace.id}
|
||||||
p="xs"
|
p="xs"
|
||||||
withBorder
|
withBorder
|
||||||
style={{
|
style={(theme) =>
|
||||||
backgroundColor: isSelected
|
getWorkspacePaperStyle(theme, isSelected)
|
||||||
? theme.colors.blue[
|
}
|
||||||
theme.colorScheme === 'dark' ? 8 : 1
|
|
||||||
]
|
|
||||||
: undefined,
|
|
||||||
borderColor: isSelected
|
|
||||||
? theme.colors.blue[
|
|
||||||
theme.colorScheme === 'dark' ? 7 : 5
|
|
||||||
]
|
|
||||||
: undefined,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
switchWorkspace(workspace.name);
|
void switchWorkspace(workspace.name);
|
||||||
setPopoverOpened(false);
|
setPopoverOpened(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -134,25 +133,13 @@ const WorkspaceSwitcher = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
fw={500}
|
fw={500}
|
||||||
truncate
|
truncate
|
||||||
c={
|
c={isSelected ? 'blue' : 'inherit'}
|
||||||
isSelected
|
|
||||||
? theme.colors.blue[
|
|
||||||
theme.colorScheme === 'dark' ? 0 : 9
|
|
||||||
]
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{workspace.name}
|
{workspace.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
c={
|
c={getConditionalColor(theme, isSelected)}
|
||||||
isSelected
|
|
||||||
? theme.colorScheme === 'dark'
|
|
||||||
? theme.colors.blue[2]
|
|
||||||
: theme.colors.blue[7]
|
|
||||||
: 'dimmed'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{new Date(
|
{new Date(
|
||||||
workspace.createdAt
|
workspace.createdAt
|
||||||
@@ -165,11 +152,8 @@ const WorkspaceSwitcher = () => {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
color={
|
color={getConditionalColor(theme, true)}
|
||||||
theme.colorScheme === 'dark'
|
aria-label="Workspace Settings"
|
||||||
? 'blue.2'
|
|
||||||
: 'blue.7'
|
|
||||||
}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSettingsModalVisible(true);
|
setSettingsModalVisible(true);
|
||||||
114
app/src/components/settings/AccordionControl.test.tsx
Normal file
114
app/src/components/settings/AccordionControl.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider, Accordion } from '@mantine/core';
|
||||||
|
import AccordionControl from './AccordionControl';
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test wrapper component to properly provide Accordion context
|
||||||
|
const AccordionWrapper: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
defaultValue?: string[];
|
||||||
|
}> = ({ children, defaultValue = ['test'] }) => (
|
||||||
|
<Accordion defaultValue={defaultValue} multiple>
|
||||||
|
<Accordion.Item value="test">{children}</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('AccordionControl', () => {
|
||||||
|
describe('Normal Operation', () => {
|
||||||
|
it('renders children as Title with order 4', () => {
|
||||||
|
render(
|
||||||
|
<AccordionWrapper>
|
||||||
|
<AccordionControl>Settings Title</AccordionControl>
|
||||||
|
</AccordionWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = screen.getByRole('heading', { level: 4 });
|
||||||
|
expect(title).toHaveTextContent('Settings Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders complex children correctly', () => {
|
||||||
|
render(
|
||||||
|
<AccordionWrapper>
|
||||||
|
<AccordionControl>
|
||||||
|
<span data-testid="complex-child">Complex</span> Content
|
||||||
|
</AccordionControl>
|
||||||
|
</AccordionWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('complex-child')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Complex')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('functions as accordion control', () => {
|
||||||
|
render(
|
||||||
|
<AccordionWrapper defaultValue={[]}>
|
||||||
|
<AccordionControl>Toggle Section</AccordionControl>
|
||||||
|
<Accordion.Panel>Hidden Content</Accordion.Panel>
|
||||||
|
</AccordionWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const control = screen.getByRole('button');
|
||||||
|
fireEvent.click(control);
|
||||||
|
|
||||||
|
expect(screen.getByText('Hidden Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('handles empty children gracefully', () => {
|
||||||
|
render(
|
||||||
|
<AccordionWrapper>
|
||||||
|
<AccordionControl>{''}</AccordionControl>
|
||||||
|
</AccordionWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = screen.getByRole('heading', { level: 4 });
|
||||||
|
expect(title).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through children props correctly', () => {
|
||||||
|
const mockClickHandler = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AccordionWrapper>
|
||||||
|
<AccordionControl>
|
||||||
|
<button onClick={mockClickHandler} data-testid="inner-button">
|
||||||
|
Click Me
|
||||||
|
</button>
|
||||||
|
</AccordionControl>
|
||||||
|
</AccordionWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const innerButton = screen.getByTestId('inner-button');
|
||||||
|
fireEvent.click(innerButton);
|
||||||
|
expect(mockClickHandler).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('provides proper semantic structure', () => {
|
||||||
|
render(
|
||||||
|
<AccordionWrapper>
|
||||||
|
<AccordionControl>Accessible Title</AccordionControl>
|
||||||
|
</AccordionWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = screen.getByRole('heading', { level: 4 });
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
|
||||||
|
expect(title).toHaveTextContent('Accessible Title');
|
||||||
|
expect(button).toContainElement(title);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Accordion, Title } from '@mantine/core';
|
import { Accordion, Title } from '@mantine/core';
|
||||||
|
|
||||||
const AccordionControl = ({ children }) => (
|
interface AccordionControlProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccordionControl: React.FC<AccordionControlProps> = ({ children }) => (
|
||||||
<Accordion.Control>
|
<Accordion.Control>
|
||||||
<Title order={4}>{children}</Title>
|
<Title order={4}>{children}</Title>
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
246
app/src/components/settings/account/AccountSettings.test.tsx
Normal file
246
app/src/components/settings/account/AccountSettings.test.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import AccountSettings from './AccountSettings';
|
||||||
|
|
||||||
|
// Mock the auth context
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: 'test@example.com',
|
||||||
|
displayName: 'Test User',
|
||||||
|
role: 'editor' as const,
|
||||||
|
};
|
||||||
|
const mockRefreshUser = vi.fn();
|
||||||
|
vi.mock('../../../contexts/AuthContext', () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
user: mockUser,
|
||||||
|
refreshUser: mockRefreshUser,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the profile settings hook
|
||||||
|
const mockUpdateProfile = vi.fn();
|
||||||
|
vi.mock('../../../hooks/useProfileSettings', () => ({
|
||||||
|
useProfileSettings: () => ({
|
||||||
|
loading: false,
|
||||||
|
updateProfile: mockUpdateProfile,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock notifications
|
||||||
|
vi.mock('@mantine/notifications', () => ({
|
||||||
|
notifications: {
|
||||||
|
show: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the sub-components
|
||||||
|
vi.mock('./ProfileSettings', () => ({
|
||||||
|
default: ({
|
||||||
|
settings,
|
||||||
|
onInputChange,
|
||||||
|
}: {
|
||||||
|
settings: {
|
||||||
|
displayName?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
onInputChange: (field: string, value: string) => void;
|
||||||
|
}) => (
|
||||||
|
<div data-testid="profile-settings">
|
||||||
|
<input
|
||||||
|
data-testid="display-name-input"
|
||||||
|
value={settings.displayName || ''}
|
||||||
|
onChange={(e) => onInputChange('displayName', e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
data-testid="email-input"
|
||||||
|
value={settings.email || ''}
|
||||||
|
onChange={(e) => onInputChange('email', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./SecuritySettings', () => ({
|
||||||
|
default: ({
|
||||||
|
settings,
|
||||||
|
onInputChange,
|
||||||
|
}: {
|
||||||
|
settings: {
|
||||||
|
currentPassword?: string;
|
||||||
|
newPassword?: string;
|
||||||
|
};
|
||||||
|
onInputChange: (field: string, value: string) => void;
|
||||||
|
}) => (
|
||||||
|
<div data-testid="security-settings">
|
||||||
|
<input
|
||||||
|
data-testid="current-password-input"
|
||||||
|
value={settings.currentPassword || ''}
|
||||||
|
onChange={(e) => onInputChange('currentPassword', e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
data-testid="new-password-input"
|
||||||
|
value={settings.newPassword || ''}
|
||||||
|
onChange={(e) => onInputChange('newPassword', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./DangerZoneSettings', () => ({
|
||||||
|
default: () => <div data-testid="danger-zone-settings">Danger Zone</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../modals/account/EmailPasswordModal', () => ({
|
||||||
|
default: ({
|
||||||
|
opened,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
onConfirm: (password: string) => void;
|
||||||
|
}) =>
|
||||||
|
opened ? (
|
||||||
|
<div data-testid="email-password-modal">
|
||||||
|
<button
|
||||||
|
onClick={() => void onConfirm('test-password')}
|
||||||
|
data-testid="confirm-email"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AccountSettings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUpdateProfile.mockResolvedValue(mockUser);
|
||||||
|
mockRefreshUser.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders modal with all sections', () => {
|
||||||
|
render(<AccountSettings opened={true} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Account Settings')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('profile-settings')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('security-settings')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('danger-zone-settings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows unsaved changes badge when settings are modified', () => {
|
||||||
|
render(<AccountSettings opened={true} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
const displayNameInput = screen.getByTestId('display-name-input');
|
||||||
|
fireEvent.change(displayNameInput, { target: { value: 'Updated Name' } });
|
||||||
|
|
||||||
|
expect(screen.getByText('Unsaved Changes')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables save button when there are changes', () => {
|
||||||
|
render(<AccountSettings opened={true} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: 'Save Changes' });
|
||||||
|
expect(saveButton).toBeDisabled();
|
||||||
|
|
||||||
|
const displayNameInput = screen.getByTestId('display-name-input');
|
||||||
|
fireEvent.change(displayNameInput, { target: { value: 'Updated Name' } });
|
||||||
|
|
||||||
|
expect(saveButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves profile changes successfully', async () => {
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
render(<AccountSettings opened={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
const displayNameInput = screen.getByTestId('display-name-input');
|
||||||
|
fireEvent.change(displayNameInput, { target: { value: 'Updated Name' } });
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: 'Save Changes' });
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateProfile).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ displayName: 'Updated Name' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRefreshUser).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens email confirmation modal for email changes', () => {
|
||||||
|
render(<AccountSettings opened={true} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByTestId('email-input');
|
||||||
|
fireEvent.change(emailInput, { target: { value: 'new@example.com' } });
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: 'Save Changes' });
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('email-password-modal')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes email change with password confirmation', async () => {
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
render(<AccountSettings opened={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByTestId('email-input');
|
||||||
|
fireEvent.change(emailInput, { target: { value: 'new@example.com' } });
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: 'Save Changes' });
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
const confirmButton = screen.getByTestId('confirm-email');
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateProfile).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
email: 'new@example.com',
|
||||||
|
currentPassword: 'test-password',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes modal when cancel is clicked', () => {
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
render(<AccountSettings opened={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
|
||||||
|
fireEvent.click(cancelButton);
|
||||||
|
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
render(<AccountSettings opened={false} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Account Settings')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,24 +16,39 @@ import SecuritySettings from './SecuritySettings';
|
|||||||
import ProfileSettings from './ProfileSettings';
|
import ProfileSettings from './ProfileSettings';
|
||||||
import DangerZoneSettings from './DangerZoneSettings';
|
import DangerZoneSettings from './DangerZoneSettings';
|
||||||
import AccordionControl from '../AccordionControl';
|
import AccordionControl from '../AccordionControl';
|
||||||
|
import {
|
||||||
|
type UserProfileSettings,
|
||||||
|
type ProfileSettingsState,
|
||||||
|
type SettingsAction,
|
||||||
|
SettingsActionType,
|
||||||
|
} from '@/types/models';
|
||||||
|
import { getAccordionStyles } from '@/utils/themeStyles';
|
||||||
|
|
||||||
|
interface AccountSettingsProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
// Reducer for managing settings state
|
// Reducer for managing settings state
|
||||||
const initialState = {
|
const initialState: ProfileSettingsState = {
|
||||||
localSettings: {},
|
localSettings: {},
|
||||||
initialSettings: {},
|
initialSettings: {},
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function settingsReducer(state, action) {
|
function settingsReducer(
|
||||||
|
state: ProfileSettingsState,
|
||||||
|
action: SettingsAction<UserProfileSettings>
|
||||||
|
): ProfileSettingsState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'INIT_SETTINGS':
|
case SettingsActionType.INIT_SETTINGS:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
localSettings: action.payload,
|
localSettings: action.payload || {},
|
||||||
initialSettings: action.payload,
|
initialSettings: action.payload || {},
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
};
|
};
|
||||||
case 'UPDATE_LOCAL_SETTINGS':
|
case SettingsActionType.UPDATE_LOCAL_SETTINGS: {
|
||||||
const newLocalSettings = { ...state.localSettings, ...action.payload };
|
const newLocalSettings = { ...state.localSettings, ...action.payload };
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
JSON.stringify(newLocalSettings) !==
|
JSON.stringify(newLocalSettings) !==
|
||||||
@@ -43,7 +58,8 @@ function settingsReducer(state, action) {
|
|||||||
localSettings: newLocalSettings,
|
localSettings: newLocalSettings,
|
||||||
hasUnsavedChanges: hasChanges,
|
hasUnsavedChanges: hasChanges,
|
||||||
};
|
};
|
||||||
case 'MARK_SAVED':
|
}
|
||||||
|
case SettingsActionType.MARK_SAVED:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
initialSettings: state.localSettings,
|
initialSettings: state.localSettings,
|
||||||
@@ -54,39 +70,51 @@ function settingsReducer(state, action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const AccountSettings = ({ opened, onClose }) => {
|
const AccountSettings: React.FC<AccountSettingsProps> = ({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
const { user, refreshUser } = useAuth();
|
const { user, refreshUser } = useAuth();
|
||||||
const { loading, updateProfile } = useProfileSettings();
|
const { loading, updateProfile } = useProfileSettings();
|
||||||
const [state, dispatch] = useReducer(settingsReducer, initialState);
|
const [state, dispatch] = useReducer(settingsReducer, initialState);
|
||||||
const isInitialMount = useRef(true);
|
const isInitialMount = useRef<boolean>(true);
|
||||||
const [emailModalOpened, setEmailModalOpened] = useState(false);
|
const [emailModalOpened, setEmailModalOpened] = useState<boolean>(false);
|
||||||
|
|
||||||
// Initialize settings on mount
|
// Initialize settings on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInitialMount.current && user) {
|
if (isInitialMount.current && user) {
|
||||||
isInitialMount.current = false;
|
isInitialMount.current = false;
|
||||||
const settings = {
|
const settings: UserProfileSettings = {
|
||||||
displayName: user.displayName,
|
displayName: user.displayName || '',
|
||||||
email: user.email,
|
email: user.email,
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
};
|
};
|
||||||
dispatch({ type: 'INIT_SETTINGS', payload: settings });
|
dispatch({
|
||||||
|
type: SettingsActionType.INIT_SETTINGS,
|
||||||
|
payload: settings,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const handleInputChange = (key, value) => {
|
const handleInputChange = (
|
||||||
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
|
key: keyof UserProfileSettings,
|
||||||
|
value: string
|
||||||
|
): void => {
|
||||||
|
dispatch({
|
||||||
|
type: SettingsActionType.UPDATE_LOCAL_SETTINGS,
|
||||||
|
payload: { [key]: value } as UserProfileSettings,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (): Promise<void> => {
|
||||||
const updates = {};
|
const updates: UserProfileSettings = {};
|
||||||
const needsPasswordConfirmation =
|
const needsPasswordConfirmation =
|
||||||
state.localSettings.email !== state.initialSettings.email;
|
state.localSettings.email !== state.initialSettings.email;
|
||||||
|
|
||||||
// Add display name if changed
|
// Add display name if changed
|
||||||
if (state.localSettings.displayName !== state.initialSettings.displayName) {
|
if (state.localSettings.displayName !== state.initialSettings.displayName) {
|
||||||
updates.displayName = state.localSettings.displayName;
|
updates.displayName = state.localSettings.displayName || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle password change
|
// Handle password change
|
||||||
@@ -106,17 +134,17 @@ const AccountSettings = ({ opened, onClose }) => {
|
|||||||
// If we're only changing display name or have password already provided, proceed directly
|
// If we're only changing display name or have password already provided, proceed directly
|
||||||
if (!needsPasswordConfirmation || state.localSettings.currentPassword) {
|
if (!needsPasswordConfirmation || state.localSettings.currentPassword) {
|
||||||
if (needsPasswordConfirmation) {
|
if (needsPasswordConfirmation) {
|
||||||
updates.email = state.localSettings.email;
|
updates.email = state.localSettings.email || '';
|
||||||
// If we don't have a password change, we still need to include the current password for email change
|
// If we don't have a password change, we still need to include the current password for email change
|
||||||
if (!updates.currentPassword) {
|
if (!updates.currentPassword) {
|
||||||
updates.currentPassword = state.localSettings.currentPassword;
|
updates.currentPassword = state.localSettings.currentPassword || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateProfile(updates);
|
const updatedUser = await updateProfile(updates);
|
||||||
if (result.success) {
|
if (updatedUser) {
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
dispatch({ type: 'MARK_SAVED' });
|
dispatch({ type: SettingsActionType.MARK_SAVED });
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -125,17 +153,20 @@ const AccountSettings = ({ opened, onClose }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEmailConfirm = async (password) => {
|
const handleEmailConfirm = async (password: string): Promise<boolean> => {
|
||||||
const updates = {
|
const updates: UserProfileSettings = {
|
||||||
...state.localSettings,
|
...state.localSettings,
|
||||||
currentPassword: password,
|
currentPassword: password,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove any undefined/empty values
|
// Remove any undefined/empty values
|
||||||
Object.keys(updates).forEach((key) => {
|
Object.keys(updates).forEach((key) => {
|
||||||
if (updates[key] === undefined || updates[key] === '') {
|
const typedKey = key as keyof UserProfileSettings;
|
||||||
delete updates[key];
|
if (updates[typedKey] === undefined || updates[typedKey] === '') {
|
||||||
|
delete updates[typedKey];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove keys that haven't changed
|
// Remove keys that haven't changed
|
||||||
if (updates.displayName === state.initialSettings.displayName) {
|
if (updates.displayName === state.initialSettings.displayName) {
|
||||||
delete updates.displayName;
|
delete updates.displayName;
|
||||||
@@ -144,12 +175,17 @@ const AccountSettings = ({ opened, onClose }) => {
|
|||||||
delete updates.email;
|
delete updates.email;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateProfile(updates);
|
const updatedUser = await updateProfile(updates);
|
||||||
if (result.success) {
|
if (updatedUser) {
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
dispatch({ type: 'MARK_SAVED' });
|
dispatch({ type: SettingsActionType.MARK_SAVED });
|
||||||
setEmailModalOpened(false);
|
setEmailModalOpened(false);
|
||||||
onClose();
|
onClose();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// TODO: Handle errors appropriately
|
||||||
|
// notifications.show({...
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,7 +198,7 @@ const AccountSettings = ({ opened, onClose }) => {
|
|||||||
centered
|
centered
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<Stack spacing="xl">
|
<Stack gap="xl">
|
||||||
{state.hasUnsavedChanges && (
|
{state.hasUnsavedChanges && (
|
||||||
<Badge color="yellow" variant="light">
|
<Badge color="yellow" variant="light">
|
||||||
Unsaved Changes
|
Unsaved Changes
|
||||||
@@ -172,25 +208,7 @@ const AccountSettings = ({ opened, onClose }) => {
|
|||||||
<Accordion
|
<Accordion
|
||||||
defaultValue={['profile', 'security', 'danger']}
|
defaultValue={['profile', 'security', 'danger']}
|
||||||
multiple
|
multiple
|
||||||
styles={(theme) => ({
|
styles={(theme) => getAccordionStyles(theme)}
|
||||||
control: {
|
|
||||||
paddingTop: theme.spacing.md,
|
|
||||||
paddingBottom: theme.spacing.md,
|
|
||||||
},
|
|
||||||
item: {
|
|
||||||
borderBottom: `1px solid ${
|
|
||||||
theme.colorScheme === 'dark'
|
|
||||||
? theme.colors.dark[4]
|
|
||||||
: theme.colors.gray[3]
|
|
||||||
}`,
|
|
||||||
'&[data-active]': {
|
|
||||||
backgroundColor:
|
|
||||||
theme.colorScheme === 'dark'
|
|
||||||
? theme.colors.dark[7]
|
|
||||||
: theme.colors.gray[0],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Accordion.Item value="profile">
|
<Accordion.Item value="profile">
|
||||||
<AccordionControl>Profile</AccordionControl>
|
<AccordionControl>Profile</AccordionControl>
|
||||||
@@ -225,7 +243,7 @@ const AccountSettings = ({ opened, onClose }) => {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={() => void handleSubmit()}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={!state.hasUnsavedChanges}
|
disabled={!state.hasUnsavedChanges}
|
||||||
>
|
>
|
||||||
@@ -239,7 +257,7 @@ const AccountSettings = ({ opened, onClose }) => {
|
|||||||
opened={emailModalOpened}
|
opened={emailModalOpened}
|
||||||
onClose={() => setEmailModalOpened(false)}
|
onClose={() => setEmailModalOpened(false)}
|
||||||
onConfirm={handleEmailConfirm}
|
onConfirm={handleEmailConfirm}
|
||||||
email={state.localSettings.email}
|
email={state.localSettings.email || ''}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
140
app/src/components/settings/account/DangerZoneSettings.test.tsx
Normal file
140
app/src/components/settings/account/DangerZoneSettings.test.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import DangerZoneSettings from './DangerZoneSettings';
|
||||||
|
|
||||||
|
// Mock the auth context
|
||||||
|
const mockLogout = vi.fn();
|
||||||
|
vi.mock('../../../contexts/AuthContext', () => ({
|
||||||
|
useAuth: () => ({ logout: mockLogout }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the profile settings hook
|
||||||
|
const mockDeleteAccount = vi.fn();
|
||||||
|
vi.mock('../../../hooks/useProfileSettings', () => ({
|
||||||
|
useProfileSettings: () => ({ deleteAccount: mockDeleteAccount }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the DeleteAccountModal
|
||||||
|
vi.mock('../../modals/account/DeleteAccountModal', () => ({
|
||||||
|
default: ({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (password: string) => void;
|
||||||
|
}) =>
|
||||||
|
opened ? (
|
||||||
|
<div data-testid="delete-account-modal">
|
||||||
|
<button onClick={onClose} data-testid="modal-close">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onConfirm('test-password')}
|
||||||
|
data-testid="modal-confirm"
|
||||||
|
>
|
||||||
|
Confirm Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DangerZoneSettings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockDeleteAccount.mockResolvedValue(true);
|
||||||
|
mockLogout.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders delete button with warning text', () => {
|
||||||
|
render(<DangerZoneSettings />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'Delete Account' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'Once you delete your account, there is no going back. Please be certain.'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens and closes delete modal', () => {
|
||||||
|
render(<DangerZoneSettings />);
|
||||||
|
|
||||||
|
const deleteButton = screen.getByRole('button', { name: 'Delete Account' });
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('delete-account-modal')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('modal-close'));
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('delete-account-modal')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes account deletion and logout flow', async () => {
|
||||||
|
render(<DangerZoneSettings />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete Account' }));
|
||||||
|
fireEvent.click(screen.getByTestId('modal-confirm'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDeleteAccount).toHaveBeenCalledWith('test-password');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockLogout).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('delete-account-modal')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps modal open when deletion fails', async () => {
|
||||||
|
mockDeleteAccount.mockResolvedValue(false);
|
||||||
|
|
||||||
|
render(<DangerZoneSettings />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete Account' }));
|
||||||
|
fireEvent.click(screen.getByTestId('modal-confirm'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDeleteAccount).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId('delete-account-modal')).toBeInTheDocument();
|
||||||
|
expect(mockLogout).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows cancellation of deletion process', () => {
|
||||||
|
render(<DangerZoneSettings />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete Account' }));
|
||||||
|
fireEvent.click(screen.getByTestId('modal-close'));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('delete-account-modal')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(mockDeleteAccount).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,16 +4,16 @@ import DeleteAccountModal from '../../modals/account/DeleteAccountModal';
|
|||||||
import { useAuth } from '../../../contexts/AuthContext';
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
import { useProfileSettings } from '../../../hooks/useProfileSettings';
|
import { useProfileSettings } from '../../../hooks/useProfileSettings';
|
||||||
|
|
||||||
const DangerZoneSettings = () => {
|
const DangerZoneSettings: React.FC = () => {
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const { deleteAccount } = useProfileSettings();
|
const { deleteAccount } = useProfileSettings();
|
||||||
const [deleteModalOpened, setDeleteModalOpened] = useState(false);
|
const [deleteModalOpened, setDeleteModalOpened] = useState<boolean>(false);
|
||||||
|
|
||||||
const handleDelete = async (password) => {
|
const handleDelete = async (password: string): Promise<void> => {
|
||||||
const result = await deleteAccount(password);
|
const success = await deleteAccount(password);
|
||||||
if (result.success) {
|
if (success) {
|
||||||
setDeleteModalOpened(false);
|
setDeleteModalOpened(false);
|
||||||
logout();
|
await logout();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
113
app/src/components/settings/account/ProfileSettings.test.tsx
Normal file
113
app/src/components/settings/account/ProfileSettings.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import ProfileSettings from './ProfileSettings';
|
||||||
|
import type { UserProfileSettings } from '@/types/models';
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ProfileSettings', () => {
|
||||||
|
const mockOnInputChange = vi.fn();
|
||||||
|
|
||||||
|
const defaultSettings: UserProfileSettings = {
|
||||||
|
displayName: 'John Doe',
|
||||||
|
email: 'john.doe@example.com',
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptySettings: UserProfileSettings = {
|
||||||
|
displayName: '',
|
||||||
|
email: '',
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders form fields with current values', () => {
|
||||||
|
render(
|
||||||
|
<ProfileSettings
|
||||||
|
settings={defaultSettings}
|
||||||
|
onInputChange={mockOnInputChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayNameInput = screen.getByTestId('display-name-input');
|
||||||
|
const emailInput = screen.getByTestId('email-input');
|
||||||
|
|
||||||
|
expect(displayNameInput).toHaveValue('John Doe');
|
||||||
|
expect(emailInput).toHaveValue('john.doe@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with empty settings', () => {
|
||||||
|
render(
|
||||||
|
<ProfileSettings
|
||||||
|
settings={emptySettings}
|
||||||
|
onInputChange={mockOnInputChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayNameInput = screen.getByTestId('display-name-input');
|
||||||
|
const emailInput = screen.getByTestId('email-input');
|
||||||
|
|
||||||
|
expect(displayNameInput).toHaveValue('');
|
||||||
|
expect(emailInput).toHaveValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onInputChange when display name is modified', () => {
|
||||||
|
render(
|
||||||
|
<ProfileSettings
|
||||||
|
settings={defaultSettings}
|
||||||
|
onInputChange={mockOnInputChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayNameInput = screen.getByTestId('display-name-input');
|
||||||
|
fireEvent.change(displayNameInput, { target: { value: 'Jane Smith' } });
|
||||||
|
|
||||||
|
expect(mockOnInputChange).toHaveBeenCalledWith('displayName', 'Jane Smith');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onInputChange when email is modified', () => {
|
||||||
|
render(
|
||||||
|
<ProfileSettings
|
||||||
|
settings={defaultSettings}
|
||||||
|
onInputChange={mockOnInputChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailInput = screen.getByTestId('email-input');
|
||||||
|
fireEvent.change(emailInput, { target: { value: 'jane@example.com' } });
|
||||||
|
|
||||||
|
expect(mockOnInputChange).toHaveBeenCalledWith('email', 'jane@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct input types and accessibility', () => {
|
||||||
|
render(
|
||||||
|
<ProfileSettings
|
||||||
|
settings={defaultSettings}
|
||||||
|
onInputChange={mockOnInputChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayNameInput = screen.getByTestId('display-name-input');
|
||||||
|
const emailInput = screen.getByTestId('email-input');
|
||||||
|
|
||||||
|
expect(displayNameInput).toHaveAttribute('type', 'text');
|
||||||
|
expect(emailInput).toHaveAttribute('type', 'email');
|
||||||
|
expect(displayNameInput).toHaveAccessibleName();
|
||||||
|
expect(emailInput).toHaveAccessibleName();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +1,33 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Stack, TextInput } from '@mantine/core';
|
import { Box, Stack, TextInput } from '@mantine/core';
|
||||||
|
import type { UserProfileSettings } from '@/types/models';
|
||||||
|
|
||||||
const ProfileSettings = ({ settings, onInputChange }) => (
|
interface ProfileSettingsProps {
|
||||||
|
settings: UserProfileSettings;
|
||||||
|
onInputChange: (key: keyof UserProfileSettings, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||||
|
settings,
|
||||||
|
onInputChange,
|
||||||
|
}) => (
|
||||||
<Box>
|
<Box>
|
||||||
<Stack spacing="md">
|
<Stack gap="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Display Name"
|
label="Display Name"
|
||||||
|
type="text"
|
||||||
value={settings.displayName || ''}
|
value={settings.displayName || ''}
|
||||||
onChange={(e) => onInputChange('displayName', e.currentTarget.value)}
|
onChange={(e) => onInputChange('displayName', e.currentTarget.value)}
|
||||||
placeholder="Enter display name"
|
placeholder="Enter display name"
|
||||||
|
data-testid="display-name-input"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Email"
|
label="Email"
|
||||||
|
type="email"
|
||||||
value={settings.email || ''}
|
value={settings.email || ''}
|
||||||
onChange={(e) => onInputChange('email', e.currentTarget.value)}
|
onChange={(e) => onInputChange('email', e.currentTarget.value)}
|
||||||
placeholder="Enter email"
|
placeholder="Enter email"
|
||||||
|
data-testid="email-input"
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
137
app/src/components/settings/account/SecuritySettings.test.tsx
Normal file
137
app/src/components/settings/account/SecuritySettings.test.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import SecuritySettings from './SecuritySettings';
|
||||||
|
import type { UserProfileSettings } from '@/types/models';
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SecuritySettings', () => {
|
||||||
|
const mockOnInputChange = vi.fn();
|
||||||
|
|
||||||
|
const defaultSettings: UserProfileSettings = {
|
||||||
|
displayName: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all password fields', () => {
|
||||||
|
render(
|
||||||
|
<SecuritySettings
|
||||||
|
settings={defaultSettings}
|
||||||
|
onInputChange={mockOnInputChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Current Password')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('New Password')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Confirm New Password')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onInputChange for current password', () => {
|
||||||
|
render(
|
||||||
|
<SecuritySettings
|
||||||
|
settings={defaultSettings}
|
||||||
|
onInputChange={mockOnInputChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentPasswordInput = screen.getByLabelText('Current Password');
|
||||||
|
fireEvent.change(currentPasswordInput, { target: { value: 'oldpass123' } });
|
||||||
|
|
||||||
|
expect(mockOnInputChange).toHaveBeenCalledWith(
|
||||||
|
'currentPassword',
|
||||||
|
'oldpass123'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onInputChange for new password', () => {
|
||||||
|
render(
|
||||||
|
<SecuritySettings
|
||||||
|
settings={defaultSettings}
|
||||||
|
onInputChange={mockOnInputChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const newPasswordInput = screen.getByLabelText('New Password');
|
||||||
|
fireEvent.change(newPasswordInput, { target: { value: 'newpass123' } });
|
||||||
|
|
||||||
|
expect(mockOnInputChange).toHaveBeenCalledWith('newPassword', 'newpass123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when passwords do not match', () => {
|
||||||
|
render(
|
||||||
|
<SecuritySettings
|
||||||
|
settings={{ ...defaultSettings, newPassword: 'password123' }}
|
||||||
|
onInputChange={mockOnInputChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmPasswordInput = screen.getByLabelText('Confirm New Password');
|
||||||
|
fireEvent.change(confirmPasswordInput, {
|
||||||
|
target: { value: 'different123' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Passwords do not match')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears error when passwords match', () => {
|
||||||
|
render(
|
||||||
|
<SecuritySettings
|
||||||
|
settings={{ ...defaultSettings, newPassword: 'password123' }}
|
||||||
|
onInputChange={mockOnInputChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmPasswordInput = screen.getByLabelText('Confirm New Password');
|
||||||
|
|
||||||
|
// First make them not match
|
||||||
|
fireEvent.change(confirmPasswordInput, {
|
||||||
|
target: { value: 'different123' },
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Passwords do not match')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Then make them match
|
||||||
|
fireEvent.change(confirmPasswordInput, {
|
||||||
|
target: { value: 'password123' },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.queryByText('Passwords do not match')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct input types and help text', () => {
|
||||||
|
render(
|
||||||
|
<SecuritySettings
|
||||||
|
settings={defaultSettings}
|
||||||
|
onInputChange={mockOnInputChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentPasswordInput = screen.getByLabelText('Current Password');
|
||||||
|
const newPasswordInput = screen.getByLabelText('New Password');
|
||||||
|
const confirmPasswordInput = screen.getByLabelText('Confirm New Password');
|
||||||
|
|
||||||
|
expect(currentPasswordInput).toHaveAttribute('type', 'password');
|
||||||
|
expect(newPasswordInput).toHaveAttribute('type', 'password');
|
||||||
|
expect(confirmPasswordInput).toHaveAttribute('type', 'password');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Password must be at least 8 characters long/)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,22 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Box, PasswordInput, Stack, Text } from '@mantine/core';
|
import { Box, PasswordInput, Stack, Text } from '@mantine/core';
|
||||||
|
import type { UserProfileSettings } from '@/types/models';
|
||||||
|
|
||||||
const SecuritySettings = ({ settings, onInputChange }) => {
|
interface SecuritySettingsProps {
|
||||||
|
settings: UserProfileSettings;
|
||||||
|
onInputChange: (key: keyof UserProfileSettings, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasswordField = 'currentPassword' | 'newPassword' | 'confirmNewPassword';
|
||||||
|
|
||||||
|
const SecuritySettings: React.FC<SecuritySettingsProps> = ({
|
||||||
|
settings,
|
||||||
|
onInputChange,
|
||||||
|
}) => {
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const handlePasswordChange = (field, value) => {
|
const handlePasswordChange = (field: PasswordField, value: string) => {
|
||||||
if (field === 'confirmNewPassword') {
|
if (field === 'confirmNewPassword') {
|
||||||
setConfirmPassword(value);
|
setConfirmPassword(value);
|
||||||
// Check if passwords match when either password field changes
|
// Check if passwords match when either password field changes
|
||||||
@@ -27,9 +38,10 @@ const SecuritySettings = ({ settings, onInputChange }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Stack spacing="md">
|
<Stack gap="md">
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Current Password"
|
label="Current Password"
|
||||||
|
type="password"
|
||||||
value={settings.currentPassword || ''}
|
value={settings.currentPassword || ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handlePasswordChange('currentPassword', e.currentTarget.value)
|
handlePasswordChange('currentPassword', e.currentTarget.value)
|
||||||
@@ -38,6 +50,7 @@ const SecuritySettings = ({ settings, onInputChange }) => {
|
|||||||
/>
|
/>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="New Password"
|
label="New Password"
|
||||||
|
type="password"
|
||||||
value={settings.newPassword || ''}
|
value={settings.newPassword || ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handlePasswordChange('newPassword', e.currentTarget.value)
|
handlePasswordChange('newPassword', e.currentTarget.value)
|
||||||
@@ -46,6 +59,7 @@ const SecuritySettings = ({ settings, onInputChange }) => {
|
|||||||
/>
|
/>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Confirm New Password"
|
label="Confirm New Password"
|
||||||
|
type="password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handlePasswordChange('confirmNewPassword', e.currentTarget.value)
|
handlePasswordChange('confirmNewPassword', e.currentTarget.value)
|
||||||
@@ -55,7 +69,7 @@ const SecuritySettings = ({ settings, onInputChange }) => {
|
|||||||
/>
|
/>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Password must be at least 8 characters long. Leave password fields
|
Password must be at least 8 characters long. Leave password fields
|
||||||
empty if you don't want to change it.
|
empty if you don't want to change it.
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
110
app/src/components/settings/admin/AdminDashboard.test.tsx
Normal file
110
app/src/components/settings/admin/AdminDashboard.test.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import AdminDashboard from './AdminDashboard';
|
||||||
|
import { UserRole, type User } from '@/types/models';
|
||||||
|
|
||||||
|
// Mock the auth context
|
||||||
|
const mockCurrentUser: User = {
|
||||||
|
id: 1,
|
||||||
|
email: 'admin@example.com',
|
||||||
|
displayName: 'Admin User',
|
||||||
|
role: UserRole.Admin,
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
lastWorkspaceId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../../contexts/AuthContext', () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
user: mockCurrentUser,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the sub-components
|
||||||
|
vi.mock('./AdminUsersTab', () => ({
|
||||||
|
default: ({ currentUser }: { currentUser: User }) => (
|
||||||
|
<div data-testid="admin-users-tab">Users Tab - {currentUser.email}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./AdminWorkspacesTab', () => ({
|
||||||
|
default: () => <div data-testid="admin-workspaces-tab">Workspaces Tab</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./AdminStatsTab', () => ({
|
||||||
|
default: () => <div data-testid="admin-stats-tab">Stats Tab</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AdminDashboard', () => {
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders modal with all tabs', () => {
|
||||||
|
render(<AdminDashboard opened={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Admin Dashboard')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('tab', { name: /users/i })).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('tab', { name: /workspaces/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('tab', { name: /statistics/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows users tab by default', () => {
|
||||||
|
render(<AdminDashboard opened={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('admin-users-tab')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('Users Tab - admin@example.com')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to workspaces tab when clicked', () => {
|
||||||
|
render(<AdminDashboard opened={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /workspaces/i }));
|
||||||
|
|
||||||
|
expect(screen.getByTestId('admin-workspaces-tab')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Workspaces Tab')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to statistics tab when clicked', () => {
|
||||||
|
render(<AdminDashboard opened={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /statistics/i }));
|
||||||
|
|
||||||
|
expect(screen.getByTestId('admin-stats-tab')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Stats Tab')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes current user to users tab', () => {
|
||||||
|
render(<AdminDashboard opened={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
// Should pass current user to AdminUsersTab
|
||||||
|
expect(
|
||||||
|
screen.getByText('Users Tab - admin@example.com')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
render(<AdminDashboard opened={false} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Admin Dashboard')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,13 +6,23 @@ import AdminUsersTab from './AdminUsersTab';
|
|||||||
import AdminWorkspacesTab from './AdminWorkspacesTab';
|
import AdminWorkspacesTab from './AdminWorkspacesTab';
|
||||||
import AdminStatsTab from './AdminStatsTab';
|
import AdminStatsTab from './AdminStatsTab';
|
||||||
|
|
||||||
const AdminDashboard = ({ opened, onClose }) => {
|
interface AdminDashboardProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminTabValue = 'users' | 'workspaces' | 'stats';
|
||||||
|
|
||||||
|
const AdminDashboard: React.FC<AdminDashboardProps> = ({ opened, onClose }) => {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const [activeTab, setActiveTab] = useState('users');
|
const [activeTab, setActiveTab] = useState<AdminTabValue>('users');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal opened={opened} onClose={onClose} size="xl" title="Admin Dashboard">
|
<Modal opened={opened} onClose={onClose} size="xl" title="Admin Dashboard">
|
||||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(value) => setActiveTab(value as AdminTabValue)}
|
||||||
|
>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
|
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
|
||||||
Users
|
Users
|
||||||
@@ -26,7 +36,7 @@ const AdminDashboard = ({ opened, onClose }) => {
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="users" pt="md">
|
<Tabs.Panel value="users" pt="md">
|
||||||
<AdminUsersTab currentUser={currentUser} />
|
{currentUser && <AdminUsersTab currentUser={currentUser} />}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="workspaces" pt="md">
|
<Tabs.Panel value="workspaces" pt="md">
|
||||||
126
app/src/components/settings/admin/AdminStatsTab.test.tsx
Normal file
126
app/src/components/settings/admin/AdminStatsTab.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render as rtlRender, screen } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import AdminStatsTab from './AdminStatsTab';
|
||||||
|
import type { SystemStats } from '@/types/models';
|
||||||
|
|
||||||
|
// Mock the admin data hook
|
||||||
|
vi.mock('../../../hooks/useAdminData', () => ({
|
||||||
|
useAdminData: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the formatBytes utility
|
||||||
|
vi.mock('../../../utils/formatBytes', () => ({
|
||||||
|
formatBytes: (bytes: number) => `${bytes} bytes`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AdminStatsTab', () => {
|
||||||
|
const mockStats: SystemStats = {
|
||||||
|
totalUsers: 150,
|
||||||
|
activeUsers: 120,
|
||||||
|
totalWorkspaces: 85,
|
||||||
|
totalFiles: 2500,
|
||||||
|
totalSize: 1073741824, // 1GB in bytes
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||||
|
vi.mocked(useAdminData).mockReturnValue({
|
||||||
|
data: mockStats,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
reload: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders statistics table with all metrics', () => {
|
||||||
|
render(<AdminStatsTab />);
|
||||||
|
|
||||||
|
expect(screen.getByText('System Statistics')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Total Users')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Active Users')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Total Workspaces')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Total Files')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Total Storage Size')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays correct statistics values', () => {
|
||||||
|
render(<AdminStatsTab />);
|
||||||
|
|
||||||
|
expect(screen.getByText('150')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('120')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('85')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2500')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1073741824 bytes')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state', async () => {
|
||||||
|
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||||
|
vi.mocked(useAdminData).mockReturnValue({
|
||||||
|
data: {} as SystemStats,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
reload: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AdminStatsTab />);
|
||||||
|
|
||||||
|
// Mantine LoadingOverlay should be visible
|
||||||
|
expect(
|
||||||
|
document.querySelector('.mantine-LoadingOverlay-root')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state', async () => {
|
||||||
|
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||||
|
vi.mocked(useAdminData).mockReturnValue({
|
||||||
|
data: {} as SystemStats,
|
||||||
|
loading: false,
|
||||||
|
error: 'Failed to load statistics',
|
||||||
|
reload: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AdminStatsTab />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Failed to load statistics')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles zero values correctly', async () => {
|
||||||
|
const zeroStats: SystemStats = {
|
||||||
|
totalUsers: 0,
|
||||||
|
activeUsers: 0,
|
||||||
|
totalWorkspaces: 0,
|
||||||
|
totalFiles: 0,
|
||||||
|
totalSize: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||||
|
vi.mocked(useAdminData).mockReturnValue({
|
||||||
|
data: zeroStats,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
reload: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AdminStatsTab />);
|
||||||
|
|
||||||
|
// Should display zeros without issues
|
||||||
|
const zeros = screen.getAllByText('0');
|
||||||
|
expect(zeros.length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText('0 bytes')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,8 +4,13 @@ import { IconAlertCircle } from '@tabler/icons-react';
|
|||||||
import { useAdminData } from '../../../hooks/useAdminData';
|
import { useAdminData } from '../../../hooks/useAdminData';
|
||||||
import { formatBytes } from '../../../utils/formatBytes';
|
import { formatBytes } from '../../../utils/formatBytes';
|
||||||
|
|
||||||
const AdminStatsTab = () => {
|
interface StatsRow {
|
||||||
const { data: stats, loading, error } = useAdminData('stats');
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminStatsTab: React.FC = () => {
|
||||||
|
const { data: stats, loading, error } = useAdminData<'stats'>('stats');
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingOverlay visible={true} />;
|
return <LoadingOverlay visible={true} />;
|
||||||
@@ -19,7 +24,7 @@ const AdminStatsTab = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statsRows = [
|
const statsRows: StatsRow[] = [
|
||||||
{ label: 'Total Users', value: stats.totalUsers },
|
{ label: 'Total Users', value: stats.totalUsers },
|
||||||
{ label: 'Active Users', value: stats.activeUsers },
|
{ label: 'Active Users', value: stats.activeUsers },
|
||||||
{ label: 'Total Workspaces', value: stats.totalWorkspaces },
|
{ label: 'Total Workspaces', value: stats.totalWorkspaces },
|
||||||
@@ -33,7 +38,7 @@ const AdminStatsTab = () => {
|
|||||||
System Statistics
|
System Statistics
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Table striped highlightOnHover withBorder>
|
<Table striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Metric</Table.Th>
|
<Table.Th>Metric</Table.Th>
|
||||||
288
app/src/components/settings/admin/AdminUsersTab.test.tsx
Normal file
288
app/src/components/settings/admin/AdminUsersTab.test.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import AdminUsersTab from './AdminUsersTab';
|
||||||
|
import { UserRole, type User } from '@/types/models';
|
||||||
|
|
||||||
|
// Mock the user admin hook
|
||||||
|
const mockCreate = vi.fn();
|
||||||
|
const mockUpdate = vi.fn();
|
||||||
|
const mockDelete = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/useUserAdmin', () => ({
|
||||||
|
useUserAdmin: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock notifications
|
||||||
|
vi.mock('@mantine/notifications', () => ({
|
||||||
|
notifications: {
|
||||||
|
show: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the user modals
|
||||||
|
vi.mock('../../modals/user/CreateUserModal', () => ({
|
||||||
|
default: ({
|
||||||
|
opened,
|
||||||
|
onCreateUser,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
onCreateUser: (userData: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
displayName: string;
|
||||||
|
role: UserRole;
|
||||||
|
}) => Promise<boolean>;
|
||||||
|
}) =>
|
||||||
|
opened ? (
|
||||||
|
<div data-testid="create-user-modal">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
void onCreateUser({
|
||||||
|
email: 'new@example.com',
|
||||||
|
password: 'pass',
|
||||||
|
displayName: 'New User',
|
||||||
|
role: UserRole.Editor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data-testid="create-user-button"
|
||||||
|
>
|
||||||
|
Create User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../modals/user/EditUserModal', () => ({
|
||||||
|
default: ({
|
||||||
|
opened,
|
||||||
|
onEditUser,
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
onEditUser: (
|
||||||
|
userId: number,
|
||||||
|
userData: { email: string }
|
||||||
|
) => Promise<boolean>;
|
||||||
|
user: User | null;
|
||||||
|
}) =>
|
||||||
|
opened ? (
|
||||||
|
<div data-testid="edit-user-modal">
|
||||||
|
<span data-testid="edit-user-email">{user?.email}</span>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
void onEditUser(user?.id || 0, { email: 'updated@example.com' })
|
||||||
|
}
|
||||||
|
data-testid="edit-user-button"
|
||||||
|
>
|
||||||
|
Update User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../modals/user/DeleteUserModal', () => ({
|
||||||
|
default: ({
|
||||||
|
opened,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
}) =>
|
||||||
|
opened ? (
|
||||||
|
<div data-testid="delete-user-modal">
|
||||||
|
<button
|
||||||
|
onClick={() => void onConfirm()}
|
||||||
|
data-testid="delete-user-button"
|
||||||
|
>
|
||||||
|
Delete User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AdminUsersTab', () => {
|
||||||
|
const mockCurrentUser: User = {
|
||||||
|
id: 1,
|
||||||
|
email: 'admin@example.com',
|
||||||
|
displayName: 'Admin User',
|
||||||
|
role: UserRole.Admin,
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
lastWorkspaceId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUsers: User[] = [
|
||||||
|
mockCurrentUser,
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
email: 'editor@example.com',
|
||||||
|
displayName: 'Editor User',
|
||||||
|
role: UserRole.Editor,
|
||||||
|
createdAt: '2024-01-15T00:00:00Z',
|
||||||
|
lastWorkspaceId: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
email: 'viewer@example.com',
|
||||||
|
displayName: 'Viewer User',
|
||||||
|
role: UserRole.Viewer,
|
||||||
|
createdAt: '2024-02-01T00:00:00Z',
|
||||||
|
lastWorkspaceId: 3,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockCreate.mockResolvedValue(true);
|
||||||
|
mockUpdate.mockResolvedValue(true);
|
||||||
|
mockDelete.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const { useUserAdmin } = await import('../../../hooks/useUserAdmin');
|
||||||
|
vi.mocked(useUserAdmin).mockReturnValue({
|
||||||
|
users: mockUsers,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
create: mockCreate,
|
||||||
|
update: mockUpdate,
|
||||||
|
delete: mockDelete,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders users table with all users', () => {
|
||||||
|
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('User Management')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('admin@example.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('editor@example.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('viewer@example.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Admin User')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Editor User')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Viewer User')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows create user button', () => {
|
||||||
|
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /create user/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens create user modal when create button is clicked', () => {
|
||||||
|
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create user/i }));
|
||||||
|
|
||||||
|
expect(screen.getByTestId('create-user-modal')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates new user successfully', async () => {
|
||||||
|
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create user/i }));
|
||||||
|
fireEvent.click(screen.getByTestId('create-user-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith({
|
||||||
|
email: 'new@example.com',
|
||||||
|
password: 'pass',
|
||||||
|
displayName: 'New User',
|
||||||
|
role: UserRole.Editor,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens edit modal when edit button is clicked', () => {
|
||||||
|
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||||
|
|
||||||
|
const editButtons = screen.getAllByLabelText(/edit/i);
|
||||||
|
expect(editButtons[0]).toBeDefined();
|
||||||
|
fireEvent.click(editButtons[0]!); // Click first edit button
|
||||||
|
|
||||||
|
expect(screen.getByTestId('edit-user-modal')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('edit-user-email')).toHaveTextContent(
|
||||||
|
'admin@example.com'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates user successfully', async () => {
|
||||||
|
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||||
|
|
||||||
|
const editButtons = screen.getAllByLabelText(/edit/i);
|
||||||
|
expect(editButtons[0]).toBeDefined();
|
||||||
|
fireEvent.click(editButtons[0]!);
|
||||||
|
fireEvent.click(screen.getByTestId('edit-user-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(1, {
|
||||||
|
email: 'updated@example.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents deleting current user', () => {
|
||||||
|
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByLabelText(/delete/i);
|
||||||
|
const currentUserDeleteButton = deleteButtons[0]; // First user is current user
|
||||||
|
|
||||||
|
expect(currentUserDeleteButton).toBeDefined();
|
||||||
|
expect(currentUserDeleteButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows deleting other users', () => {
|
||||||
|
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByLabelText(/delete/i);
|
||||||
|
expect(deleteButtons[1]).toBeDefined();
|
||||||
|
fireEvent.click(deleteButtons[1]!); // Click delete for second user
|
||||||
|
|
||||||
|
expect(screen.getByTestId('delete-user-modal')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes user successfully', async () => {
|
||||||
|
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByLabelText(/delete/i);
|
||||||
|
expect(deleteButtons[1]).toBeDefined();
|
||||||
|
fireEvent.click(deleteButtons[1]!);
|
||||||
|
fireEvent.click(screen.getByTestId('delete-user-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith(2); // Second user's ID
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state when loading fails', async () => {
|
||||||
|
const { useUserAdmin } = await import('../../../hooks/useUserAdmin');
|
||||||
|
vi.mocked(useUserAdmin).mockReturnValue({
|
||||||
|
users: [],
|
||||||
|
loading: false,
|
||||||
|
error: 'Failed to load users',
|
||||||
|
create: mockCreate,
|
||||||
|
update: mockUpdate,
|
||||||
|
delete: mockDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AdminUsersTab currentUser={mockCurrentUser} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Failed to load users')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,8 +20,14 @@ import { useUserAdmin } from '../../../hooks/useUserAdmin';
|
|||||||
import CreateUserModal from '../../modals/user/CreateUserModal';
|
import CreateUserModal from '../../modals/user/CreateUserModal';
|
||||||
import EditUserModal from '../../modals/user/EditUserModal';
|
import EditUserModal from '../../modals/user/EditUserModal';
|
||||||
import DeleteUserModal from '../../modals/user/DeleteUserModal';
|
import DeleteUserModal from '../../modals/user/DeleteUserModal';
|
||||||
|
import type { User } from '@/types/models';
|
||||||
|
import type { CreateUserRequest, UpdateUserRequest } from '@/types/api';
|
||||||
|
|
||||||
const AdminUsersTab = ({ currentUser }) => {
|
interface AdminUsersTabProps {
|
||||||
|
currentUser: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminUsersTab: React.FC<AdminUsersTabProps> = ({ currentUser }) => {
|
||||||
const {
|
const {
|
||||||
users,
|
users,
|
||||||
loading,
|
loading,
|
||||||
@@ -31,19 +37,24 @@ const AdminUsersTab = ({ currentUser }) => {
|
|||||||
delete: deleteUser,
|
delete: deleteUser,
|
||||||
} = useUserAdmin();
|
} = useUserAdmin();
|
||||||
|
|
||||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
const [createModalOpened, setCreateModalOpened] = useState<boolean>(false);
|
||||||
const [editModalData, setEditModalData] = useState(null);
|
const [editModalData, setEditModalData] = useState<User | null>(null);
|
||||||
const [deleteModalData, setDeleteModalData] = useState(null);
|
const [deleteModalData, setDeleteModalData] = useState<User | null>(null);
|
||||||
|
|
||||||
const handleCreateUser = async (userData) => {
|
const handleCreateUser = async (
|
||||||
|
userData: CreateUserRequest
|
||||||
|
): Promise<boolean> => {
|
||||||
return await create(userData);
|
return await create(userData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditUser = async (id, userData) => {
|
const handleEditUser = async (
|
||||||
|
id: number,
|
||||||
|
userData: UpdateUserRequest
|
||||||
|
): Promise<boolean> => {
|
||||||
return await update(id, userData);
|
return await update(id, userData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteClick = (user) => {
|
const handleDeleteClick = (user: User): void => {
|
||||||
if (user.id === currentUser.id) {
|
if (user.id === currentUser.id) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
@@ -55,26 +66,27 @@ const AdminUsersTab = ({ currentUser }) => {
|
|||||||
setDeleteModalData(user);
|
setDeleteModalData(user);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
const handleDeleteConfirm = async (): Promise<void> => {
|
||||||
if (!deleteModalData) return;
|
if (!deleteModalData) return;
|
||||||
const result = await deleteUser(deleteModalData.id);
|
const success = await deleteUser(deleteModalData.id);
|
||||||
if (result.success) {
|
if (success) {
|
||||||
setDeleteModalData(null);
|
setDeleteModalData(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const rows = users.map((user) => (
|
const renderUserRow = (user: User) => (
|
||||||
<Table.Tr key={user.id}>
|
<Table.Tr key={user.id}>
|
||||||
<Table.Td>{user.email}</Table.Td>
|
<Table.Td>{user.email}</Table.Td>
|
||||||
<Table.Td>{user.displayName}</Table.Td>
|
<Table.Td>{user.displayName}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text transform="capitalize">{user.role}</Text>
|
<Text style={{ textTransform: 'capitalize' }}>{user.role}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{new Date(user.createdAt).toLocaleDateString()}</Table.Td>
|
<Table.Td>{new Date(user.createdAt).toLocaleDateString()}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs" justify="flex-end">
|
<Group gap="xs" justify="flex-end">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
aria-label="Edit user"
|
||||||
color="blue"
|
color="blue"
|
||||||
onClick={() => setEditModalData(user)}
|
onClick={() => setEditModalData(user)}
|
||||||
>
|
>
|
||||||
@@ -82,6 +94,7 @@ const AdminUsersTab = ({ currentUser }) => {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
aria-label="Delete user"
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => handleDeleteClick(user)}
|
onClick={() => handleDeleteClick(user)}
|
||||||
disabled={user.id === currentUser.id}
|
disabled={user.id === currentUser.id}
|
||||||
@@ -91,7 +104,7 @@ const AdminUsersTab = ({ currentUser }) => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
));
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box pos="relative">
|
<Box pos="relative">
|
||||||
@@ -114,6 +127,7 @@ const AdminUsersTab = ({ currentUser }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconPlus size={16} />}
|
leftSection={<IconPlus size={16} />}
|
||||||
|
aria-label="Create user"
|
||||||
onClick={() => setCreateModalOpened(true)}
|
onClick={() => setCreateModalOpened(true)}
|
||||||
>
|
>
|
||||||
Create User
|
Create User
|
||||||
@@ -130,7 +144,7 @@ const AdminUsersTab = ({ currentUser }) => {
|
|||||||
<Table.Th style={{ width: 100 }}>Actions</Table.Th>
|
<Table.Th style={{ width: 100 }}>Actions</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>{rows}</Table.Tbody>
|
<Table.Tbody>{users.map(renderUserRow)}</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<CreateUserModal
|
<CreateUserModal
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
Group,
|
|
||||||
Text,
|
|
||||||
ActionIcon,
|
|
||||||
Box,
|
|
||||||
LoadingOverlay,
|
|
||||||
Alert,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconTrash, IconEdit, IconAlertCircle } from '@tabler/icons-react';
|
|
||||||
import { useAdminData } from '../../../hooks/useAdminData';
|
|
||||||
import { formatBytes } from '../../../utils/formatBytes';
|
|
||||||
|
|
||||||
const AdminWorkspacesTab = () => {
|
|
||||||
const { data: workspaces, loading, error } = useAdminData('workspaces');
|
|
||||||
|
|
||||||
const rows = workspaces.map((workspace) => (
|
|
||||||
<Table.Tr key={workspace.id}>
|
|
||||||
<Table.Td>{workspace.userEmail}</Table.Td>
|
|
||||||
<Table.Td>{workspace.workspaceName}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
{new Date(workspace.workspaceCreatedAt).toLocaleDateString()}
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{workspace.totalFiles}</Table.Td>
|
|
||||||
<Table.Td>{formatBytes(workspace.totalSize)}</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box pos="relative">
|
|
||||||
<LoadingOverlay visible={loading} />
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert
|
|
||||||
icon={<IconAlertCircle size={16} />}
|
|
||||||
title="Error"
|
|
||||||
color="red"
|
|
||||||
mb="md"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="space-between" mb="md">
|
|
||||||
<Text size="xl" fw={700}>
|
|
||||||
Workspace Management
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Table striped highlightOnHover withTableBorder>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Owner</Table.Th>
|
|
||||||
<Table.Th>Name</Table.Th>
|
|
||||||
<Table.Th>Created At</Table.Th>
|
|
||||||
<Table.Th>Total Files</Table.Th>
|
|
||||||
<Table.Th>Total Size</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>{rows}</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminWorkspacesTab;
|
|
||||||
140
app/src/components/settings/admin/AdminWorkspacesTab.test.tsx
Normal file
140
app/src/components/settings/admin/AdminWorkspacesTab.test.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render as rtlRender, screen } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import AdminWorkspacesTab from './AdminWorkspacesTab';
|
||||||
|
import type { WorkspaceStats } from '@/types/models';
|
||||||
|
|
||||||
|
// Mock the admin data hook
|
||||||
|
vi.mock('../../../hooks/useAdminData', () => ({
|
||||||
|
useAdminData: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the formatBytes utility
|
||||||
|
vi.mock('../../../utils/formatBytes', () => ({
|
||||||
|
formatBytes: (bytes: number) => `${bytes} bytes`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AdminWorkspacesTab', () => {
|
||||||
|
const mockWorkspaces: WorkspaceStats[] = [
|
||||||
|
{
|
||||||
|
workspaceID: 1,
|
||||||
|
userID: 1,
|
||||||
|
userEmail: 'user1@example.com',
|
||||||
|
workspaceName: 'Project Alpha',
|
||||||
|
workspaceCreatedAt: '2024-01-15T10:30:00Z',
|
||||||
|
fileCountStats: {
|
||||||
|
totalFiles: 25,
|
||||||
|
totalSize: 1048576, // 1MB
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workspaceID: 2,
|
||||||
|
userID: 2,
|
||||||
|
userEmail: 'user2@example.com',
|
||||||
|
workspaceName: 'Project Beta',
|
||||||
|
workspaceCreatedAt: '2024-02-20T14:45:00Z',
|
||||||
|
fileCountStats: {
|
||||||
|
totalFiles: 42,
|
||||||
|
totalSize: 2097152, // 2MB
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||||
|
vi.mocked(useAdminData).mockReturnValue({
|
||||||
|
data: mockWorkspaces,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
reload: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders workspace table with all columns', () => {
|
||||||
|
render(<AdminWorkspacesTab />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Workspace Management')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Created At')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Total Files')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Total Size')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays workspace data correctly', () => {
|
||||||
|
render(<AdminWorkspacesTab />);
|
||||||
|
|
||||||
|
expect(screen.getByText('user1@example.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Project Alpha')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1/15/2024')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('25')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1048576 bytes')).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText('user2@example.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Project Beta')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2/20/2024')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('42')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2097152 bytes')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading state', async () => {
|
||||||
|
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||||
|
vi.mocked(useAdminData).mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
reload: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AdminWorkspacesTab />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.querySelector('.mantine-LoadingOverlay-root')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state', async () => {
|
||||||
|
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||||
|
vi.mocked(useAdminData).mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
loading: false,
|
||||||
|
error: 'Failed to load workspaces',
|
||||||
|
reload: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AdminWorkspacesTab />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Failed to load workspaces')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty workspace list', async () => {
|
||||||
|
const { useAdminData } = await import('../../../hooks/useAdminData');
|
||||||
|
vi.mocked(useAdminData).mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
reload: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AdminWorkspacesTab />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Workspace Management')).toBeInTheDocument();
|
||||||
|
// Table headers should still be present
|
||||||
|
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
73
app/src/components/settings/admin/AdminWorkspacesTab.tsx
Normal file
73
app/src/components/settings/admin/AdminWorkspacesTab.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Table, Group, Text, Box, LoadingOverlay, Alert } from '@mantine/core';
|
||||||
|
import { IconAlertCircle } from '@tabler/icons-react';
|
||||||
|
import { useAdminData } from '../../../hooks/useAdminData';
|
||||||
|
import { formatBytes } from '../../../utils/formatBytes';
|
||||||
|
import type { FileCountStats, WorkspaceStats } from '@/types/models';
|
||||||
|
|
||||||
|
const AdminWorkspacesTab: React.FC = () => {
|
||||||
|
const {
|
||||||
|
data: workspaces,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
} = useAdminData<'workspaces'>('workspaces');
|
||||||
|
|
||||||
|
const renderWorkspaceRow = (workspace: WorkspaceStats) => {
|
||||||
|
const fileStats: FileCountStats = workspace.fileCountStats || {
|
||||||
|
totalFiles: 0,
|
||||||
|
totalSize: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Tr key={workspace.workspaceID}>
|
||||||
|
<Table.Td>{workspace.userEmail}</Table.Td>
|
||||||
|
<Table.Td>{workspace.workspaceName}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{new Date(workspace.workspaceCreatedAt).toLocaleDateString()}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{fileStats.totalFiles}</Table.Td>
|
||||||
|
<Table.Td>{formatBytes(fileStats.totalSize)}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box pos="relative">
|
||||||
|
<LoadingOverlay visible={loading} />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
icon={<IconAlertCircle size={16} />}
|
||||||
|
title="Error"
|
||||||
|
color="red"
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Text size="xl" fw={700}>
|
||||||
|
Workspace Management
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Table striped highlightOnHover withTableBorder>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Owner</Table.Th>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Created At</Table.Th>
|
||||||
|
<Table.Th>Total Files</Table.Th>
|
||||||
|
<Table.Th>Total Size</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{!loading && !error && workspaces.map(renderWorkspaceRow)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminWorkspacesTab;
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Text, Switch, Group, Box, Title } from '@mantine/core';
|
|
||||||
import { useWorkspace } from '../../../contexts/WorkspaceContext';
|
|
||||||
|
|
||||||
const AppearanceSettings = ({ themeSettings, onThemeChange }) => {
|
|
||||||
const { colorScheme, updateColorScheme } = useWorkspace();
|
|
||||||
|
|
||||||
const handleThemeChange = () => {
|
|
||||||
const newTheme = colorScheme === 'dark' ? 'light' : 'dark';
|
|
||||||
updateColorScheme(newTheme);
|
|
||||||
onThemeChange(newTheme);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box mb="md">
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<Text size="sm">Dark Mode</Text>
|
|
||||||
<Switch checked={colorScheme === 'dark'} onChange={handleThemeChange} />
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AppearanceSettings;
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import AppearanceSettings from './AppearanceSettings';
|
||||||
|
import { Theme } from '@/types/models';
|
||||||
|
|
||||||
|
const mockUpdateColorScheme = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../../contexts/ThemeContext', () => ({
|
||||||
|
useTheme: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AppearanceSettings', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
const { useTheme } = await import('../../../contexts/ThemeContext');
|
||||||
|
vi.mocked(useTheme).mockReturnValue({
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: mockUpdateColorScheme,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders dark mode toggle with correct state', () => {
|
||||||
|
render(<AppearanceSettings />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Dark Mode')).toBeInTheDocument();
|
||||||
|
const toggle = screen.getByRole('switch');
|
||||||
|
expect(toggle).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows toggle as checked when in dark mode', async () => {
|
||||||
|
const { useTheme } = await import('../../../contexts/ThemeContext');
|
||||||
|
vi.mocked(useTheme).mockReturnValue({
|
||||||
|
colorScheme: 'dark',
|
||||||
|
updateColorScheme: mockUpdateColorScheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AppearanceSettings />);
|
||||||
|
|
||||||
|
const toggle = screen.getByRole('switch');
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles theme from light to dark', () => {
|
||||||
|
render(<AppearanceSettings />);
|
||||||
|
|
||||||
|
const toggle = screen.getByRole('switch');
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
|
||||||
|
expect(mockUpdateColorScheme).toHaveBeenCalledWith(Theme.Dark);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles theme from dark to light', async () => {
|
||||||
|
const { useTheme } = await import('../../../contexts/ThemeContext');
|
||||||
|
vi.mocked(useTheme).mockReturnValue({
|
||||||
|
colorScheme: 'dark',
|
||||||
|
updateColorScheme: mockUpdateColorScheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AppearanceSettings />);
|
||||||
|
|
||||||
|
const toggle = screen.getByRole('switch');
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
|
||||||
|
expect(mockUpdateColorScheme).toHaveBeenCalledWith(Theme.Light);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
app/src/components/settings/workspace/AppearanceSettings.tsx
Normal file
24
app/src/components/settings/workspace/AppearanceSettings.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Text, Switch, Group, Box } from '@mantine/core';
|
||||||
|
import { Theme } from '@/types/models';
|
||||||
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
|
|
||||||
|
const AppearanceSettings: React.FC = () => {
|
||||||
|
const { colorScheme, updateColorScheme } = useTheme();
|
||||||
|
|
||||||
|
const handleThemeChange = (): void => {
|
||||||
|
const newTheme = colorScheme === 'dark' ? Theme.Light : Theme.Dark;
|
||||||
|
updateColorScheme(newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box mb="md">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="sm">Dark Mode</Text>
|
||||||
|
<Switch checked={colorScheme === 'dark'} onChange={handleThemeChange} />
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppearanceSettings;
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import DangerZoneSettings from './DangerZoneSettings';
|
||||||
|
import { Theme } from '@/types/models';
|
||||||
|
|
||||||
|
const mockDeleteCurrentWorkspace = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/useWorkspace', () => ({
|
||||||
|
useWorkspace: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSetSettingsModalVisible = vi.fn();
|
||||||
|
vi.mock('../../../contexts/ModalContext', () => ({
|
||||||
|
useModalContext: () => ({
|
||||||
|
setSettingsModalVisible: mockSetSettingsModalVisible,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../modals/workspace/DeleteWorkspaceModal', () => ({
|
||||||
|
default: ({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
workspaceName,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
workspaceName: string | undefined;
|
||||||
|
}) =>
|
||||||
|
opened ? (
|
||||||
|
<div data-testid="delete-workspace-modal">
|
||||||
|
<span data-testid="workspace-name">{workspaceName}</span>
|
||||||
|
<button onClick={onClose} data-testid="modal-close">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button onClick={() => void onConfirm()} data-testid="modal-confirm">
|
||||||
|
Confirm Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper wrapper component for testing
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MantineProvider defaultColorScheme="light">{children}</MantineProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom render function
|
||||||
|
const render = (ui: React.ReactElement) => {
|
||||||
|
return rtlRender(ui, { wrapper: TestWrapper });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DangerZoneSettings (Workspace)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockDeleteCurrentWorkspace.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const { useWorkspace } = await import('../../../hooks/useWorkspace');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: {
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
name: 'Test Workspace',
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
theme: Theme.Light,
|
||||||
|
autoSave: false,
|
||||||
|
showHiddenFiles: false,
|
||||||
|
gitEnabled: false,
|
||||||
|
gitUrl: '',
|
||||||
|
gitUser: '',
|
||||||
|
gitToken: '',
|
||||||
|
gitAutoCommit: false,
|
||||||
|
gitCommitMsgTemplate: '',
|
||||||
|
gitCommitName: '',
|
||||||
|
gitCommitEmail: '',
|
||||||
|
},
|
||||||
|
workspaces: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
name: 'Workspace 1',
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
theme: Theme.Light,
|
||||||
|
autoSave: false,
|
||||||
|
showHiddenFiles: false,
|
||||||
|
gitEnabled: false,
|
||||||
|
gitUrl: '',
|
||||||
|
gitUser: '',
|
||||||
|
gitToken: '',
|
||||||
|
gitAutoCommit: false,
|
||||||
|
gitCommitMsgTemplate: '',
|
||||||
|
gitCommitName: '',
|
||||||
|
gitCommitEmail: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
userId: 1,
|
||||||
|
name: 'Workspace 2',
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
theme: Theme.Light,
|
||||||
|
autoSave: false,
|
||||||
|
showHiddenFiles: false,
|
||||||
|
gitEnabled: false,
|
||||||
|
gitUrl: '',
|
||||||
|
gitUser: '',
|
||||||
|
gitToken: '',
|
||||||
|
gitAutoCommit: false,
|
||||||
|
gitCommitMsgTemplate: '',
|
||||||
|
gitCommitName: '',
|
||||||
|
gitCommitEmail: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: false,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: vi.fn(),
|
||||||
|
deleteCurrentWorkspace: mockDeleteCurrentWorkspace,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders delete button when multiple workspaces exist', () => {
|
||||||
|
render(<DangerZoneSettings />);
|
||||||
|
|
||||||
|
const deleteButton = screen.getByRole('button', {
|
||||||
|
name: 'Delete Workspace',
|
||||||
|
});
|
||||||
|
expect(deleteButton).toBeInTheDocument();
|
||||||
|
expect(deleteButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables delete button when only one workspace exists', async () => {
|
||||||
|
const { useWorkspace } = await import('../../../hooks/useWorkspace');
|
||||||
|
vi.mocked(useWorkspace).mockReturnValue({
|
||||||
|
currentWorkspace: {
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
name: 'Last Workspace',
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
theme: Theme.Light,
|
||||||
|
autoSave: false,
|
||||||
|
showHiddenFiles: false,
|
||||||
|
gitEnabled: false,
|
||||||
|
gitUrl: '',
|
||||||
|
gitUser: '',
|
||||||
|
gitToken: '',
|
||||||
|
gitAutoCommit: false,
|
||||||
|
gitCommitMsgTemplate: '',
|
||||||
|
gitCommitName: '',
|
||||||
|
gitCommitEmail: '',
|
||||||
|
},
|
||||||
|
workspaces: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
name: 'Last Workspace',
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
theme: Theme.Light,
|
||||||
|
autoSave: false,
|
||||||
|
showHiddenFiles: false,
|
||||||
|
gitEnabled: false,
|
||||||
|
gitUrl: '',
|
||||||
|
gitUser: '',
|
||||||
|
gitToken: '',
|
||||||
|
gitAutoCommit: false,
|
||||||
|
gitCommitMsgTemplate: '',
|
||||||
|
gitCommitName: '',
|
||||||
|
gitCommitEmail: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updateSettings: vi.fn(),
|
||||||
|
loading: false,
|
||||||
|
colorScheme: 'light',
|
||||||
|
updateColorScheme: vi.fn(),
|
||||||
|
switchWorkspace: vi.fn(),
|
||||||
|
deleteCurrentWorkspace: mockDeleteCurrentWorkspace,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<DangerZoneSettings />);
|
||||||
|
|
||||||
|
const deleteButton = screen.getByRole('button', {
|
||||||
|
name: 'Delete Workspace',
|
||||||
|
});
|
||||||
|
expect(deleteButton).toBeDisabled();
|
||||||
|
expect(deleteButton).toHaveAttribute(
|
||||||
|
'title',
|
||||||
|
'Cannot delete the last workspace'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens and closes delete modal', () => {
|
||||||
|
render(<DangerZoneSettings />);
|
||||||
|
|
||||||
|
const deleteButton = screen.getByRole('button', {
|
||||||
|
name: 'Delete Workspace',
|
||||||
|
});
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('delete-workspace-modal')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('workspace-name')).toHaveTextContent(
|
||||||
|
'Test Workspace'
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('modal-close'));
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('delete-workspace-modal')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes workspace deletion flow', async () => {
|
||||||
|
render(<DangerZoneSettings />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete Workspace' }));
|
||||||
|
fireEvent.click(screen.getByTestId('modal-confirm'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDeleteCurrentWorkspace).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('delete-workspace-modal')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows cancellation of deletion process', () => {
|
||||||
|
render(<DangerZoneSettings />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete Workspace' }));
|
||||||
|
fireEvent.click(screen.getByTestId('modal-close'));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId('delete-workspace-modal')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(mockDeleteCurrentWorkspace).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetSettingsModalVisible).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Box, Button, Title } from '@mantine/core';
|
import { Box, Button } from '@mantine/core';
|
||||||
import DeleteWorkspaceModal from '../../modals/workspace/DeleteWorkspaceModal';
|
import DeleteWorkspaceModal from '../../modals/workspace/DeleteWorkspaceModal';
|
||||||
import { useWorkspace } from '../../../contexts/WorkspaceContext';
|
import { useWorkspace } from '../../../hooks/useWorkspace';
|
||||||
import { useModalContext } from '../../../contexts/ModalContext';
|
import { useModalContext } from '../../../contexts/ModalContext';
|
||||||
|
|
||||||
const DangerZoneSettings = () => {
|
const DangerZoneSettings: React.FC = () => {
|
||||||
const { currentWorkspace, workspaces, deleteCurrentWorkspace } =
|
const { currentWorkspace, workspaces, deleteCurrentWorkspace } =
|
||||||
useWorkspace();
|
useWorkspace();
|
||||||
const { setSettingsModalVisible } = useModalContext();
|
const { setSettingsModalVisible } = useModalContext();
|
||||||
const [deleteModalOpened, setDeleteModalOpened] = useState(false);
|
const [deleteModalOpened, setDeleteModalOpened] = useState<boolean>(false);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async (): Promise<void> => {
|
||||||
await deleteCurrentWorkspace();
|
await deleteCurrentWorkspace();
|
||||||
setDeleteModalOpened(false);
|
setDeleteModalOpened(false);
|
||||||
setSettingsModalVisible(false);
|
setSettingsModalVisible(false);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user