414 Commits

Author SHA1 Message Date
6c408fdfbe Merge pull request #67 from lordmathis/fix/file-upload
Fix file upload
2025-10-28 19:19:01 +01:00
6753f32520 Remove test case for uploading with missing file_path parameter 2025-10-28 19:09:46 +01:00
de06939b01 Fix file upload 2025-10-28 18:58:10 +01:00
c11d956ced Merge pull request #66 from lordmathis/fix/head-request
Handle HEAD requests with static router
2025-10-23 20:35:47 +02:00
9a232819a8 Handle HEAD requests with static router 2025-10-23 20:26:34 +02:00
f9ce8b9e9f Merge pull request #65 from lordmathis/fix/code-highlight
Fix code highlight theme change
2025-10-23 19:24:14 +02:00
a6d2663a7d Add type declarations for CSS imports with ?inline modifier 2025-10-23 19:19:27 +02:00
071e99f4da Remove documentation.md 2025-10-23 19:17:51 +02:00
b13ee987c7 Fix code highlight theme change 2025-10-23 19:12:09 +02:00
543dbe6ffe Merge pull request #64 from lordmathis/fix/file-rename
Fix move file on frontend
2025-10-22 21:55:52 +02:00
d0842c515f Change move file endpoint from PUT to POST and add integration tests for file moving and renaming 2025-10-22 21:50:37 +02:00
01d9a984fc Merge pull request #63 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-7372c7abf2
Bump vite from 6.3.6 to 6.4.1 in /app in the npm_and_yarn group across 1 directory
2025-10-21 10:21:46 +02:00
dependabot[bot]
a3975c9acd Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.3.6 to 6.4.1
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@6.4.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.1
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-21 05:07:05 +00:00
e82f25a2ed Merge pull request #62 from lordmathis/fix/image-preview
Fix get image url
2025-10-11 22:56:49 +02:00
ec89f95880 Fix get image url 2025-10-11 22:51:08 +02:00
f101376bef Merge pull request #61 from lordmathis/fix/syntax-highlight
Replace react-syntax-highlighter with rehype-highlight
2025-10-11 22:49:21 +02:00
d582b1a1e9 Add syntax highlighting themes for markdown preview 2025-10-11 22:26:21 +02:00
aca127e52e Move rate limiting for authentication endpoints to the public routes group 2025-10-11 22:16:15 +02:00
9ca8a46093 Replace react-syntax-highlighter with rehype-highlight 2025-10-11 22:02:02 +02:00
2370043986 Merge pull request #60 from lordmathis/fix/prism-js
Update deps to fix prism-js vulnerability
2025-10-11 21:45:25 +02:00
386ac2f15f Update react-syntax-highlighter to version 15.6.6 in package.json and package-lock.json 2025-10-11 21:40:17 +02:00
cf95bf4862 Merge pull request #59 from lordmathis/fix/signing-key
Generate signing and encryption key to a file
2025-10-11 21:31:56 +02:00
e5ad02df6d Update launch configurations 2025-10-11 21:27:54 +02:00
a904a0d1a3 Improve admin credentials error messages with setup instructions 2025-10-11 21:22:38 +02:00
62605b3689 Refactor encryption key handling: auto-generate if not provided, update README and tests 2025-10-11 21:18:24 +02:00
d40321685e Remove system settings functionality and related database migrations 2025-10-11 21:04:01 +02:00
8920027a9c Load or generate signing key from file 2025-10-11 20:55:44 +02:00
c0bcb3069b Merge pull request #58 from lordmathis/fix/theme-switching
Fix/theme switching
2025-10-11 20:27:17 +02:00
4ec019f2b7 Fix typescript type check issues 2025-10-11 20:23:10 +02:00
769385d8c7 Fix workspace selection logic and update settings initialization conditions 2025-10-11 19:04:08 +02:00
bcc5d2588a Streamline theme management and improve AppearanceSettings component 2025-10-11 17:46:12 +02:00
8bd9eb2236 Merge pull request #57 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-73ea615029
Bump vite from 6.3.4 to 6.3.6 in /app in the npm_and_yarn group across 1 directory
2025-09-14 10:53:14 +02:00
dependabot[bot]
399dfda6ea Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.3.4 to 6.3.6
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.6
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 01:38:31 +00:00
f710e32659 Merge pull request #56 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-e04d5d616f
Bump form-data from 4.0.2 to 4.0.4 in /app in the npm_and_yarn group across 1 directory
2025-07-22 22:07:10 +02:00
dependabot[bot]
49c1a06d98 Bump form-data in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [form-data](https://github.com/form-data/form-data).


Updates `form-data` from 4.0.2 to 4.0.4
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.2...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-22 07:45:26 +00:00
14adcf2765 Merge pull request #55 from lordmathis/feat/file-actions
Implement file upload, move and rename
2025-07-12 15:49:52 +02:00
69fa105291 Add rename file modal visibility state and handler to CreateWorkspaceModal and WorkspaceSwitcher tests 2025-07-12 15:44:26 +02:00
005288b3d8 Add file size validation in UploadFile handler to prevent excessive memory allocation 2025-07-12 15:06:01 +02:00
9f01c64e5e Enhance file upload functionality to support multiple files; update related tests and response structure 2025-07-12 14:58:32 +02:00
491d056dd4 Add isUploadFilesResponse type guard and related tests; remove prototype pollution tests 2025-07-12 14:40:07 +02:00
41d526af4c Remove redundant CSRF token tests and clean up related assertions in apiCall tests 2025-07-12 14:34:04 +02:00
ff4d1de2b7 Refactor uploadFile to support multiple file uploads and update related types and handlers 2025-07-12 14:25:03 +02:00
51c6f62c44 Enhance uploadFile to support multiple files and update handleMove to process file paths for moving files 2025-07-12 13:59:30 +02:00
1a7c97fb08 Refactor file API endpoints for consistency and add moveFile functionality 2025-07-12 12:39:51 +02:00
4ea24f07d1 Refactor file routes and update API endpoints for consistency 2025-07-12 12:34:17 +02:00
f4d8a354c4 Add parameters for source and destination paths in moveFile endpoint 2025-07-12 11:58:50 +02:00
3ac657486c Implement MoveFile functionality 2025-07-12 11:52:51 +02:00
0b0cd9160e Refactor WorkspaceDataContext and useFileOperations to remove settings 2025-07-11 23:53:37 +02:00
e1760ccd82 Fix FileActions, FileTree, and MainContent tests by mocking context providers 2025-07-11 23:35:00 +02:00
9bb95f603c Implement MoveFile functionality in FileManager and corresponding tests 2025-07-11 19:49:08 +02:00
5a6895ecdc Enhance integration tests with file upload functionality and support for multipart requests 2025-07-10 22:02:03 +02:00
ae4e9c8db2 Add upload and move file functionalities to API with appropriate routes 2025-07-10 21:07:20 +02:00
4b8ad359a4 Refactor API endpoints to include "_op" prefix for file and workspace operations 2025-07-08 20:28:44 +02:00
48d42a92c9 Refactor API routes to include "_op" prefix for last workspace and file operations 2025-07-08 20:16:46 +02:00
a789c62a68 Add rename file functionality with modal support 2025-07-07 20:18:36 +02:00
4a3df3a040 Implement file upload functionality in FileActions and FileTree components 2025-07-07 19:13:25 +02:00
b10591ee60 Add file upload functionality to FileActions and FileTree components 2025-07-07 18:14:14 +02:00
70cd67f8bb Merge pull request #54 from lordmathis/chore/frontend-test
Add frontend tests
2025-07-06 01:44:38 +02:00
fa86a950fd Update README.md to show fe tests badge 2025-07-06 01:37:54 +02:00
520f58435c Refactor tests to remove redundant settings mock data 2025-07-06 01:32:02 +02:00
e5c34c25d7 Update tests to handle asynchronous loading and initialization states across multiple components 2025-07-06 01:29:55 +02:00
7a31bd4c76 Update WorkspaceDataContext tests to handle asynchronous loading states 2025-07-06 00:51:48 +02:00
d0cdc48f3e Update AuthContext tests to handle asynchronous initialization and loading states 2025-07-06 00:47:58 +02:00
cf554fbb6e Refactor workspace settings handling in tests and components to use currentWorkspace directly 2025-07-06 00:41:30 +02:00
7368797a11 Add tests for ContentView and MarkdownPreview components 2025-07-06 00:08:42 +02:00
2747f51293 Add tests for Header, Layout, MainContent, and Sidebar components 2025-07-05 17:39:23 +02:00
7742a04d9a Add tests for FileActions and FileTree components 2025-07-05 16:54:47 +02:00
fffd93afeb Add tests for UserMenu and WorkspaceSwitcher components 2025-07-04 22:44:19 +02:00
eaa37a262e Add tests for AdminDashboard, AdminStatsTab, AdminUsersTab, and AdminWorkspacesTab components 2025-07-04 20:24:56 +02:00
184d9fae15 Merge pull request #53 from lordmathis/dependabot/go_modules/server/go_modules-c5fc80f408
Bump github.com/go-chi/chi/v5 from 5.1.0 to 5.2.2 in /server in the go_modules group across 1 directory
2025-07-04 16:34:00 +02:00
dependabot[bot]
254bccb1d9 Bump github.com/go-chi/chi/v5
Bumps the go_modules group with 1 update in the /server directory: [github.com/go-chi/chi/v5](https://github.com/go-chi/chi).


Updates `github.com/go-chi/chi/v5` from 5.1.0 to 5.2.2
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.1.0...v5.2.2)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-version: 5.2.2
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-03 20:00:53 +00:00
d30c0a4b9e Merge pull request #52 from lordmathis/dependabot/go_modules/server/go_modules-d354d3216e
Bump github.com/cloudflare/circl from 1.3.7 to 1.6.1 in /server in the go_modules group across 1 directory
2025-07-03 21:59:47 +02:00
5c7edf40a8 Add tests for AppearanceSettings, DangerZoneSettings, EditorSettings, GeneralSettings, GitSettings, and WorkspaceSettings components 2025-07-03 21:58:14 +02:00
f4ec3af80c Fix handleSubmit invocation in AccountSettings component 2025-07-01 21:10:03 +02:00
e4b584e440 Add tests for AccountSettings, DangerZoneSettings, ProfileSettings, and SecuritySettings components 2025-07-01 21:01:53 +02:00
15486c584a Simplify AccordionControl tests 2025-07-01 20:40:04 +02:00
1a7ddc1a97 Refactor AccordionControl tests for improved structure, clarity, and error handling 2025-06-30 21:42:44 +02:00
32d03347fc Refactor modal tests for improved clarity and consistency in content and actions 2025-06-29 16:06:35 +02:00
8d9222d084 Refactor DeleteWorkspaceModal tests for improved clarity and consistency in assertions 2025-06-29 14:56:57 +02:00
6add442e03 Simplify user modals tests 2025-06-29 14:47:34 +02:00
e40aaff905 Refactor CommitMessageModal tests to improve clarity and remove redundant cases 2025-06-29 14:21:33 +02:00
d90f9968c5 Simplify CreateFileModal and DeleteFileModal tests 2025-06-29 13:58:43 +02:00
5980c308a2 Simplify account modals tests 2025-06-29 13:17:07 +02:00
b673d2ed2d Refactor LoginPage tests 2025-06-29 12:58:51 +02:00
dependabot[bot]
bc863bd8ef Bump github.com/cloudflare/circl
Bumps the go_modules group with 1 update in the /server directory: [github.com/cloudflare/circl](https://github.com/cloudflare/circl).


Updates `github.com/cloudflare/circl` from 1.3.7 to 1.6.1
- [Release notes](https://github.com/cloudflare/circl/releases)
- [Commits](https://github.com/cloudflare/circl/compare/v1.3.7...v1.6.1)

---
updated-dependencies:
- dependency-name: github.com/cloudflare/circl
  dependency-version: 1.6.1
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-10 21:23:52 +00:00
734b98d286 Refactor ProfileSettings and SecuritySettings components to improve code clarity and add input types 2025-06-05 21:01:06 +02:00
3276fd98c2 Refactor ModalContext tests 2025-06-04 20:48:38 +02:00
73653c4271 Remove redundant tests from contexts 2025-06-04 20:44:31 +02:00
0fd87c072d Remove duplicate error handling tests from useWorkspaceOperations test 2025-06-04 19:22:21 +02:00
07af3f6e39 Refactor ThemeContext to ensure fallback to light scheme and update color scheme logic 2025-06-04 19:17:09 +02:00
54feefcd5c Remove redundant test in useProfileSettings 2025-06-04 18:54:48 +02:00
9854deb43b Add test for handling empty file path in saveLastOpenedFile function 2025-06-04 18:46:31 +02:00
1e80edd5ca Remove unnecessary test 2025-06-04 18:41:39 +02:00
d938c3b03b Add test for handling large file lists in useFileList hook 2025-06-04 18:22:11 +02:00
6a7736ea5b Improve useFileContent hook tests 2025-06-04 18:11:37 +02:00
dfd9d5b70c Simplify useAdminData tests 2025-06-04 17:54:57 +02:00
f37c024d65 Simplify theme tests 2025-06-04 17:03:24 +02:00
3f2aaa34e5 Add CreateWorkspaceModal and DeleteWorkspaceModal tests 2025-06-01 15:19:50 +02:00
bc17005eca Add DeleteUserModal and EditUserModal tests 2025-06-01 13:58:06 +02:00
9d7f312527 Implement CreateUserModal tests 2025-06-01 11:49:05 +02:00
8deededc05 Add DeleteAccountModal and EmailPasswordModal tests 2025-05-31 21:59:00 +02:00
e642b73556 Add data-testid attributes for improved testing in modals 2025-05-31 20:23:15 +02:00
e279cd4535 Add DeleteFileModal and CommitMessageModal tests with accessibility improvements 2025-05-31 20:00:34 +02:00
2964963f98 Add tests for CreateFileModal component and improve form handling 2025-05-29 22:03:28 +02:00
e01ae5b815 Add tests for LoginPage component and improve accessibility features 2025-05-29 20:04:58 +02:00
57b9d4cc89 Add frontend tests workflow and update paths for Go and TypeScript workflows 2025-05-29 17:12:42 +02:00
37c49dc0cc Add tests for WorkspaceDataContext 2025-05-29 16:52:03 +02:00
33d45568ec Add tests for AuthContext, ModalContext and ThemeContext 2025-05-29 16:25:42 +02:00
19771dd094 Add tests for API and Models type guards 2025-05-29 13:10:20 +02:00
2211f85193 Add tests for apiCall function 2025-05-29 11:57:50 +02:00
5ed3e96350 Refactor admin API types and add validation functions for WorkspaceStats and FileCountStats 2025-05-29 11:15:53 +02:00
d814c365ea Refactor hooks and hook tests error handling and state management 2025-05-28 22:01:27 +02:00
907dffe362 Refactor tests in useGitOperations and useWorkspaceOperations to handle undefined values gracefully 2025-05-28 21:04:43 +02:00
b38792a47f Add ESLint rule override for test files 2025-05-28 20:59:59 +02:00
6a8b359c84 Add tests for useWorkspaceOperations hook functionality 2025-05-28 20:41:12 +02:00
3e482c546c Add tests for useUserAdmin hook functionality 2025-05-28 19:44:00 +02:00
ae35172c2a Add tests for useFileNavigation hook functionality 2025-05-28 18:39:41 +02:00
05c3111f8b Fix error message casing for email already exists in useProfileSettings tests 2025-05-28 18:39:33 +02:00
1532896b27 Add tests for useProfileSettings hook functionality 2025-05-27 21:52:21 +02:00
00edb9e5a6 Add tests for useLastOpenedFile hook functionality 2025-05-27 21:43:20 +02:00
9cefe12872 Improve tests and add useGitOps hook test 2025-05-27 21:33:02 +02:00
942ff17c4f Add tests for useFileContent and useFileOperations hooks 2025-05-26 21:53:52 +02:00
e9abe14364 Add tests for remarkWikiLinks functionality 2025-05-26 20:58:26 +02:00
49cac03db8 Implement utils tests 2025-05-26 20:35:02 +02:00
e5569fc4a5 Initial vitest setup 2025-05-26 20:02:53 +02:00
3c6e767954 Merge pull request #51 from lordmathis/chore/readme-update
Chore/readme update
2025-05-25 16:40:55 +02:00
44c3271e1d Update frontend builder to use Node.js 24-slim 2025-05-25 16:34:57 +02:00
3eaf79f2a7 Add TypeScript check badge to README 2025-05-25 16:25:19 +02:00
fbfab22666 Merge pull request #50 from lordmathis/feat/typescript
Migrate frontend to typescript
2025-05-25 16:11:34 +02:00
47b88cb93a Potential fix for code scanning alert no. 7: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-25 16:07:48 +02:00
63f3679e1f Merge branch 'main' into feat/typescript 2025-05-25 16:03:18 +02:00
694f842178 Merge pull request #49 from lordmathis/workspace_context
Workspace context
2025-05-25 15:58:16 +02:00
3926a2726e Fix cache dependency path in TypeScript workflow 2025-05-25 15:54:13 +02:00
ca602bd0bd Refactor TypeScript workflow to set working directory in defaults 2025-05-25 15:51:31 +02:00
f3f2deea35 Update default DBURL to use sqlite:// format in DefaultConfig 2025-05-25 15:39:47 +02:00
fe2a466a4f Add URL decoding for workspace and file paths in handlers 2025-05-25 15:33:07 +02:00
d2c4a84c32 Refactor API call to include CSRF token handling for non-GET requests 2025-05-25 12:46:51 +02:00
ecc1fe9989 Fix logout button 2025-05-25 12:34:36 +02:00
a724bc44e9 Refactor workspace context usage to improve structure and introduce ThemeContext 2025-05-25 12:11:03 +02:00
6cf118280a Update Workspace interface for optional properties 2025-05-24 20:43:10 +02:00
34ac76b87d Fix async handling for API response in updateProfile and workspace functions 2025-05-24 14:10:35 +02:00
07e0647174 Refactor type guards for LoginResponse and UserRole to improve validation logic 2025-05-24 14:10:13 +02:00
ab3cb56aa1 Add IF NOT EXISTS clause to index creation for sessions and workspaces tables 2025-05-24 14:05:58 +02:00
f511dafad2 Refactor ESLint config 2025-05-24 00:15:10 +02:00
15538b243d Refactor theme styles for hover, accordion, and workspace components 2025-05-24 00:12:31 +02:00
e43efc736a Refactor type definitions in AuthenticatedContent and App components 2025-05-23 23:48:45 +02:00
623f619f88 Fix more lint issues 2025-05-23 23:29:29 +02:00
2519d46061 Refactor Node component to destructure props and improve onNodeClick handling 2025-05-23 23:21:58 +02:00
78de42d195 Fix various eslint issues 2025-05-23 23:03:05 +02:00
ad2334c414 Refactor imports and handle async operations in components 2025-05-23 22:13:25 +02:00
646a897b93 Fix some lint issues 2025-05-22 22:00:37 +02:00
32218e5595 Refactor types 2025-05-22 21:24:39 +02:00
2f181d0f7f Run npm lint:fix 2025-05-18 16:57:48 +02:00
16fbbec992 Configure eslint 2025-05-18 16:56:09 +02:00
60ab01b0c8 Fix type-check issues 2025-05-18 16:36:20 +02:00
3619cf4ed4 Update ts configuration 2025-05-18 15:51:44 +02:00
bfc5cc2d29 Migrate edito components 2025-05-18 15:19:58 +02:00
db75bdcc89 Migrate file components 2025-05-18 14:39:17 +02:00
834a7b1e7e Migrate layout 2025-05-18 13:08:03 +02:00
a8a525531e Migrate navigation to ts 2025-05-18 12:33:42 +02:00
9125cbdad3 Migrate workspace settings to ts 2025-05-18 12:21:18 +02:00
7044e42e94 Migrate admin dashboard to ts 2025-05-18 11:53:17 +02:00
c478e8e8a1 Migrate account settings 2025-05-16 22:57:47 +02:00
924d710b2f Migrate all modals to ts 2025-05-12 21:26:07 +02:00
f3993accdc Merge pull request #48 from lordmathis/alert-autofix-6
Fix for code scanning alert no. 6: Workflow does not contain permissions
2025-05-12 15:56:38 +02:00
00affe3456 Potential fix for code scanning alert no. 6: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-12 15:47:58 +02:00
b7be5a46a2 Migrate useUserAdmin 2025-05-11 15:44:27 +02:00
5fcd24db3e Migrate useProfileSettings hook 2025-05-11 15:36:30 +02:00
c6d46df7a0 Migrate useLastOpenedFile hook 2025-05-11 15:09:11 +02:00
32cb89d329 Migrate useGitOperations 2025-05-11 13:01:43 +02:00
f3691d4dbf Migrate useFileOperations 2025-05-11 12:55:18 +02:00
5dc427ce00 Migrate useFileNavigation hook 2025-05-11 12:37:28 +02:00
a7c83d0c24 Migrate useFileList 2025-05-11 12:25:59 +02:00
1c477f1022 Migrate useFileContent hook 2025-05-11 12:24:12 +02:00
bc60cb3451 Migrate useAdminData to TypeScript with improved type safety and error handling 2025-05-08 22:04:49 +02:00
14b1a46508 Migrate ModalContext and WorkspaceContext 2025-05-08 21:42:13 +02:00
1a06c31705 Migrate AuthContext 2025-05-08 21:01:20 +02:00
1e350bb0cf Migrate git api to ts 2025-05-06 21:02:09 +02:00
02c8100f0b Migrate workspace api to ts 2025-05-06 20:42:12 +02:00
66fe5e485b Migrate user api to ts 2025-05-06 20:16:22 +02:00
905df9f6dd Migrate file api ops to ts 2025-05-05 22:20:11 +02:00
8849deec21 Migrate admin API to typescript 2025-05-05 21:28:29 +02:00
043eab423f Migrating from services to dedicated API files 2025-05-03 21:28:41 +02:00
65db2d2030 Merge pull request #47 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-de653eece3
Bump vite from 6.2.6 to 6.3.4 in /app in the npm_and_yarn group across 1 directory
2025-05-03 14:31:35 +02:00
dependabot[bot]
1fd8d4af22 Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.2.6 to 6.3.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.4/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.4
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-30 19:45:45 +00:00
f1b7fa56b6 Merge pull request #46 from lordmathis/dependabot/go_modules/server/go_modules-bbb8b02913
Bump golang.org/x/net from 0.36.0 to 0.38.0 in /server in the go_modules group across 1 directory
2025-04-19 19:04:14 +02:00
689483d490 Merge pull request #45 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-b7c6efa8f1
Bump vite from 6.2.5 to 6.2.6 in /app in the npm_and_yarn group across 1 directory
2025-04-19 19:03:35 +02:00
e789025cd1 Refactor authentication API service to TypeScript 2025-04-18 19:37:39 +02:00
0769aa2bac Fix default database URL and type in Config 2025-04-18 19:21:06 +02:00
dependabot[bot]
46f4df155f Bump golang.org/x/net
Bumps the go_modules group with 1 update in the /server directory: [golang.org/x/net](https://github.com/golang/net).


Updates `golang.org/x/net` from 0.36.0 to 0.38.0
- [Commits](https://github.com/golang/net/compare/v0.36.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.38.0
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-16 23:18:51 +00:00
46e4897881 Add TypeScript types for App component 2025-04-15 20:00:20 +02:00
dependabot[bot]
fdf7c1738b Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.2.5 to 6.2.6
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.2.6
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-15 17:07:53 +00:00
e6a2fdc0d5 Merge pull request #44 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-2bd33993d4
Bump vite from 6.2.4 to 6.2.5 in /app in the npm_and_yarn group across 1 directory
2025-04-15 19:06:48 +02:00
e4fb276cf7 Migrate utils to ts 2025-04-04 19:23:31 +02:00
dependabot[bot]
c2ceb296fd Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.2.4 to 6.2.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.5/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.2.5
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-04 16:20:16 +00:00
49ecaac720 Initial typescript setup 2025-04-03 20:31:00 +02:00
f5cf0131cd Merge pull request #43 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-8ec3883370
Bump vite from 6.2.3 to 6.2.4 in /app in the npm_and_yarn group across 1 directory
2025-04-02 16:59:21 +02:00
dependabot[bot]
a2d0f5fc36 Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.2.3 to 6.2.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.4/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.4/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-31 18:47:09 +00:00
5273062e59 Merge pull request #41 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-14f44f5325
Bump vite from 6.2.1 to 6.2.3 in /app in the npm_and_yarn group across 1 directory
2025-03-25 19:11:27 +01:00
9883b1d122 Merge pull request #42 from lordmathis/lordmathis-patch-1
Create codeql.yml
2025-03-25 19:10:45 +01:00
928ddcc758 Create codeql.yml 2025-03-25 19:07:09 +01:00
dependabot[bot]
c1e13ce02b Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.2.1 to 6.2.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.3/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-25 15:12:43 +00:00
be3d4ee949 Merge pull request #40 from lordmathis/dependabot/go_modules/server/go_modules-036d30de33
Bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2 in /server in the go_modules group across 1 directory
2025-03-22 00:54:14 +01:00
dependabot[bot]
c44381bb86 Bump github.com/golang-jwt/jwt/v5
Bumps the go_modules group with 1 update in the /server directory: [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt).


Updates `github.com/golang-jwt/jwt/v5` from 5.2.1 to 5.2.2
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.2.1...v5.2.2)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-21 22:18:32 +00:00
7c0a4166c6 Merge pull request #39 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-2c631a4876
Bump @babel/runtime from 7.25.7 to 7.26.10 in /app in the npm_and_yarn group across 1 directory
2025-03-18 16:33:22 +01:00
dependabot[bot]
191d9f8cdb Bump @babel/runtime in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime).


Updates `@babel/runtime` from 7.25.7 to 7.26.10
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-18 14:04:38 +00:00
89d99be10b Merge pull request #38 from lordmathis/dependabot/go_modules/server/go_modules-c153b83258
Bump golang.org/x/net from 0.33.0 to 0.36.0 in /server in the go_modules group across 1 directory
2025-03-18 15:03:35 +01:00
dependabot[bot]
09f1ee6a57 Bump golang.org/x/net
Bumps the go_modules group with 1 update in the /server directory: [golang.org/x/net](https://github.com/golang/net).


Updates `golang.org/x/net` from 0.33.0 to 0.36.0
- [Commits](https://github.com/golang/net/compare/v0.33.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-13 01:34:56 +00:00
374c910267 Merge pull request #37 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-5db6d3c188
Bump the npm_and_yarn group across 1 directory with 3 updates
2025-03-07 23:08:23 +01:00
dependabot[bot]
a1bc82d820 Bump the npm_and_yarn group across 1 directory with 3 updates
Bumps the npm_and_yarn group with 3 updates in the /app directory: [esbuild](https://github.com/evanw/esbuild), [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `esbuild` from 0.21.5 to 0.25.0
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.25.0)

Updates `@vitejs/plugin-react` from 4.3.3 to 4.3.4
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/v4.3.4/packages/plugin-react)

Updates `vite` from 5.4.14 to 6.2.1
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@6.2.1/packages/vite)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: "@vitejs/plugin-react"
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-07 21:43:21 +00:00
bc10ad5c25 Merge pull request #36 from lordmathis/feat/postgres
Add support for Postgres
2025-03-07 22:42:08 +01:00
0be1bbf9a7 Update README with postgres url info 2025-03-07 22:37:20 +01:00
d0f6f27526 Add PostgreSQL service to GitHub Actions workflow for integration tests 2025-03-07 22:31:00 +01:00
d97f5a0178 Use time since 2025-03-07 19:53:19 +01:00
94ea2d0d78 Fix time since 2025-03-07 19:53:03 +01:00
3eb4424e86 Implement time since query 2025-03-07 19:48:36 +01:00
f7825e5a67 Use search path in connection string 2025-03-07 19:48:17 +01:00
72b0ac08ce Add test env var to settings.json 2025-03-07 19:47:44 +01:00
32628abf09 Add pgadmin to test compose 2025-03-07 19:47:15 +01:00
d8de67ae6c Add test postgres docker compose 2025-03-06 21:40:16 +01:00
f55d2644c3 Run integration tests with both dbs 2025-03-06 21:39:56 +01:00
629baa9952 Add test postgres db connection 2025-03-06 19:23:24 +01:00
4766a166df Add struct_query tests 2025-03-05 22:36:27 +01:00
7e9aab01cb Fix ommit empty db tag 2025-03-05 22:03:32 +01:00
904d4ce106 Update documentation 2025-03-05 21:31:58 +01:00
52aa406c6d Add docs comments to struct query 2025-03-05 21:30:40 +01:00
3aa8c838e8 Use struct queries in users 2025-03-05 21:20:57 +01:00
976425d660 Use ScanStruct in sessions 2025-03-05 21:07:05 +01:00
0f97927219 Rework UpdateStruct 2025-03-03 22:04:38 +01:00
829b359e82 Implement select struct and scan struct 2025-03-03 21:36:04 +01:00
5fd9755f12 Implement scan struct 2025-03-02 21:54:10 +01:00
ccac439465 Implement update struct 2025-03-02 18:40:12 +01:00
204dacd15e Encrypt field in query 2025-03-01 22:26:50 +01:00
3ce92322f4 Encrypt git token in insertstruct 2025-03-01 21:59:04 +01:00
e89b4a0e14 Use InsertStruct 2025-02-27 21:44:32 +01:00
c0de3538dc Implement insert struct 2025-02-27 21:16:43 +01:00
a80b48956a Add returning tests 2025-02-25 22:36:22 +01:00
96fc490c1d Update migrations for postgres 2025-02-25 22:29:06 +01:00
3b7deaa107 Update workspaces to query builder 2025-02-25 22:11:59 +01:00
9da51aeb5e Update users to query builder 2025-02-25 21:37:06 +01:00
802f192dc0 Implement returning clause 2025-02-25 21:36:55 +01:00
7d05c8aacc Update system to query builder 2025-02-25 21:36:42 +01:00
27b81ef433 Update sessions to query builder 2025-02-25 21:36:26 +01:00
d3ffcfbb53 Replace interface{} with any 2025-02-24 21:42:39 +01:00
96284c3dbd Add query and scanner tests 2025-02-24 21:38:52 +01:00
7cbe6fd272 Add postgres init 2025-02-23 15:19:34 +01:00
a946b8ae76 Implement db struct scanner 2025-02-23 14:59:00 +01:00
c76057d605 Implement sql query builder 2025-02-23 14:58:30 +01:00
25defa5b65 Fix tests for db type 2025-02-22 22:32:38 +01:00
d47f7d7fb0 Use golang migrate for migrations 2025-02-22 21:53:12 +01:00
aef42ff33c Merge pull request #35 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-be4b90d81e
Bump vite from 5.4.10 to 5.4.14 in /app in the npm_and_yarn group across 1 directory
2025-02-02 14:15:46 +01:00
dependabot[bot]
562f84536d Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 5.4.10 to 5.4.14
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.14/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.14/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-22 04:19:07 +00:00
43b5f22793 Merge pull request #34 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-7064c9a8ac
Bump katex from 0.16.11 to 0.16.21 in /app in the npm_and_yarn group across 1 directory
2025-01-21 18:15:27 +01:00
dependabot[bot]
9d0c9dbac6 Bump katex in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [katex](https://github.com/KaTeX/KaTeX).


Updates `katex` from 0.16.11 to 0.16.21
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.16.11...v0.16.21)

---
updated-dependencies:
- dependency-name: katex
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-17 21:39:40 +00:00
f6d2c3d407 Merge pull request #33 from lordmathis/dependabot/go_modules/server/go_modules-a688e28eb2
Bump the go_modules group across 1 directory with 2 updates
2025-01-06 18:58:00 +01:00
dependabot[bot]
e580a2d402 Bump the go_modules group across 1 directory with 2 updates
Bumps the go_modules group with 1 update in the /server directory: [github.com/go-git/go-git/v5](https://github.com/go-git/go-git).


Updates `github.com/go-git/go-git/v5` from 5.12.0 to 5.13.1
- [Release notes](https://github.com/go-git/go-git/releases)
- [Commits](https://github.com/go-git/go-git/compare/v5.12.0...v5.13.1)

Updates `golang.org/x/net` from 0.25.0 to 0.33.0
- [Commits](https://github.com/golang/net/compare/v0.25.0...v0.33.0)

---
updated-dependencies:
- dependency-name: github.com/go-git/go-git/v5
  dependency-type: direct:production
  dependency-group: go_modules
- dependency-name: golang.org/x/net
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-06 16:26:29 +00:00
c450b4ae2f Merge pull request #32 from lordmathis/fix/docker-ca-certs
Add ca-certs to docker
2024-12-27 20:14:17 +01:00
eb98f801a1 Add ca-certs to docker 2024-12-27 20:10:19 +01:00
f1f622cda3 Merge pull request #31 from lordmathis/chore/update-readme
Update README config section
2024-12-27 17:01:59 +01:00
5079f3c96d Update README config section 2024-12-27 16:55:56 +01:00
2861b1a719 Merge pull request #30 from lordmathis/chore/update-docs
Update documentation
2024-12-27 16:50:27 +01:00
9bc1b2ecd4 Update documentation 2024-12-27 16:37:31 +01:00
339ab657b8 Merge pull request #29 from lordmathis/chore/rename
Rename app to Lemma
2024-12-20 00:08:18 +01:00
28d2c053a8 Rename missed files 2024-12-20 00:05:23 +01:00
b6b4c01f0e Rename app to Lemma 2024-12-19 23:59:27 +01:00
5598c0861d Merge pull request #28 from lordmathis/feat/logging
Implement structured logging
2024-12-19 23:45:28 +01:00
f0b6aa0d6e Setup test logging 2024-12-19 23:42:19 +01:00
cf2e1809a4 Delete more debug logs 2024-12-19 22:26:29 +01:00
b065938211 Remove too many debug messages 2024-12-19 22:00:42 +01:00
0aa67f5cc2 Ignore env dev file 2024-12-18 22:17:22 +01:00
f6de4fb839 Log the config after loading 2024-12-18 22:16:20 +01:00
7ccd36f0e4 Add logging to handlers 2024-12-17 23:28:01 +01:00
54cefac4b7 Update package comment 2024-12-16 22:39:26 +01:00
032ae1354c Add logging to storage package 2024-12-16 22:38:21 +01:00
03e78c3e6b Harmonize logging 2024-12-16 21:25:17 +01:00
b00f01f033 Add logging to secrets package 2024-12-16 21:25:06 +01:00
51004d980d Implement logging for git package 2024-12-16 21:16:31 +01:00
e7a48fcd27 Add logging to context 2024-12-16 20:59:21 +01:00
d8528d1b42 Merge pull request #27 from LordMathis/dependabot/go_modules/server/go_modules-5a9c29dde4
Bump golang.org/x/crypto from 0.21.0 to 0.31.0 in /server in the go_modules group across 1 directory
2024-12-15 18:48:04 +01:00
3edce8a0b9 Add logging to auth package 2024-12-15 18:03:04 +01:00
d6680d8e03 Update db logging 2024-12-15 17:28:53 +01:00
8dd57bdf0b Use lowercase for log messages 2024-12-15 17:13:40 +01:00
a32e0957ed Add logging to app package 2024-12-15 16:46:29 +01:00
ab00276f0d Add logging to db package 2024-12-15 14:12:39 +01:00
d14eae4de4 Add Logger interface 2024-12-15 13:34:31 +01:00
71df436a93 Simplify logging 2024-12-14 23:59:28 +01:00
1ee8d94789 Add logger to server 2024-12-12 21:06:15 +01:00
9d82b6426c Use slog for logging 2024-12-12 20:53:35 +01:00
dependabot[bot]
6280586aaf Bump golang.org/x/crypto
Bumps the go_modules group with 1 update in the /server directory: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.21.0 to 0.31.0
- [Commits](https://github.com/golang/crypto/compare/v0.21.0...v0.31.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-12 15:49:43 +00:00
ea916c3ecc Initial logging implementation 2024-12-10 22:16:50 +01:00
3d03da221b Merge pull request #26 from LordMathis/feat/cookies
Implement cookie auth
2024-12-09 21:19:35 +01:00
26a208123a Implement cookie auth on frontend 2024-12-09 20:57:56 +01:00
8a62c9e306 Regenerate docs 2024-12-08 17:47:44 +01:00
a7a97caa6a Regenerate api docs 2024-12-08 17:46:59 +01:00
8ebbe1e076 Update api docs 2024-12-08 17:43:50 +01:00
ed6ac312ed Fix failing logout tests 2024-12-08 17:22:39 +01:00
2268ea48f2 Fix session validation 2024-12-08 17:13:34 +01:00
69af630332 Update tests to use test user struct 2024-12-08 15:03:39 +01:00
2e1ccb45d6 Remove extra argument 2024-12-07 23:16:12 +01:00
5633406f5c Update handler integration tests 2024-12-07 23:09:57 +01:00
ad4af2f82d Update auth test 2024-12-07 21:41:37 +01:00
8a4508e29f Update session and cookie managers 2024-12-07 21:19:02 +01:00
de9e9102db Migrate backend auth to cookies 2024-12-05 21:56:35 +01:00
b4528c1561 Merge pull request #25 from LordMathis/chore/readme-badge
Readme badges
2024-12-04 22:09:10 +01:00
1c8e16fc80 Rename workflow 2024-12-04 21:52:21 +01:00
bd90cf3813 Add workflow badges 2024-12-04 21:50:49 +01:00
836e233954 Merge pull request #24 from LordMathis/chore/apidocs
Create swagger api docs
2024-12-04 21:44:35 +01:00
3d36c67c90 Update frontend requests 2024-12-04 21:33:34 +01:00
dc8fc6c225 Working swagger api docs 2024-12-03 22:06:57 +01:00
e413e955c5 Update api docs 2024-12-03 21:50:16 +01:00
c400d81c87 Add initial api doc comments 2024-12-02 22:28:12 +01:00
08e76671d0 Rework app setup 2024-12-02 21:23:47 +01:00
7d74821f29 Merge pull request #23 from LordMathis/chore/update-readme
Update README
2024-11-30 22:11:17 +01:00
66415738b6 Add upgrading section 2024-11-30 22:07:58 +01:00
dff66ab1b4 Update README 2024-11-30 22:05:26 +01:00
05933f8c22 Merge pull request #22 from LordMathis/fix/missing-git-email
Fix missing commit author name and email
2024-11-30 21:36:14 +01:00
68ad70b0b7 Add commit author and email to frontend settings 2024-11-30 21:21:54 +01:00
d5d2792689 Fix missing admin user setup 2024-11-30 19:38:47 +01:00
31d00681a1 Update handlers and db 2024-11-30 16:12:51 +01:00
4359267d55 Add commit name and commit email to workspace model 2024-11-30 13:56:07 +01:00
c07f19bbb3 Merge migrations 2024-11-30 13:55:29 +01:00
325cedc235 Add commit name and commit email to git client 2024-11-30 12:54:47 +01:00
453c022959 Merge pull request #21 from LordMathis/chore/split-main
Split main into app package
2024-11-30 12:11:06 +01:00
de2a9364ab Split main into app package 2024-11-30 12:06:34 +01:00
cfa048b8de Merge pull request #20 from LordMathis/chore/backend-test
Implement backend tests
2024-11-30 11:51:35 +01:00
842513f8a5 Add test tags to github workflow 2024-11-30 11:48:57 +01:00
ae48761d34 Implement workspace handlers integration tests 2024-11-30 11:44:17 +01:00
8bed3614ee Fix user deletion handler 2024-11-30 00:12:14 +01:00
2a53be5a6e Fix user update tests 2024-11-30 00:09:01 +01:00
af9ab42969 Add integration tests for use handlers 2024-11-29 23:57:17 +01:00
d47b601447 Rename mock secrets 2024-11-29 23:15:37 +01:00
1ddf93a8be Implement git handlers integration test 2024-11-29 23:14:36 +01:00
6aa3fd6c65 Add script for generating single file documentation 2024-11-28 22:05:27 +01:00
9b4db528ca Fix lint issues 2024-11-28 21:55:01 +01:00
f5d616fe00 Update documentation 2024-11-28 21:53:03 +01:00
51ed9e53a4 Implement static handler tests 2024-11-28 21:33:28 +01:00
3fb40a8817 Implement file handlers integration tests 2024-11-28 21:18:47 +01:00
91489ca633 Update path validation error handling 2024-11-28 21:18:30 +01:00
fbb8fa3a60 Implement admin handlers integration test 2024-11-27 21:28:59 +01:00
4ddf1f570f Implement auth handler integration test 2024-11-26 22:50:43 +01:00
e8868dde39 Test users and workspaces 2024-11-25 21:58:16 +01:00
32bd202d6f Implement session and system tests 2024-11-25 21:44:43 +01:00
9ac047d440 Delete unused test case fixture 2024-11-25 20:54:49 +01:00
1e7cd0934e Add migrations tests 2024-11-24 00:17:08 +01:00
9d81b1036d Refactor db init 2024-11-23 22:33:55 +01:00
9f241271a7 Test context package 2024-11-23 22:15:25 +01:00
8f2f8b30dd Test secrets package 2024-11-23 21:28:15 +01:00
1150c4ba39 Test config package 2024-11-23 16:36:29 +01:00
ebdd7bd741 Implement auth package tests 2024-11-23 00:29:26 +01:00
b3ec4e136c Implement auth tests 2024-11-22 23:17:59 +01:00
807e96a76c Rework db package to make it testable 2024-11-21 22:36:12 +01:00
2faefb6db5 Implement JWTManager interface 2024-11-21 21:25:29 +01:00
435dce89d9 Add go test workflow 2024-11-21 19:42:50 +01:00
6cb5aec372 Implement storage git tests 2024-11-20 22:06:38 +01:00
7396b57a5d Rework gitutils package to make it testable 2024-11-19 22:43:24 +01:00
53e52bfdb5 Test workspace 2024-11-19 22:17:00 +01:00
de2c9a6d0c Implement files test 2024-11-19 21:44:06 +01:00
2fe642ac61 Rework mock filesystem 2024-11-19 21:43:52 +01:00
408746187e Implement test list files 2024-11-14 22:11:40 +01:00
e4510298ed Rename filesystem interfaces and structs 2024-11-14 21:13:45 +01:00
5311d2e144 Move storage to separate file 2024-11-13 22:34:22 +01:00
6a9461d928 Rename fs variable 2024-11-13 22:32:43 +01:00
93963b1867 Refactor filesystem to make it testable 2024-11-13 22:31:04 +01:00
52ffb17e2d Merge pull request #19 from LordMathis/chore/rename-folders
Rename root folders
2024-11-12 21:28:42 +01:00
fb1c9a499f Rename root folders 2024-11-12 21:25:02 +01:00
f4c21edca0 Merge pull request #18 from LordMathis/feat/show-hidden-setting
Implement show hidden files setting
2024-11-12 21:08:23 +01:00
1b58b693d0 Add show hidden files toggle to settings 2024-11-12 20:27:56 +01:00
d11525732d Filter hidden files on frontend 2024-11-12 20:15:12 +01:00
03cdb133e7 Fix get workspace db query 2024-11-12 20:14:58 +01:00
bac4702771 Add show_hidden_files filed to workspace 2024-11-11 22:24:27 +01:00
f3f3cb7371 Merge pull request #17 from LordMathis/feat/security-hardening
Implement Rate Limit, Secure Headers and CORS
2024-11-10 21:09:02 +01:00
d4c671caa7 Increase default rate limit 2024-11-10 20:56:04 +01:00
29b35f6b91 Add password length check 2024-11-10 20:49:07 +01:00
e275b45c86 Add secure headers and cors middlewares 2024-11-10 20:43:24 +01:00
77d9abb691 Implement rate limiting 2024-11-10 18:12:25 +01:00
8cf850a62c Merge pull request #16 from LordMathis/feat/admin-dashboard
Admin dashboard
2024-11-10 15:16:42 +01:00
5e2d434b4b Implement admin dash workspaces tab 2024-11-10 15:03:51 +01:00
148001be43 Implement admin edit user 2024-11-10 00:05:32 +01:00
33bc28560f Update admin stats 2024-11-09 23:33:07 +01:00
118591df62 Unify errors 2024-11-09 23:12:52 +01:00
7b1da94e8a Implement file system stats 2024-11-09 23:11:23 +01:00
9688b2d528 Split filesystem.go file 2024-11-09 21:59:04 +01:00
ebf32e775c Remove not working stats 2024-11-08 23:58:57 +01:00
dd3ea9f65f Improve admin dashboard 2024-11-08 23:49:12 +01:00
51751a5af6 Load users in AdminDashboard 2024-11-07 22:12:37 +01:00
0480c165ae Implement admin api handlers 2024-11-07 21:32:09 +01:00
24f877e50b Initial admin dashboard layout 2024-11-06 23:34:37 +01:00
adf5287db2 Merge pull request #15 from LordMathis/chore/componants-structure
Restructure components
2024-11-06 23:16:05 +01:00
0f6dcd3a60 Split AccountSettings 2024-11-06 23:12:38 +01:00
7f8c40c3a2 Reorganize components 2024-11-06 22:31:29 +01:00
64029615ea Merge pull request #14 from LordMathis/feat/user-auth
User authentication and account settings
2024-11-06 21:56:48 +01:00
1a14c06be2 Retrieve pass hash from db 2024-11-06 21:52:46 +01:00
48f75b3839 Update AccountSettings layout 2024-11-06 21:51:45 +01:00
e56378f1f0 Imlement user update on frontend 2024-11-05 21:56:08 +01:00
505b93ff09 Implement update and delete account handlers 2024-11-05 21:49:09 +01:00
8b8bfaa8c8 Add account settings 2024-11-05 21:03:25 +01:00
9581e32e06 Implement frontend logout 2024-11-04 22:26:05 +01:00
771650d66e Reset file when workspace switch 2024-11-04 22:09:11 +01:00
69afef15ec Make logging in work on frontend 2024-11-04 21:51:38 +01:00
9cdbf9fec8 Add initial frontend auth implementation 2024-11-03 23:16:57 +01:00
fae628c02b Remove user id from frintend api call 2024-11-03 22:14:17 +01:00
927d1feb05 Split user and workspace contexts 2024-11-03 22:02:39 +01:00
c8cc854fd6 Rework request context handler 2024-11-03 19:17:10 +01:00
dfd9544fba Reorganize handlers 2024-11-03 17:41:17 +01:00
72680abdf4 Setup api auth middleware 2024-11-01 17:04:24 +01:00
46eeb18a31 Setup jwt signing key 2024-11-01 17:04:08 +01:00
34868c53eb Update api for auth 2024-11-01 15:43:04 +01:00
be0f97ab24 Update db for auth 2024-11-01 15:42:46 +01:00
ce245c980a Implement jwt auth backend 2024-11-01 15:42:31 +01:00
ea91d8d608 Merge pull request #13 from LordMathis/fix/package-json
Fix wrong package.json
2024-10-31 23:17:02 +01:00
0b0a5253f0 Fix wrong package.json 2024-10-31 23:13:23 +01:00
f8cd11c9ac Merge pull request #12 from LordMathis/feat/remark
Migrate from react-markdown to remark
2024-10-31 23:04:42 +01:00
0ed2813643 Migrate to rehype-mathjax 2024-10-31 23:03:50 +01:00
3c855fce21 Migrate to remark 2024-10-31 22:41:50 +01:00
279 changed files with 55214 additions and 9387 deletions

View File

@@ -1,4 +1,4 @@
name: Build, Push, and Release
name: Docker Build
on:
push:
@@ -8,7 +8,7 @@ on:
env:
REGISTRY: ghcr.io
OWNER: lordmathis
REPO: novamd
REPO: lemma
jobs:
build-and-push-image:

102
.github/workflows/codeql.yml vendored Normal file
View 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
View 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

56
.github/workflows/go-test.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: Go Tests
on:
push:
branches:
- "*"
paths:
- "server/**"
pull_request:
branches:
- main
permissions:
contents: read
jobs:
test:
name: Run Tests
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:
run:
working-directory: ./server
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
cache: true
- name: Run Tests
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
run: go test -tags=test,integration -race ./... -v

43
.github/workflows/typescript.yml vendored Normal file
View 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
View File

@@ -157,6 +157,7 @@ go.work.sum
# env file
.env
.env.dev
main
*.db

36
.vscode/launch.json vendored Normal file
View 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
}
]
}

View File

@@ -14,6 +14,10 @@
"go.lintTool": "golangci-lint",
"go.lintOnSave": "package",
"go.formatTool": "goimports",
"go.testFlags": ["-tags=test,integration"],
"go.testEnvVars": {
"LEMMA_TEST_POSTGRES_URL": "postgres://postgres:postgres@localhost:5432/lemma_test?sslmode=disable"
},
"[go]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
@@ -23,6 +27,7 @@
},
"gopls": {
"usePlaceholders": true,
"staticcheck": true
"staticcheck": true,
"buildFlags": ["-tags", "test,integration"]
}
}

View File

@@ -1,33 +1,35 @@
# Stage 1: Build the frontend
FROM node:20 AS frontend-builder
FROM node:24-slim AS frontend-builder
WORKDIR /app
COPY frontend/package*.json ./
COPY app/package*.json ./
RUN npm ci
COPY frontend .
COPY app .
RUN npm run build
# Stage 2: Build the backend
FROM golang:1.23 AS backend-builder
WORKDIR /app
RUN apt-get update && apt-get install -y gcc musl-dev
COPY backend/go.mod backend/go.sum ./
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY backend .
RUN CGO_ENABLED=1 GOOS=linux go build -o novamd ./cmd/server
COPY server .
RUN CGO_ENABLED=1 GOOS=linux go build -o lemma ./cmd/server
# Stage 3: Final stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates
RUN update-ca-certificates
WORKDIR /app
COPY --from=backend-builder /app/novamd .
COPY --from=backend-builder /app/lemma .
COPY --from=frontend-builder /app/dist ./dist
RUN mkdir -p /app/data
# Set default environment variables
ENV NOVAMD_STATIC_PATH=/app/dist
ENV NOVAMD_PORT=8080
ENV NOVAMD_WORKDIR=/app/data
ENV LEMMA_STATIC_PATH=/app/dist
ENV LEMMA_PORT=8080
ENV LEMMA_WORKDIR=/app/data
EXPOSE 8080
CMD ["./novamd"]
CMD ["./lemma"]

View File

@@ -1,4 +1,6 @@
# NovaMD
# Lemma
![Build](https://github.com/lordmathis/lemma/actions/workflows/build-and-release.yml/badge.svg) ![Backend Tests](https://github.com/lordmathis/lemma/actions/workflows/go-test.yml/badge.svg) ![Frontend Tests](https://github.com/lordmathis/lemma/actions/workflows/frontend-tests.yml/badge.svg)
Yet another markdown editor. Work in progress
@@ -9,7 +11,7 @@ Yet another markdown editor. Work in progress
- Git integration for version control
- Dark and light theme support
- Multiple workspaces
- Math equation support (KaTeX)
- Math equation support (MathJax)
- Code syntax highlighting
## Prerequisites
@@ -18,37 +20,50 @@ Yet another markdown editor. Work in progress
- Node.js 20 or later
- gcc (for go-sqlite3 package)
## Setup
## Configuration
Set the following environment variables:
Lemma can be configured using environment variables. Here are the available configuration options:
- `CGO_ENABLED=1`: This is necessary for the go-sqlite3 package
- `NOVAMD_DB_PATH`: Path to the SQLite database file (default: "./sqlite.db")
- `NOVAMD_WORKDIR`: Directory for storing Markdown files (default: "./data")
- `NOVAMD_STATIC_PATH`: Path to the frontend build files (default: "../frontend/dist")
- `NOVAMD_PORT`: Port to run the server on (default: "8080")
- `NOVAMD_ADMIN_EMAIL`: Admin user email
- `NOVAMD_ADMIN_PASSWORD`: Admin user password
- `NOVAMD_ENCRYPTION_KEY`: 32-byte key for encrypting sensitive data
### Required Environment Variables
To generate a secure encryption key you can use openssl:
- `LEMMA_ADMIN_EMAIL`: Email address for the admin account
- `LEMMA_ADMIN_PASSWORD`: Password for the admin account
```bash
openssl rand -base64 32
```
### Optional Environment Variables
## Running the Backend
- `LEMMA_ENV`: Set to "development" to enable development mode
- `LEMMA_DB_URL`: URL (Connection string) to the database. Supported databases are sqlite and postgres a (default: "./lemma.db")
- `LEMMA_WORKDIR`: Working directory for application data (default: "sqlite://lemma.db")
- `LEMMA_STATIC_PATH`: Path to static files (default: "../app/dist")
- `LEMMA_PORT`: Port to run the server on (default: "8080")
- `LEMMA_DOMAIN`: Domain name where the application is hosted for cookie authentication
- `LEMMA_CORS_ORIGINS`: Comma-separated list of allowed CORS origins
- `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`
- `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`
- `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)
1. Navigate to the `backend` directory
2. Ensure all environment variables are set
3. Run the server:
### Security Keys
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).
**Important**: Back up the `secrets` directory! If these keys are lost, encrypted data will become inaccessible and all users will need to re-authenticate.
## Running the backend server
1. Navigate to the `server` directory
2. Install dependecies: `go mod tidy`
3. Ensure all environment variables are set
4. Additionally set `CGO_ENABLED=1` (needed for sqlite3)
5. Run the server:
```
go run cmd/server/main.go
```
## Running the Frontend
## Running the frontend app
1. Navigate to the `frontend` directory
1. Navigate to the `app` directory
2. Install dependencies:
```
npm install
@@ -59,20 +74,20 @@ openssl rand -base64 32
```
The frontend will be available at `http://localhost:3000`
## Building for Production
## Building for production
1. Build the frontend:
1. Build the frontend app:
```
cd frontend
cd app
npm run build
```
2. Build the backend:
```
cd backend
go build -o novamd ./cmd/server
cd server
go build -o lemma ./cmd/server
```
3. Set the `NOVAMD_STATIC_PATH` environment variable to point to the frontend build directory
4. Run the `novamd` executable
3. Set the `LEMMA_STATIC_PATH` environment variable to point to the frontend build directory
4. Run the `lemma` executable
## Docker Support
@@ -80,9 +95,13 @@ A Dockerfile is provided for easy deployment. To build and run the Docker image:
1. Build the image:
```
docker build -t novamd .
docker build -t lemma .
```
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
Before first stable release (1.0.0) there is not upgrade path. You have to delete the database file and start over.

114
app/eslint.config.mjs Normal file
View 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',
},
},
]);

10280
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

94
app/package.json Normal file
View File

@@ -0,0 +1,94 @@
{
"name": "lemma-frontend",
"version": "0.1.0",
"description": "Yet another markdown editor",
"type": "module",
"scripts": {
"start": "vite",
"build": "tsc && vite build",
"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": {
"type": "git",
"url": "git+https://github.com/LordMathis/Lemma.git"
},
"keywords": [
"markdown",
"editor"
],
"author": "Matúš Námešný",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/LordMathis/Lemma/issues"
},
"homepage": "https://github.com/LordMathis/Lemma#readme",
"dependencies": {
"@codemirror/commands": "^6.6.2",
"@codemirror/lang-markdown": "^6.2.5",
"@codemirror/state": "^6.4.1",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.34.0",
"@mantine/core": "^7.13.2",
"@mantine/hooks": "^7.13.2",
"@mantine/modals": "^7.13.2",
"@mantine/notifications": "^7.13.2",
"@react-hook/resize-observer": "^2.0.2",
"@tabler/icons-react": "^3.19.0",
"codemirror": "^6.0.1",
"react": "^18.3.1",
"react-arborist": "^3.4.0",
"react-dom": "^18.3.1",
"rehype-highlight": "^7.0.2",
"rehype-mathjax": "^6.0.0",
"rehype-react": "^8.0.0",
"remark": "^15.0.1",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@eslint/compat": "^1.2.9",
"@testing-library/jest-dom": "^6.6.3",
"@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-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"sass": "^1.80.4",
"typescript": "^5.8.2",
"vite": "^6.4.1",
"vite-plugin-compression2": "^1.3.0",
"vitest": "^3.1.4"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

69
app/src/App.tsx Normal file
View File

@@ -0,0 +1,69 @@
import React from 'react';
import {
MantineProvider,
ColorSchemeScript,
localStorageColorSchemeManager,
} from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { ModalsProvider } from '@mantine/modals';
import Layout from './components/layout/Layout';
import LoginPage from './components/auth/LoginPage';
import { WorkspaceProvider } from './contexts/WorkspaceContext';
import { ModalProvider } from './contexts/ModalContext';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import './App.scss';
type AuthenticatedContentProps = object;
const AuthenticatedContent: React.FC<AuthenticatedContentProps> = () => {
const { user, loading, initialized } = useAuth();
if (!initialized) {
return null;
}
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
return <LoginPage />;
}
return (
<WorkspaceProvider>
<ModalProvider>
<Layout />
</ModalProvider>
</WorkspaceProvider>
);
};
type AppProps = object;
const colorSchemeManager = localStorageColorSchemeManager({
key: 'mantine-color-scheme',
});
const App: React.FC<AppProps> = () => {
return (
<>
<ColorSchemeScript defaultColorScheme="light" />
<MantineProvider
defaultColorScheme="light"
colorSchemeManager={colorSchemeManager}
>
<Notifications />
<ModalsProvider>
<AuthProvider>
<AuthenticatedContent />
</AuthProvider>
</ModalsProvider>
</MantineProvider>
</>
);
};
export default App;

135
app/src/api/admin.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
};

View 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
);
});
});
});
});

View File

@@ -0,0 +1,71 @@
import React, { useState, type FormEvent } from 'react';
import {
TextInput,
PasswordInput,
Paper,
Title,
Container,
Button,
Text,
Stack,
} from '@mantine/core';
import { useAuth } from '../../contexts/AuthContext';
const LoginPage: React.FC = () => {
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const { login } = useAuth();
const handleSubmit = (e: FormEvent<HTMLElement>): void => {
e.preventDefault();
setLoading(true);
login(email, password)
.catch((error) => {
console.error('Login failed:', error);
})
.finally(() => {
setLoading(false);
});
};
return (
<Container size={420} my={40}>
<Title ta="center">Welcome to Lemma</Title>
<Text c="dimmed" size="sm" ta="center" mt={5}>
Please sign in to continue
</Text>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form onSubmit={handleSubmit} role="form">
<Stack>
<TextInput
type="email"
label="Email"
placeholder="your@email.com"
data-testid="email-input"
required
value={email}
onChange={(event) => setEmail(event.currentTarget.value)}
/>
<PasswordInput
label="Password"
placeholder="Your password"
data-testid="password-input"
required
value={password}
onChange={(event) => setPassword(event.currentTarget.value)}
/>
<Button type="submit" loading={loading} data-testid="login-button">
Sign in
</Button>
</Stack>
</form>
</Paper>
</Container>
);
};
export default LoginPage;

View 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();
});
});

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { Text, Center } from '@mantine/core';
import Editor from './Editor';
import MarkdownPreview from './MarkdownPreview';
import { getFileUrl, isImageFile } from '../../utils/fileHelpers';
import { useWorkspace } from '@/contexts/WorkspaceContext';
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,
selectedFile,
content,
handleContentChange,
handleSave,
handleFileSelect,
}) => {
const { currentWorkspace } = useWorkspace();
if (!currentWorkspace) {
return (
<Center style={{ height: '100%' }}>
<Text size="xl" fw={500}>
No workspace selected.
</Text>
</Center>
);
}
if (!selectedFile) {
return (
<Center style={{ height: '100%' }}>
<Text size="xl" fw={500}>
No file selected.
</Text>
</Center>
);
}
if (isImageFile(selectedFile)) {
return (
<Center className="image-preview">
<img
src={getFileUrl(currentWorkspace.name, selectedFile)}
alt={selectedFile}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
/>
</Center>
);
}
return activeTab === 'source' ? (
<Editor
content={content}
handleContentChange={handleContentChange}
handleSave={handleSave}
selectedFile={selectedFile}
/>
) : (
<MarkdownPreview content={content} handleFileSelect={handleFileSelect} />
);
};
export default ContentView;

View File

@@ -5,16 +5,28 @@ import { EditorView, keymap } from '@codemirror/view';
import { markdown } from '@codemirror/lang-markdown';
import { defaultKeymap } from '@codemirror/commands';
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 editorRef = useRef();
const viewRef = useRef();
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
useEffect(() => {
const handleEditorSave = (view) => {
handleSave(selectedFile, view.state.doc.toString());
const handleEditorSave = (view: EditorView): boolean => {
void handleSave(selectedFile, view.state.doc.toString());
return true;
};
@@ -36,6 +48,8 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
},
});
if (!editorRef.current) return;
const state = EditorState.create({
doc: content,
extensions: [
@@ -69,8 +83,11 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
return () => {
view.destroy();
viewRef.current = null;
};
}, [colorScheme, handleContentChange]);
// TODO: Refactor
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [colorScheme, handleContentChange, handleSave, selectedFile]);
useEffect(() => {
if (viewRef.current && content !== viewRef.current.state.doc.toString()) {

View 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 = '![Test Image](invalid-image.jpg)';
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'
);
});
});
});

View 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;

View 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();
});
});

View 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;

View 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();
});
});
});

View 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;

View 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();
});
});

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Group, Text } from '@mantine/core';
import UserMenu from '../navigation/UserMenu';
import WorkspaceSwitcher from '../navigation/WorkspaceSwitcher';
import WorkspaceSettings from '../settings/workspace/WorkspaceSettings';
const Header: React.FC = () => {
return (
<Group justify="space-between" h={60} px="md">
<Text fw={700} size="lg">
Lemma
</Text>
<Group>
<WorkspaceSwitcher />
<UserMenu />
</Group>
<WorkspaceSettings />
</Group>
);
};
export default Header;

View 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();
});
});

View File

@@ -3,19 +3,22 @@ import { AppShell, Container, Loader, Center } from '@mantine/core';
import Header from './Header';
import Sidebar from './Sidebar';
import MainContent from './MainContent';
import { useFileNavigation } from '../hooks/useFileNavigation';
import { useFileList } from '../hooks/useFileList';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useFileNavigation } from '../../hooks/useFileNavigation';
import { useFileList } from '../../hooks/useFileList';
import { useWorkspace } from '../../hooks/useWorkspace';
const Layout = () => {
const Layout: React.FC = () => {
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
const { selectedFile, handleFileSelect, handleLinkClick } =
useFileNavigation();
const { selectedFile, handleFileSelect } = useFileNavigation();
const { files, loadFileList } = useFileList();
if (workspaceLoading) {
return (
<Center style={{ height: '100vh' }}>
<Center
style={{ height: '100vh' }}
role="status"
aria-label="Loading workspace"
>
<Loader size="xl" />
</Center>
);
@@ -49,7 +52,6 @@ const Layout = () => {
<MainContent
selectedFile={selectedFile}
handleFileSelect={handleFileSelect}
handleLinkClick={handleLinkClick}
loadFileList={loadFileList}
/>
</Container>

View 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');
});
});

View File

@@ -0,0 +1,180 @@
import React, { useState, useCallback, useMemo } from 'react';
import { Tabs, Breadcrumbs, Group, Box, Text, Flex } from '@mantine/core';
import { IconCode, IconEye, IconPointFilled } from '@tabler/icons-react';
import ContentView from '../editor/ContentView';
import CreateFileModal from '../modals/file/CreateFileModal';
import DeleteFileModal from '../modals/file/DeleteFileModal';
import RenameFileModal from '../modals/file/RenameFileModal';
import CommitMessageModal from '../modals/git/CommitMessageModal';
import { useFileContent } from '../../hooks/useFileContent';
import { useFileOperations } from '../../hooks/useFileOperations';
import { useGitOperations } from '../../hooks/useGitOperations';
import { useModalContext } from '../../contexts/ModalContext';
type ViewTab = 'source' | 'preview';
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 {
content,
hasUnsavedChanges,
setHasUnsavedChanges,
handleContentChange,
} = useFileContent(selectedFile);
const { handleSave, handleCreate, handleDelete, handleRename } =
useFileOperations();
const { handleCommitAndPush } = useGitOperations();
const { setRenameFileModalVisible } = useModalContext();
const handleTabChange = useCallback((value: string | null): void => {
if (value) {
setActiveTab(value as ViewTab);
}
}, []);
const handleSaveFile = useCallback(
async (filePath: string, fileContent: string): Promise<boolean> => {
const success = await handleSave(filePath, fileContent);
if (success) {
setHasUnsavedChanges(false);
}
return success;
},
[handleSave, setHasUnsavedChanges]
);
const handleCreateFile = useCallback(
async (fileName: string): Promise<void> => {
const success = await handleCreate(fileName);
if (success) {
await loadFileList();
await handleFileSelect(fileName);
}
},
[handleCreate, handleFileSelect, loadFileList]
);
const handleDeleteFile = useCallback(
async (filePath: string): Promise<void> => {
const success = await handleDelete(filePath);
if (success) {
await loadFileList();
await handleFileSelect(null);
}
},
[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(() => {
if (!selectedFile) return null;
const pathParts = selectedFile.split('/');
const items = pathParts.map((part, index) => {
// Make the filename (last part) clickable for rename
const isFileName = index === pathParts.length - 1;
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 (
<Group>
<Breadcrumbs separator="/">{items}</Breadcrumbs>
{hasUnsavedChanges && (
<IconPointFilled
size={16}
style={{ color: 'var(--mantine-color-yellow-filled)' }}
/>
)}
</Group>
);
}, [selectedFile, hasUnsavedChanges, handleBreadcrumbClick]);
return (
<Box
style={{
flex: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Flex justify="space-between" align="center" p="md">
{renderBreadcrumbs}
<Tabs value={activeTab} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab value="source" leftSection={<IconCode size="0.8rem" />} />
<Tabs.Tab value="preview" leftSection={<IconEye size="0.8rem" />} />
</Tabs.List>
</Tabs>
</Flex>
<Box style={{ flex: 1, overflow: 'auto' }}>
<ContentView
activeTab={activeTab}
selectedFile={selectedFile}
content={content}
handleContentChange={handleContentChange}
handleSave={handleSaveFile}
handleFileSelect={handleFileSelect}
/>
</Box>
<CreateFileModal onCreateFile={handleCreateFile} />
<DeleteFileModal
onDeleteFile={handleDeleteFile}
selectedFile={selectedFile}
/>
<RenameFileModal
onRenameFile={handleRenameFile}
selectedFile={selectedFile}
/>
<CommitMessageModal onCommitAndPush={handleCommitAndPush} />
</Box>
);
};
export default MainContent;

View 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();
});
});

View 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;

View 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();
});
});
});

View 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;

View 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();
});
});
});

View 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;

View 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();
});
});
});

View File

@@ -1,19 +1,30 @@
import React, { useState } from 'react';
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
import { useModalContext } from '../../contexts/ModalContext';
import { useModalContext } from '../../../contexts/ModalContext';
const CreateFileModal = ({ onCreateFile }) => {
const [fileName, setFileName] = useState('');
interface CreateFileModalProps {
onCreateFile: (fileName: string) => Promise<void>;
}
const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
const [fileName, setFileName] = useState<string>('');
const { newFileModalVisible, setNewFileModalVisible } = useModalContext();
const handleSubmit = async () => {
const handleSubmit = async (): Promise<void> => {
if (fileName) {
await onCreateFile(fileName);
await onCreateFile(fileName.trim());
setFileName('');
setNewFileModalVisible(false);
}
};
const handleKeyDown = (event: React.KeyboardEvent): void => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleSubmit();
}
};
return (
<Modal
opened={newFileModalVisible}
@@ -25,9 +36,12 @@ const CreateFileModal = ({ onCreateFile }) => {
<Box maw={400} mx="auto">
<TextInput
label="File Name"
type="text"
placeholder="Enter file name"
data-testid="file-name-input"
value={fileName}
onChange={(event) => setFileName(event.currentTarget.value)}
onKeyDown={handleKeyDown}
mb="md"
w="100%"
/>
@@ -35,10 +49,17 @@ const CreateFileModal = ({ onCreateFile }) => {
<Button
variant="default"
onClick={() => setNewFileModalVisible(false)}
data-testid="cancel-create-file-button"
>
Cancel
</Button>
<Button onClick={handleSubmit}>Create</Button>
<Button
onClick={() => void handleSubmit()}
data-testid="confirm-create-file-button"
disabled={!fileName.trim()}
>
Create
</Button>
</Group>
</Box>
</Modal>

View 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');
});
});
});

View File

@@ -1,12 +1,22 @@
import React from 'react';
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 } =
useModalContext();
const handleConfirm = async () => {
const handleConfirm = async (): Promise<void> => {
if (!selectedFile) return;
await onDeleteFile(selectedFile);
setDeleteFileModalVisible(false);
};
@@ -18,15 +28,20 @@ const DeleteFileModal = ({ onDeleteFile, selectedFile }) => {
title="Delete File"
centered
>
<Text>Are you sure you want to delete "{selectedFile}"?</Text>
<Text>Are you sure you want to delete &quot;{selectedFile}&quot;?</Text>
<Group justify="flex-end" mt="xl">
<Button
variant="default"
onClick={() => setDeleteFileModalVisible(false)}
data-testid="cancel-delete-file-button"
>
Cancel
</Button>
<Button color="red" onClick={handleConfirm}>
<Button
color="red"
onClick={() => void handleConfirm()}
data-testid="confirm-delete-file-button"
>
Delete
</Button>
</Group>

View 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;

View 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');
});
});
});

View File

@@ -1,20 +1,34 @@
import React, { useState } from 'react';
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 { commitMessageModalVisible, setCommitMessageModalVisible } =
useModalContext();
const handleSubmit = async () => {
if (message) {
await onCommitAndPush(message);
const handleSubmit = async (): Promise<void> => {
const commitMessage = message.trim();
if (commitMessage) {
await onCommitAndPush(commitMessage);
setMessage('');
setCommitMessageModalVisible(false);
}
};
const handleKeyDown = (event: React.KeyboardEvent): void => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleSubmit();
}
};
return (
<Modal
opened={commitMessageModalVisible}
@@ -25,10 +39,13 @@ const CommitMessageModal = ({ onCommitAndPush }) => {
>
<Box maw={400} mx="auto">
<TextInput
type="text"
label="Commit Message"
data-testid="commit-message-input"
placeholder="Enter commit message"
value={message}
onChange={(event) => setMessage(event.currentTarget.value)}
onKeyDown={handleKeyDown}
mb="md"
w="100%"
/>
@@ -36,10 +53,17 @@ const CommitMessageModal = ({ onCommitAndPush }) => {
<Button
variant="default"
onClick={() => setCommitMessageModalVisible(false)}
data-testid="cancel-commit-message-button"
>
Cancel
</Button>
<Button onClick={handleSubmit}>Commit</Button>
<Button
onClick={() => void handleSubmit()}
data-testid="confirm-commit-message-button"
disabled={!message.trim()}
>
Commit
</Button>
</Group>
</Box>
</Modal>

View 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();
});
});
});

View 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;

View 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();
});
});
});

View 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 &quot;{user?.email}&quot;? 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;

View 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();
});
});
});

View File

@@ -0,0 +1,137 @@
import React, { useState, useEffect } from 'react';
import {
Modal,
Stack,
TextInput,
Select,
Button,
Group,
PasswordInput,
Text,
} from '@mantine/core';
import type { UpdateUserRequest } from '@/types/api';
import { type User, UserRole } from '@/types/models';
interface EditUserModalProps {
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: '',
displayName: '',
role: UserRole.Editor,
password: '',
});
useEffect(() => {
if (user) {
setFormData({
email: user.email,
displayName: user.displayName || '',
role: user.role,
password: '',
});
}
}, [user]);
const handleSubmit = async (): Promise<void> => {
if (!user) return;
const updateData = {
...formData,
...(formData.password ? { password: formData.password } : {}),
};
const success = await onEditUser(user.id, updateData);
if (success) {
setFormData({
email: '',
displayName: '',
role: UserRole.Editor,
password: '',
});
onClose();
}
};
return (
<Modal opened={opened} onClose={onClose} title="Edit User" centered>
<Stack>
<TextInput
label="Email"
required
value={formData.email}
data-testid="edit-user-email-input"
onChange={(e) =>
setFormData({ ...formData, email: e.currentTarget.value })
}
placeholder="user@example.com"
/>
<TextInput
label="Display Name"
value={formData.displayName}
data-testid="edit-user-display-name-input"
onChange={(e) =>
setFormData({ ...formData, displayName: e.currentTarget.value })
}
placeholder="John Doe"
/>
<Select
label="Role"
required
value={formData.role ? formData.role.toString() : null}
data-testid="edit-user-role-select"
onChange={(value) =>
setFormData({ ...formData, role: value as UserRole })
}
data={[
{ value: UserRole.Admin, label: 'Admin' },
{ value: UserRole.Editor, label: 'Editor' },
{ value: UserRole.Viewer, label: 'Viewer' },
]}
/>
<PasswordInput
label="New Password"
value={formData.password}
data-testid="edit-user-password-input"
onChange={(e) =>
setFormData({ ...formData, password: e.currentTarget.value })
}
placeholder="Enter new password (leave empty to keep current)"
/>
<Text size="xs" c="dimmed">
Leave password empty to keep the current password
</Text>
<Group justify="flex-end" mt="md">
<Button
variant="default"
onClick={onClose}
data-testid="cancel-edit-user-button"
>
Cancel
</Button>
<Button
onClick={() => void handleSubmit()}
loading={loading}
data-testid="confirm-edit-user-button"
>
Save Changes
</Button>
</Group>
</Stack>
</Modal>
);
};
export default EditUserModal;

View File

@@ -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');
});
});
});

View File

@@ -1,17 +1,25 @@
import React, { useState } from 'react';
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
import { useModalContext } from '../../contexts/ModalContext';
import { createWorkspace } from '../../services/api';
import { useModalContext } from '../../../contexts/ModalContext';
import { notifications } from '@mantine/notifications';
import type { Workspace } from '@/types/models';
import { createWorkspace } from '@/api/workspace';
const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
interface CreateWorkspaceModalProps {
onWorkspaceCreated?: (workspace: Workspace) => Promise<void>;
}
const CreateWorkspaceModal: React.FC<CreateWorkspaceModalProps> = ({
onWorkspaceCreated,
}) => {
const [name, setName] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const { createWorkspaceModalVisible, setCreateWorkspaceModalVisible } =
useModalContext();
const handleSubmit = async () => {
if (!name.trim()) {
const handleSubmit = async (): Promise<void> => {
const trimmedName = name.trim();
if (!trimmedName) {
notifications.show({
title: 'Error',
message: 'Workspace name is required',
@@ -22,7 +30,7 @@ const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
setLoading(true);
try {
const workspace = await createWorkspace(name);
const workspace = await createWorkspace(trimmedName);
notifications.show({
title: 'Success',
message: 'Workspace created successfully',
@@ -31,9 +39,9 @@ const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
setName('');
setCreateWorkspaceModalVisible(false);
if (onWorkspaceCreated) {
onWorkspaceCreated(workspace);
await onWorkspaceCreated(workspace);
}
} catch (error) {
} catch (_error) {
notifications.show({
title: 'Error',
message: 'Failed to create workspace',
@@ -54,8 +62,10 @@ const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
>
<Box maw={400} mx="auto">
<TextInput
type="text"
label="Workspace Name"
placeholder="Enter workspace name"
data-testid="workspace-name-input"
value={name}
onChange={(event) => setName(event.currentTarget.value)}
mb="md"
@@ -67,10 +77,15 @@ const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
variant="default"
onClick={() => setCreateWorkspaceModalVisible(false)}
disabled={loading}
data-testid="cancel-create-workspace-button"
>
Cancel
</Button>
<Button onClick={handleSubmit} loading={loading}>
<Button
onClick={() => void handleSubmit()}
loading={loading}
data-testid="confirm-create-workspace-button"
>
Create
</Button>
</Group>

View File

@@ -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();
});
});
});

View 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 &quot;{workspaceName}&quot;?
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;

View 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();
});
});
});

View File

@@ -0,0 +1,142 @@
import React, { useState } from 'react';
import {
Avatar,
Popover,
Stack,
UnstyledButton,
Group,
Text,
Divider,
} from '@mantine/core';
import {
IconUser,
IconUsers,
IconLogout,
IconSettings,
} from '@tabler/icons-react';
import { useAuth } from '../../contexts/AuthContext';
import AccountSettings from '../settings/account/AccountSettings';
import AdminDashboard from '../settings/admin/AdminDashboard';
import { UserRole } from '@/types/models';
import { getHoverStyle } from '@/utils/themeStyles';
const UserMenu: React.FC = () => {
const [accountSettingsOpened, setAccountSettingsOpened] =
useState<boolean>(false);
const [adminDashboardOpened, setAdminDashboardOpened] =
useState<boolean>(false);
const [opened, setOpened] = useState<boolean>(false);
const { user, logout } = useAuth();
const handleLogout = async (): Promise<void> => {
await logout();
};
return (
<>
<Popover
width={200}
position="bottom-end"
withArrow
shadow="md"
opened={opened}
onChange={setOpened}
>
<Popover.Target>
<Avatar
radius="xl"
style={{ cursor: 'pointer' }}
onClick={() => setOpened((o) => !o)}
aria-label="User menu"
aria-expanded={opened}
aria-haspopup="menu"
role="button"
>
<IconUser size={24} />
</Avatar>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
{/* User Info Section */}
<Group gap="sm">
<Avatar radius="xl" size="md">
<IconUser size={24} />
</Avatar>
<div>
<Text size="sm" fw={500}>
{user?.displayName || user?.email}
</Text>
</div>
</Group>
<Divider />
{/* Menu Items */}
<UnstyledButton
onClick={() => {
setAccountSettingsOpened(true);
setOpened(false);
}}
px="sm"
py="xs"
style={(theme) => getHoverStyle(theme)}
>
<Group>
<IconSettings size={16} />
<Text size="sm">Account Settings</Text>
</Group>
</UnstyledButton>
{user?.role === UserRole.Admin && (
<UnstyledButton
onClick={() => {
setAdminDashboardOpened(true);
setOpened(false);
}}
px="sm"
py="xs"
style={(theme) => getHoverStyle(theme)}
>
<Group>
<IconUsers size={16} />
<Text size="sm">Admin Dashboard</Text>
</Group>
</UnstyledButton>
)}
<UnstyledButton
onClick={() => {
void handleLogout();
setOpened(false);
}}
px="sm"
py="xs"
color="red"
style={(theme) => getHoverStyle(theme)}
>
<Group>
<IconLogout size={16} color="red" />
<Text size="sm" c="red">
Logout
</Text>
</Group>
</UnstyledButton>
</Stack>
</Popover.Dropdown>
</Popover>
<AccountSettings
opened={accountSettingsOpened}
onClose={() => setAccountSettingsOpened(false)}
/>
<AdminDashboard
opened={adminDashboardOpened}
onClose={() => setAdminDashboardOpened(false)}
/>
</>
);
};
export default UserMenu;

View 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);
});
});

View File

@@ -15,21 +15,26 @@ import {
useMantineTheme,
} from '@mantine/core';
import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useModalContext } from '../contexts/ModalContext';
import { listWorkspaces } from '../services/api';
import CreateWorkspaceModal from './modals/CreateWorkspaceModal';
import { useWorkspace } from '../../hooks/useWorkspace';
import { useModalContext } from '../../contexts/ModalContext';
import { listWorkspaces } from '../../api/workspace';
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 { setSettingsModalVisible, setCreateWorkspaceModalVisible } =
useModalContext();
const [workspaces, setWorkspaces] = useState([]);
const [loading, setLoading] = useState(false);
const [popoverOpened, setPopoverOpened] = useState(false);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [popoverOpened, setPopoverOpened] = useState<boolean>(false);
const theme = useMantineTheme();
const loadWorkspaces = async () => {
const loadWorkspaces = async (): Promise<void> => {
setLoading(true);
try {
const list = await listWorkspaces();
@@ -40,14 +45,16 @@ const WorkspaceSwitcher = () => {
setLoading(false);
};
const handleCreateWorkspace = () => {
const handleCreateWorkspace = (): void => {
setPopoverOpened(false);
setCreateWorkspaceModalVisible(true);
};
const handleWorkspaceCreated = async (newWorkspace) => {
const handleWorkspaceCreated = async (
newWorkspace: Workspace
): Promise<void> => {
await loadWorkspaces();
switchWorkspace(newWorkspace.id);
await switchWorkspace(newWorkspace.name);
};
return (
@@ -64,7 +71,7 @@ const WorkspaceSwitcher = () => {
onClick={() => {
setPopoverOpened((o) => !o);
if (!popoverOpened) {
loadWorkspaces();
void loadWorkspaces();
}
}}
>
@@ -88,6 +95,7 @@ const WorkspaceSwitcher = () => {
<ActionIcon
variant="default"
size="md"
aria-label="Create New Workspace"
onClick={handleCreateWorkspace}
>
<IconFolderPlus size={16} />
@@ -108,24 +116,15 @@ const WorkspaceSwitcher = () => {
key={workspace.id}
p="xs"
withBorder
style={{
backgroundColor: isSelected
? theme.colors.blue[
theme.colorScheme === 'dark' ? 8 : 1
]
: undefined,
borderColor: isSelected
? theme.colors.blue[
theme.colorScheme === 'dark' ? 7 : 5
]
: undefined,
}}
style={(theme) =>
getWorkspacePaperStyle(theme, isSelected)
}
>
<Group justify="space-between" wrap="nowrap">
<UnstyledButton
style={{ flex: 1 }}
onClick={() => {
switchWorkspace(workspace.id);
void switchWorkspace(workspace.name);
setPopoverOpened(false);
}}
>
@@ -134,25 +133,13 @@ const WorkspaceSwitcher = () => {
size="sm"
fw={500}
truncate
c={
isSelected
? theme.colors.blue[
theme.colorScheme === 'dark' ? 0 : 9
]
: undefined
}
c={isSelected ? 'blue' : 'inherit'}
>
{workspace.name}
</Text>
<Text
size="xs"
c={
isSelected
? theme.colorScheme === 'dark'
? theme.colors.blue[2]
: theme.colors.blue[7]
: 'dimmed'
}
c={getConditionalColor(theme, isSelected)}
>
{new Date(
workspace.createdAt
@@ -165,11 +152,8 @@ const WorkspaceSwitcher = () => {
<ActionIcon
variant="subtle"
size="lg"
color={
theme.colorScheme === 'dark'
? 'blue.2'
: 'blue.7'
}
color={getConditionalColor(theme, true)}
aria-label="Workspace Settings"
onClick={(e) => {
e.stopPropagation();
setSettingsModalVisible(true);

View 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);
});
});
});

View File

@@ -0,0 +1,14 @@
import React from 'react';
import { Accordion, Title } from '@mantine/core';
interface AccordionControlProps {
children: React.ReactNode;
}
const AccordionControl: React.FC<AccordionControlProps> = ({ children }) => (
<Accordion.Control>
<Title order={4}>{children}</Title>
</Accordion.Control>
);
export default AccordionControl;

View 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();
});
});

View File

@@ -0,0 +1,266 @@
import React, { useState, useReducer, useRef, useEffect } from 'react';
import {
Modal,
Badge,
Button,
Group,
Title,
Stack,
Accordion,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useAuth } from '../../../contexts/AuthContext';
import { useProfileSettings } from '../../../hooks/useProfileSettings';
import EmailPasswordModal from '../../modals/account/EmailPasswordModal';
import SecuritySettings from './SecuritySettings';
import ProfileSettings from './ProfileSettings';
import DangerZoneSettings from './DangerZoneSettings';
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
const initialState: ProfileSettingsState = {
localSettings: {},
initialSettings: {},
hasUnsavedChanges: false,
};
function settingsReducer(
state: ProfileSettingsState,
action: SettingsAction<UserProfileSettings>
): ProfileSettingsState {
switch (action.type) {
case SettingsActionType.INIT_SETTINGS:
return {
...state,
localSettings: action.payload || {},
initialSettings: action.payload || {},
hasUnsavedChanges: false,
};
case SettingsActionType.UPDATE_LOCAL_SETTINGS: {
const newLocalSettings = { ...state.localSettings, ...action.payload };
const hasChanges =
JSON.stringify(newLocalSettings) !==
JSON.stringify(state.initialSettings);
return {
...state,
localSettings: newLocalSettings,
hasUnsavedChanges: hasChanges,
};
}
case SettingsActionType.MARK_SAVED:
return {
...state,
initialSettings: state.localSettings,
hasUnsavedChanges: false,
};
default:
return state;
}
}
const AccountSettings: React.FC<AccountSettingsProps> = ({
opened,
onClose,
}) => {
const { user, refreshUser } = useAuth();
const { loading, updateProfile } = useProfileSettings();
const [state, dispatch] = useReducer(settingsReducer, initialState);
const isInitialMount = useRef<boolean>(true);
const [emailModalOpened, setEmailModalOpened] = useState<boolean>(false);
// Initialize settings on mount
useEffect(() => {
if (isInitialMount.current && user) {
isInitialMount.current = false;
const settings: UserProfileSettings = {
displayName: user.displayName || '',
email: user.email,
currentPassword: '',
newPassword: '',
};
dispatch({
type: SettingsActionType.INIT_SETTINGS,
payload: settings,
});
}
}, [user]);
const handleInputChange = (
key: keyof UserProfileSettings,
value: string
): void => {
dispatch({
type: SettingsActionType.UPDATE_LOCAL_SETTINGS,
payload: { [key]: value } as UserProfileSettings,
});
};
const handleSubmit = async (): Promise<void> => {
const updates: UserProfileSettings = {};
const needsPasswordConfirmation =
state.localSettings.email !== state.initialSettings.email;
// Add display name if changed
if (state.localSettings.displayName !== state.initialSettings.displayName) {
updates.displayName = state.localSettings.displayName || '';
}
// Handle password change
if (state.localSettings.newPassword) {
if (!state.localSettings.currentPassword) {
notifications.show({
title: 'Error',
message: 'Current password is required to change password',
color: 'red',
});
return;
}
updates.newPassword = state.localSettings.newPassword;
updates.currentPassword = state.localSettings.currentPassword;
}
// If we're only changing display name or have password already provided, proceed directly
if (!needsPasswordConfirmation || state.localSettings.currentPassword) {
if (needsPasswordConfirmation) {
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 (!updates.currentPassword) {
updates.currentPassword = state.localSettings.currentPassword || '';
}
}
const updatedUser = await updateProfile(updates);
if (updatedUser) {
await refreshUser();
dispatch({ type: SettingsActionType.MARK_SAVED });
onClose();
}
} else {
// Only show the email confirmation modal if we don't already have the password
setEmailModalOpened(true);
}
};
const handleEmailConfirm = async (password: string): Promise<boolean> => {
const updates: UserProfileSettings = {
...state.localSettings,
currentPassword: password,
};
// Remove any undefined/empty values
Object.keys(updates).forEach((key) => {
const typedKey = key as keyof UserProfileSettings;
if (updates[typedKey] === undefined || updates[typedKey] === '') {
delete updates[typedKey];
}
});
// Remove keys that haven't changed
if (updates.displayName === state.initialSettings.displayName) {
delete updates.displayName;
}
if (updates.email === state.initialSettings.email) {
delete updates.email;
}
const updatedUser = await updateProfile(updates);
if (updatedUser) {
await refreshUser();
dispatch({ type: SettingsActionType.MARK_SAVED });
setEmailModalOpened(false);
onClose();
return true;
} else {
// TODO: Handle errors appropriately
// notifications.show({...
return false;
}
};
return (
<>
<Modal
opened={opened}
onClose={onClose}
title={<Title order={2}>Account Settings</Title>}
centered
size="lg"
>
<Stack gap="xl">
{state.hasUnsavedChanges && (
<Badge color="yellow" variant="light">
Unsaved Changes
</Badge>
)}
<Accordion
defaultValue={['profile', 'security', 'danger']}
multiple
styles={(theme) => getAccordionStyles(theme)}
>
<Accordion.Item value="profile">
<AccordionControl>Profile</AccordionControl>
<Accordion.Panel>
<ProfileSettings
settings={state.localSettings}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="security">
<AccordionControl>Security</AccordionControl>
<Accordion.Panel>
<SecuritySettings
settings={state.localSettings}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="danger">
<AccordionControl>Danger Zone</AccordionControl>
<Accordion.Panel>
<DangerZoneSettings />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => void handleSubmit()}
loading={loading}
disabled={!state.hasUnsavedChanges}
>
Save Changes
</Button>
</Group>
</Stack>
</Modal>
<EmailPasswordModal
opened={emailModalOpened}
onClose={() => setEmailModalOpened(false)}
onConfirm={handleEmailConfirm}
email={state.localSettings.email || ''}
/>
</>
);
};
export default AccountSettings;

View 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();
});
});

View File

@@ -0,0 +1,43 @@
import React, { useState } from 'react';
import { Box, Button, Text } from '@mantine/core';
import DeleteAccountModal from '../../modals/account/DeleteAccountModal';
import { useAuth } from '../../../contexts/AuthContext';
import { useProfileSettings } from '../../../hooks/useProfileSettings';
const DangerZoneSettings: React.FC = () => {
const { logout } = useAuth();
const { deleteAccount } = useProfileSettings();
const [deleteModalOpened, setDeleteModalOpened] = useState<boolean>(false);
const handleDelete = async (password: string): Promise<void> => {
const success = await deleteAccount(password);
if (success) {
setDeleteModalOpened(false);
await logout();
}
};
return (
<Box mb="md">
<Text size="sm" mb="sm" c="dimmed">
Once you delete your account, there is no going back. Please be certain.
</Text>
<Button
color="red"
variant="light"
onClick={() => setDeleteModalOpened(true)}
fullWidth
>
Delete Account
</Button>
<DeleteAccountModal
opened={deleteModalOpened}
onClose={() => setDeleteModalOpened(false)}
onConfirm={handleDelete}
/>
</Box>
);
};
export default DangerZoneSettings;

View 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();
});
});

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { Box, Stack, TextInput } from '@mantine/core';
import type { UserProfileSettings } from '@/types/models';
interface ProfileSettingsProps {
settings: UserProfileSettings;
onInputChange: (key: keyof UserProfileSettings, value: string) => void;
}
const ProfileSettings: React.FC<ProfileSettingsProps> = ({
settings,
onInputChange,
}) => (
<Box>
<Stack gap="md">
<TextInput
label="Display Name"
type="text"
value={settings.displayName || ''}
onChange={(e) => onInputChange('displayName', e.currentTarget.value)}
placeholder="Enter display name"
data-testid="display-name-input"
/>
<TextInput
label="Email"
type="email"
value={settings.email || ''}
onChange={(e) => onInputChange('email', e.currentTarget.value)}
placeholder="Enter email"
data-testid="email-input"
/>
</Stack>
</Box>
);
export default ProfileSettings;

View 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();
});
});

View File

@@ -0,0 +1,79 @@
import React, { useState } from 'react';
import { Box, PasswordInput, Stack, Text } from '@mantine/core';
import type { UserProfileSettings } from '@/types/models';
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 [error, setError] = useState('');
const handlePasswordChange = (field: PasswordField, value: string) => {
if (field === 'confirmNewPassword') {
setConfirmPassword(value);
// Check if passwords match when either password field changes
if (value !== settings.newPassword) {
setError('Passwords do not match');
} else {
setError('');
}
} else {
onInputChange(field, value);
// Check if passwords match when either password field changes
if (field === 'newPassword' && value !== confirmPassword) {
setError('Passwords do not match');
} else if (value === confirmPassword) {
setError('');
}
}
};
return (
<Box>
<Stack gap="md">
<PasswordInput
label="Current Password"
type="password"
value={settings.currentPassword || ''}
onChange={(e) =>
handlePasswordChange('currentPassword', e.currentTarget.value)
}
placeholder="Enter current password"
/>
<PasswordInput
label="New Password"
type="password"
value={settings.newPassword || ''}
onChange={(e) =>
handlePasswordChange('newPassword', e.currentTarget.value)
}
placeholder="Enter new password"
/>
<PasswordInput
label="Confirm New Password"
type="password"
value={confirmPassword}
onChange={(e) =>
handlePasswordChange('confirmNewPassword', e.currentTarget.value)
}
placeholder="Confirm new password"
error={error}
/>
<Text size="xs" c="dimmed">
Password must be at least 8 characters long. Leave password fields
empty if you don&apos;t want to change it.
</Text>
</Stack>
</Box>
);
};
export default SecuritySettings;

View 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();
});
});

View File

@@ -0,0 +1,54 @@
import React, { useState } from 'react';
import { Modal, Tabs } from '@mantine/core';
import { IconUsers, IconFolders, IconChartBar } from '@tabler/icons-react';
import { useAuth } from '../../../contexts/AuthContext';
import AdminUsersTab from './AdminUsersTab';
import AdminWorkspacesTab from './AdminWorkspacesTab';
import AdminStatsTab from './AdminStatsTab';
interface AdminDashboardProps {
opened: boolean;
onClose: () => void;
}
type AdminTabValue = 'users' | 'workspaces' | 'stats';
const AdminDashboard: React.FC<AdminDashboardProps> = ({ opened, onClose }) => {
const { user: currentUser } = useAuth();
const [activeTab, setActiveTab] = useState<AdminTabValue>('users');
return (
<Modal opened={opened} onClose={onClose} size="xl" title="Admin Dashboard">
<Tabs
value={activeTab}
onChange={(value) => setActiveTab(value as AdminTabValue)}
>
<Tabs.List>
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
Users
</Tabs.Tab>
<Tabs.Tab value="workspaces" leftSection={<IconFolders size={16} />}>
Workspaces
</Tabs.Tab>
<Tabs.Tab value="stats" leftSection={<IconChartBar size={16} />}>
Statistics
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="users" pt="md">
{currentUser && <AdminUsersTab currentUser={currentUser} />}
</Tabs.Panel>
<Tabs.Panel value="workspaces" pt="md">
<AdminWorkspacesTab />
</Tabs.Panel>
<Tabs.Panel value="stats" pt="md">
<AdminStatsTab />
</Tabs.Panel>
</Tabs>
</Modal>
);
};
export default AdminDashboard;

View 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();
});
});

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { Table, Text, Box, LoadingOverlay, Alert } from '@mantine/core';
import { IconAlertCircle } from '@tabler/icons-react';
import { useAdminData } from '../../../hooks/useAdminData';
import { formatBytes } from '../../../utils/formatBytes';
interface StatsRow {
label: string;
value: string | number;
}
const AdminStatsTab: React.FC = () => {
const { data: stats, loading, error } = useAdminData<'stats'>('stats');
if (loading) {
return <LoadingOverlay visible={true} />;
}
if (error) {
return (
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
{error}
</Alert>
);
}
const statsRows: StatsRow[] = [
{ label: 'Total Users', value: stats.totalUsers },
{ label: 'Active Users', value: stats.activeUsers },
{ label: 'Total Workspaces', value: stats.totalWorkspaces },
{ label: 'Total Files', value: stats.totalFiles },
{ label: 'Total Storage Size', value: formatBytes(stats.totalSize) },
];
return (
<Box pos="relative">
<Text size="xl" fw={700} mb="md">
System Statistics
</Text>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Metric</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{statsRows.map((row) => (
<Table.Tr key={row.label}>
<Table.Td>{row.label}</Table.Td>
<Table.Td>{row.value}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
);
};
export default AdminStatsTab;

View 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();
});
});

View File

@@ -0,0 +1,176 @@
import React, { useState } from 'react';
import {
Table,
Button,
Group,
Text,
ActionIcon,
Box,
LoadingOverlay,
Alert,
} from '@mantine/core';
import {
IconTrash,
IconEdit,
IconPlus,
IconAlertCircle,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useUserAdmin } from '../../../hooks/useUserAdmin';
import CreateUserModal from '../../modals/user/CreateUserModal';
import EditUserModal from '../../modals/user/EditUserModal';
import DeleteUserModal from '../../modals/user/DeleteUserModal';
import type { User } from '@/types/models';
import type { CreateUserRequest, UpdateUserRequest } from '@/types/api';
interface AdminUsersTabProps {
currentUser: User;
}
const AdminUsersTab: React.FC<AdminUsersTabProps> = ({ currentUser }) => {
const {
users,
loading,
error,
create,
update,
delete: deleteUser,
} = useUserAdmin();
const [createModalOpened, setCreateModalOpened] = useState<boolean>(false);
const [editModalData, setEditModalData] = useState<User | null>(null);
const [deleteModalData, setDeleteModalData] = useState<User | null>(null);
const handleCreateUser = async (
userData: CreateUserRequest
): Promise<boolean> => {
return await create(userData);
};
const handleEditUser = async (
id: number,
userData: UpdateUserRequest
): Promise<boolean> => {
return await update(id, userData);
};
const handleDeleteClick = (user: User): void => {
if (user.id === currentUser.id) {
notifications.show({
title: 'Error',
message: 'You cannot delete your own account',
color: 'red',
});
return;
}
setDeleteModalData(user);
};
const handleDeleteConfirm = async (): Promise<void> => {
if (!deleteModalData) return;
const success = await deleteUser(deleteModalData.id);
if (success) {
setDeleteModalData(null);
}
};
const renderUserRow = (user: User) => (
<Table.Tr key={user.id}>
<Table.Td>{user.email}</Table.Td>
<Table.Td>{user.displayName}</Table.Td>
<Table.Td>
<Text style={{ textTransform: 'capitalize' }}>{user.role}</Text>
</Table.Td>
<Table.Td>{new Date(user.createdAt).toLocaleDateString()}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
<ActionIcon
variant="subtle"
aria-label="Edit user"
color="blue"
onClick={() => setEditModalData(user)}
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
aria-label="Delete user"
color="red"
onClick={() => handleDeleteClick(user)}
disabled={user.id === currentUser.id}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</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}>
User Management
</Text>
<Button
leftSection={<IconPlus size={16} />}
aria-label="Create user"
onClick={() => setCreateModalOpened(true)}
>
Create User
</Button>
</Group>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Email</Table.Th>
<Table.Th>Display Name</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th style={{ width: 100 }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{users.map(renderUserRow)}</Table.Tbody>
</Table>
<CreateUserModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onCreateUser={handleCreateUser}
loading={loading}
/>
<EditUserModal
opened={!!editModalData}
onClose={() => setEditModalData(null)}
onEditUser={handleEditUser}
user={editModalData}
loading={loading}
/>
<DeleteUserModal
opened={!!deleteModalData}
onClose={() => setDeleteModalData(null)}
onConfirm={handleDeleteConfirm}
user={deleteModalData}
loading={loading}
/>
</Box>
);
};
export default AdminUsersTab;

View 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();
});
});

View 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;

View File

@@ -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);
});
});

View 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;

View File

@@ -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();
});
});

View File

@@ -1,16 +1,16 @@
import React, { useState } from 'react';
import { Box, Button, Title } from '@mantine/core';
import DeleteWorkspaceModal from '../modals/DeleteWorkspaceModal';
import { useWorkspace } from '../../contexts/WorkspaceContext';
import { useModalContext } from '../../contexts/ModalContext';
import { Box, Button } from '@mantine/core';
import DeleteWorkspaceModal from '../../modals/workspace/DeleteWorkspaceModal';
import { useWorkspace } from '../../../hooks/useWorkspace';
import { useModalContext } from '../../../contexts/ModalContext';
const DangerZoneSettings = () => {
const DangerZoneSettings: React.FC = () => {
const { currentWorkspace, workspaces, deleteCurrentWorkspace } =
useWorkspace();
const { setSettingsModalVisible } = useModalContext();
const [deleteModalOpened, setDeleteModalOpened] = useState(false);
const [deleteModalOpened, setDeleteModalOpened] = useState<boolean>(false);
const handleDelete = async () => {
const handleDelete = async (): Promise<void> => {
await deleteCurrentWorkspace();
setDeleteModalOpened(false);
setSettingsModalVisible(false);

View File

@@ -0,0 +1,78 @@
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 EditorSettings from './EditorSettings';
// 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('EditorSettings', () => {
const mockOnAutoSaveChange = vi.fn();
const mockOnShowHiddenFilesChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('renders both toggle switches with labels', () => {
render(
<EditorSettings
autoSave={false}
showHiddenFiles={false}
onAutoSaveChange={mockOnAutoSaveChange}
onShowHiddenFilesChange={mockOnShowHiddenFilesChange}
/>
);
expect(screen.getByText('Auto Save')).toBeInTheDocument();
expect(screen.getByText('Show Hidden Files')).toBeInTheDocument();
});
it('shows correct toggle states', () => {
render(
<EditorSettings
autoSave={true}
showHiddenFiles={false}
onAutoSaveChange={mockOnAutoSaveChange}
onShowHiddenFilesChange={mockOnShowHiddenFilesChange}
/>
);
const toggles = screen.getAllByRole('switch');
const autoSaveToggle = toggles[0];
const hiddenFilesToggle = toggles[1];
expect(autoSaveToggle).toBeChecked();
expect(hiddenFilesToggle).not.toBeChecked();
});
it('calls onShowHiddenFilesChange when toggle is clicked', () => {
render(
<EditorSettings
autoSave={false}
showHiddenFiles={false}
onAutoSaveChange={mockOnAutoSaveChange}
onShowHiddenFilesChange={mockOnShowHiddenFilesChange}
/>
);
// Get the show hidden files toggle by finding the one that's not disabled
const toggles = screen.getAllByRole('switch');
const hiddenFilesToggle = toggles.find(
(toggle) => !toggle.hasAttribute('disabled')
);
expect(hiddenFilesToggle).toBeDefined();
fireEvent.click(hiddenFilesToggle!);
expect(mockOnShowHiddenFilesChange).toHaveBeenCalledWith(true);
});
});

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { Text, Switch, Tooltip, Group, Box } from '@mantine/core';
interface EditorSettingsProps {
autoSave: boolean;
showHiddenFiles: boolean;
onAutoSaveChange: (value: boolean) => void;
onShowHiddenFilesChange: (value: boolean) => void;
}
const EditorSettings: React.FC<EditorSettingsProps> = ({
autoSave,
showHiddenFiles,
onAutoSaveChange,
onShowHiddenFilesChange,
}) => {
return (
<Box mb="md">
<Tooltip label="Auto Save feature is coming soon!" position="left">
<Group justify="space-between" align="center" mb="sm">
<Text size="sm">Auto Save</Text>
<Switch
checked={autoSave}
onChange={(event) => onAutoSaveChange(event.currentTarget.checked)}
disabled
/>
</Group>
</Tooltip>
<Group justify="space-between" align="center">
<Text size="sm">Show Hidden Files</Text>
<Switch
checked={showHiddenFiles}
onChange={(event) =>
onShowHiddenFilesChange(event.currentTarget.checked)
}
/>
</Group>
</Box>
);
};
export default EditorSettings;

View File

@@ -0,0 +1,61 @@
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 GeneralSettings from './GeneralSettings';
// 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('GeneralSettings', () => {
const mockOnInputChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('renders workspace name input with current value', () => {
render(
<GeneralSettings name="My Workspace" onInputChange={mockOnInputChange} />
);
const nameInput = screen.getByDisplayValue('My Workspace');
expect(nameInput).toBeInTheDocument();
expect(screen.getByText('Workspace Name')).toBeInTheDocument();
});
it('renders with empty name', () => {
render(<GeneralSettings name="" onInputChange={mockOnInputChange} />);
const nameInput = screen.getByPlaceholderText('Enter workspace name');
expect(nameInput).toHaveValue('');
});
it('calls onInputChange when name is modified', () => {
render(
<GeneralSettings name="Old Name" onInputChange={mockOnInputChange} />
);
const nameInput = screen.getByDisplayValue('Old Name');
fireEvent.change(nameInput, { target: { value: 'New Workspace Name' } });
expect(mockOnInputChange).toHaveBeenCalledWith(
'name',
'New Workspace Name'
);
});
it('has required attribute on input', () => {
render(<GeneralSettings name="Test" onInputChange={mockOnInputChange} />);
const nameInput = screen.getByDisplayValue('Test');
expect(nameInput).toHaveAttribute('required');
});
});

View File

@@ -1,7 +1,16 @@
import React from 'react';
import { Title, Box, TextInput, Text, Grid } from '@mantine/core';
import { Box, TextInput, Text, Grid } from '@mantine/core';
import type { Workspace } from '@/types/models';
const GeneralSettings = ({ name, onInputChange }) => {
interface GeneralSettingsProps {
name: string;
onInputChange: (key: keyof Workspace, value: string) => void;
}
const GeneralSettings: React.FC<GeneralSettingsProps> = ({
name,
onInputChange,
}) => {
return (
<Box mb="md">
<Grid gutter="md" align="center">
@@ -10,7 +19,7 @@ const GeneralSettings = ({ name, onInputChange }) => {
</Grid.Col>
<Grid.Col span={6}>
<TextInput
value={name || ''}
value={name}
onChange={(event) =>
onInputChange('name', event.currentTarget.value)
}

View File

@@ -0,0 +1,134 @@
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 GitSettings from './GitSettings';
// 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('GitSettings', () => {
const mockOnInputChange = vi.fn();
const defaultProps = {
gitEnabled: false,
gitUrl: '',
gitUser: '',
gitToken: '',
gitAutoCommit: false,
gitCommitMsgTemplate: '',
gitCommitName: '',
gitCommitEmail: '',
onInputChange: mockOnInputChange,
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders all git settings fields', () => {
render(<GitSettings {...defaultProps} />);
expect(screen.getByText('Enable Git Repository')).toBeInTheDocument();
expect(screen.getByText('Git URL')).toBeInTheDocument();
expect(screen.getByText('Username')).toBeInTheDocument();
expect(screen.getByText('Access Token')).toBeInTheDocument();
expect(screen.getByText('Commit on Save')).toBeInTheDocument();
expect(screen.getByText('Commit Message Template')).toBeInTheDocument();
expect(screen.getByText('Commit Author')).toBeInTheDocument();
expect(screen.getByText('Commit Author Email')).toBeInTheDocument();
});
it('disables all inputs when git is not enabled', () => {
render(<GitSettings {...defaultProps} gitEnabled={false} />);
expect(screen.getByPlaceholderText('Enter Git URL')).toBeDisabled();
expect(screen.getByPlaceholderText('Enter Git username')).toBeDisabled();
expect(screen.getByPlaceholderText('Enter Git token')).toBeDisabled();
const switches = screen.getAllByRole('switch');
const commitOnSaveSwitch = switches[1]; // Second switch is commit on save
expect(commitOnSaveSwitch).toBeDisabled();
});
it('enables all inputs when git is enabled', () => {
render(<GitSettings {...defaultProps} gitEnabled={true} />);
expect(screen.getByPlaceholderText('Enter Git URL')).not.toBeDisabled();
expect(
screen.getByPlaceholderText('Enter Git username')
).not.toBeDisabled();
expect(screen.getByPlaceholderText('Enter Git token')).not.toBeDisabled();
const switches = screen.getAllByRole('switch');
const commitOnSaveSwitch = switches[1];
expect(commitOnSaveSwitch).not.toBeDisabled();
});
it('calls onInputChange when git enabled toggle is changed', () => {
render(<GitSettings {...defaultProps} />);
const switches = screen.getAllByRole('switch');
const gitEnabledSwitch = switches[0];
expect(gitEnabledSwitch).toBeDefined();
fireEvent.click(gitEnabledSwitch!);
expect(mockOnInputChange).toHaveBeenCalledWith('gitEnabled', true);
});
it('calls onInputChange when git URL is changed', () => {
render(<GitSettings {...defaultProps} gitEnabled={true} />);
const urlInput = screen.getByPlaceholderText('Enter Git URL');
fireEvent.change(urlInput, {
target: { value: 'https://github.com/user/repo.git' },
});
expect(mockOnInputChange).toHaveBeenCalledWith(
'gitUrl',
'https://github.com/user/repo.git'
);
});
it('calls onInputChange when commit template is changed', () => {
render(<GitSettings {...defaultProps} gitEnabled={true} />);
const templateInput = screen.getByPlaceholderText(
'Enter commit message template'
);
fireEvent.change(templateInput, {
target: { value: '${action}: ${filename}' },
});
expect(mockOnInputChange).toHaveBeenCalledWith(
'gitCommitMsgTemplate',
'${action}: ${filename}'
);
});
it('shows current values in form fields', () => {
const propsWithValues = {
...defaultProps,
gitEnabled: true,
gitUrl: 'https://github.com/test/repo.git',
gitUser: 'testuser',
gitCommitMsgTemplate: 'Update ${filename}',
};
render(<GitSettings {...propsWithValues} />);
expect(
screen.getByDisplayValue('https://github.com/test/repo.git')
).toBeInTheDocument();
expect(screen.getByDisplayValue('testuser')).toBeInTheDocument();
expect(screen.getByDisplayValue('Update ${filename}')).toBeInTheDocument();
});
});

View File

@@ -6,24 +6,38 @@ import {
Stack,
PasswordInput,
Group,
Title,
Grid,
} from '@mantine/core';
import type { Workspace } from '@/types/models';
const GitSettings = ({
interface GitSettingsProps {
gitEnabled: boolean;
gitUrl: string;
gitUser: string;
gitToken: string;
gitAutoCommit: boolean;
gitCommitMsgTemplate: string;
gitCommitName: string;
gitCommitEmail: string;
onInputChange: (key: keyof Workspace, value: string | boolean) => void;
}
const GitSettings: React.FC<GitSettingsProps> = ({
gitEnabled,
gitUrl,
gitUser,
gitToken,
gitAutoCommit,
gitCommitMsgTemplate,
gitCommitName,
gitCommitEmail,
onInputChange,
}) => {
return (
<Stack spacing="md">
<Stack gap="md">
<Grid gutter="md" align="center">
<Grid.Col span={6}>
<Text size="sm">Enable Git</Text>
<Text size="sm">Enable Git Repository</Text>
</Grid.Col>
<Grid.Col span={6}>
<Group justify="flex-end">
@@ -42,6 +56,7 @@ const GitSettings = ({
<Grid.Col span={6}>
<TextInput
value={gitUrl}
description="The URL of your Git repository"
onChange={(event) =>
onInputChange('gitUrl', event.currentTarget.value)
}
@@ -51,11 +66,12 @@ const GitSettings = ({
</Grid.Col>
<Grid.Col span={6}>
<Text size="sm">Git Username</Text>
<Text size="sm">Username</Text>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
value={gitUser}
description="The username used to authenticate with the repository"
onChange={(event) =>
onInputChange('gitUser', event.currentTarget.value)
}
@@ -65,11 +81,12 @@ const GitSettings = ({
</Grid.Col>
<Grid.Col span={6}>
<Text size="sm">Git Token</Text>
<Text size="sm">Access Token</Text>
</Grid.Col>
<Grid.Col span={6}>
<PasswordInput
value={gitToken}
description="Personal access token with repository read/write permissions"
onChange={(event) =>
onInputChange('gitToken', event.currentTarget.value)
}
@@ -79,7 +96,7 @@ const GitSettings = ({
</Grid.Col>
<Grid.Col span={6}>
<Text size="sm">Auto Commit</Text>
<Text size="sm">Commit on Save</Text>
</Grid.Col>
<Grid.Col span={6}>
<Group justify="flex-end">
@@ -99,6 +116,7 @@ const GitSettings = ({
<Grid.Col span={6}>
<TextInput
value={gitCommitMsgTemplate}
description="Template for automated commit messages. Use ${filename} and ${action} as a placeholder."
onChange={(event) =>
onInputChange('gitCommitMsgTemplate', event.currentTarget.value)
}
@@ -106,6 +124,36 @@ const GitSettings = ({
placeholder="Enter commit message template"
/>
</Grid.Col>
<Grid.Col span={6}>
<Text size="sm">Commit Author</Text>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
value={gitCommitName}
description="Name to appear in commit history. Leave empty to use Git username."
onChange={(event) =>
onInputChange('gitCommitName', event.currentTarget.value)
}
disabled={!gitEnabled}
placeholder="Enter commit author name."
/>
</Grid.Col>
<Grid.Col span={6}>
<Text size="sm">Commit Author Email</Text>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
value={gitCommitEmail}
description="Email address to associate with your commits"
onChange={(event) =>
onInputChange('gitCommitEmail', event.currentTarget.value)
}
disabled={!gitEnabled}
placeholder="Enter commit author email."
/>
</Grid.Col>
</Grid>
</Stack>
);

View File

@@ -0,0 +1,203 @@
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 WorkspaceSettings from './WorkspaceSettings';
import { Theme } from '@/types/models';
const mockUpdateSettings = vi.fn();
const mockUpdateColorScheme = vi.fn();
vi.mock('../../../hooks/useWorkspace', () => ({
useWorkspace: vi.fn(),
}));
const mockSetSettingsModalVisible = vi.fn();
vi.mock('../../../contexts/ModalContext', () => ({
useModalContext: () => ({
settingsModalVisible: true,
setSettingsModalVisible: mockSetSettingsModalVisible,
}),
}));
vi.mock('@mantine/notifications', () => ({
notifications: {
show: vi.fn(),
},
}));
vi.mock('./GeneralSettings', () => ({
default: ({
name,
onInputChange,
}: {
name: string;
onInputChange: (key: string, value: string) => void;
}) => (
<div data-testid="general-settings">
<input
data-testid="workspace-name-input"
value={name}
onChange={(e) => onInputChange('name', e.target.value)}
/>
</div>
),
}));
vi.mock('./AppearanceSettings', () => ({
default: () => (
<div data-testid="appearance-settings">
Appearance Settings
</div>
),
}));
vi.mock('./EditorSettings', () => ({
default: () => <div data-testid="editor-settings">Editor Settings</div>,
}));
vi.mock('./GitSettings', () => ({
default: () => <div data-testid="git-settings">Git Settings</div>,
}));
vi.mock('./DangerZoneSettings', () => ({
default: () => <div data-testid="danger-zone-settings">Danger Zone</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('WorkspaceSettings', () => {
beforeEach(async () => {
vi.clearAllMocks();
mockUpdateSettings.mockResolvedValue(undefined);
const { useWorkspace } = await import('../../../hooks/useWorkspace');
vi.mocked(useWorkspace).mockReturnValue({
currentWorkspace: {
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: [],
updateSettings: mockUpdateSettings,
loading: false,
colorScheme: 'light',
updateColorScheme: mockUpdateColorScheme,
switchWorkspace: vi.fn(),
deleteCurrentWorkspace: vi.fn(),
});
});
it('renders modal with all setting sections', () => {
render(<WorkspaceSettings />);
expect(screen.getByText('Workspace Settings')).toBeInTheDocument();
expect(screen.getByTestId('general-settings')).toBeInTheDocument();
expect(screen.getByTestId('appearance-settings')).toBeInTheDocument();
expect(screen.getByTestId('editor-settings')).toBeInTheDocument();
expect(screen.getByTestId('git-settings')).toBeInTheDocument();
expect(screen.getByTestId('danger-zone-settings')).toBeInTheDocument();
});
it('shows unsaved changes badge when settings are modified', () => {
render(<WorkspaceSettings />);
const nameInput = screen.getByTestId('workspace-name-input');
fireEvent.change(nameInput, { target: { value: 'Updated Workspace' } });
expect(screen.getByText('Unsaved Changes')).toBeInTheDocument();
});
it('saves settings successfully', async () => {
render(<WorkspaceSettings />);
const nameInput = screen.getByTestId('workspace-name-input');
fireEvent.change(nameInput, { target: { value: 'Updated Workspace' } });
const saveButton = screen.getByRole('button', { name: 'Save Changes' });
expect(saveButton).toBeDefined();
fireEvent.click(saveButton);
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Updated Workspace' })
);
});
await waitFor(() => {
expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(false);
});
});
it('renders appearance settings', () => {
render(<WorkspaceSettings />);
expect(screen.getByTestId('appearance-settings')).toBeInTheDocument();
});
it('closes modal when cancel is clicked', () => {
render(<WorkspaceSettings />);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
fireEvent.click(cancelButton);
expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(false);
});
it('prevents saving with empty workspace name', async () => {
const { notifications } = await import('@mantine/notifications');
render(<WorkspaceSettings />);
const nameInput = screen.getByTestId('workspace-name-input');
fireEvent.change(nameInput, { target: { value: ' ' } }); // Empty/whitespace
const saveButton = screen.getByRole('button', { name: 'Save Changes' });
fireEvent.click(saveButton);
await waitFor(() => {
expect(notifications.show).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Workspace name cannot be empty',
color: 'red',
})
);
});
expect(mockUpdateSettings).not.toHaveBeenCalled();
});
it('reverts theme when canceling', () => {
render(<WorkspaceSettings />);
// Click cancel
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
fireEvent.click(cancelButton);
// Theme should be reverted to saved state (Light)
expect(mockUpdateColorScheme).toHaveBeenCalledWith(Theme.Light);
expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(false);
});
});

View File

@@ -1,4 +1,4 @@
import React, { useReducer, useEffect, useCallback, useRef } from 'react';
import React, { useReducer, useEffect, useCallback } from 'react';
import {
Modal,
Badge,
@@ -9,30 +9,47 @@ import {
Accordion,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useWorkspace } from '../contexts/WorkspaceContext';
import AppearanceSettings from './settings/AppearanceSettings';
import EditorSettings from './settings/EditorSettings';
import GitSettings from './settings/GitSettings';
import GeneralSettings from './settings/GeneralSettings';
import { useModalContext } from '../contexts/ModalContext';
import DangerZoneSettings from './settings/DangerZoneSettings';
import { useWorkspace } from '../../../hooks/useWorkspace';
import AppearanceSettings from './AppearanceSettings';
import EditorSettings from './EditorSettings';
import GitSettings from './GitSettings';
import GeneralSettings from './GeneralSettings';
import { useModalContext } from '../../../contexts/ModalContext';
import DangerZoneSettings from './DangerZoneSettings';
import AccordionControl from '../AccordionControl';
import {
type Theme,
type Workspace,
type SettingsAction,
SettingsActionType,
} from '@/types/models';
import { getAccordionStyles } from '@/utils/themeStyles';
// State and reducer for workspace settings
interface WorkspaceSettingsState {
localSettings: Partial<Workspace>;
initialSettings: Partial<Workspace>;
hasUnsavedChanges: boolean;
}
const initialState = {
const initialState: WorkspaceSettingsState = {
localSettings: {},
initialSettings: {},
hasUnsavedChanges: false,
};
function settingsReducer(state, action) {
function settingsReducer(
state: WorkspaceSettingsState,
action: SettingsAction<Partial<Workspace>>
): WorkspaceSettingsState {
switch (action.type) {
case 'INIT_SETTINGS':
case SettingsActionType.INIT_SETTINGS:
return {
...state,
localSettings: action.payload,
initialSettings: action.payload,
localSettings: action.payload || {},
initialSettings: action.payload || {},
hasUnsavedChanges: false,
};
case 'UPDATE_LOCAL_SETTINGS':
case SettingsActionType.UPDATE_LOCAL_SETTINGS: {
const newLocalSettings = { ...state.localSettings, ...action.payload };
const hasChanges =
JSON.stringify(newLocalSettings) !==
@@ -42,7 +59,8 @@ function settingsReducer(state, action) {
localSettings: newLocalSettings,
hasUnsavedChanges: hasChanges,
};
case 'MARK_SAVED':
}
case SettingsActionType.MARK_SAVED:
return {
...state,
initialSettings: state.localSettings,
@@ -53,41 +71,43 @@ function settingsReducer(state, action) {
}
}
const AccordionControl = ({ children }) => (
<Accordion.Control>
<Title order={4}>{children}</Title>
</Accordion.Control>
);
const Settings = () => {
const { currentWorkspace, updateSettings } = useWorkspace();
const WorkspaceSettings: React.FC = () => {
const { currentWorkspace, updateSettings, updateColorScheme, colorScheme } =
useWorkspace();
const { settingsModalVisible, setSettingsModalVisible } = useModalContext();
const [state, dispatch] = useReducer(settingsReducer, initialState);
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
const settings = {
if (currentWorkspace && settingsModalVisible) {
const settings: Partial<Workspace> = {
name: currentWorkspace.name,
theme: currentWorkspace.theme,
autoSave: currentWorkspace.autoSave,
showHiddenFiles: currentWorkspace.showHiddenFiles,
gitEnabled: currentWorkspace.gitEnabled,
gitUrl: currentWorkspace.gitUrl,
gitUser: currentWorkspace.gitUser,
gitToken: currentWorkspace.gitToken,
gitAutoCommit: currentWorkspace.gitAutoCommit,
gitCommitMsgTemplate: currentWorkspace.gitCommitMsgTemplate,
gitCommitName: currentWorkspace.gitCommitName,
gitCommitEmail: currentWorkspace.gitCommitEmail,
};
dispatch({ type: 'INIT_SETTINGS', payload: settings });
dispatch({ type: SettingsActionType.INIT_SETTINGS, payload: settings });
}
}, [currentWorkspace]);
}, [currentWorkspace, settingsModalVisible]);
const handleInputChange = useCallback((key, value) => {
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
}, []);
const handleInputChange = useCallback(
<K extends keyof Workspace>(key: K, value: Workspace[K]): void => {
dispatch({
type: SettingsActionType.UPDATE_LOCAL_SETTINGS,
payload: { [key]: value } as Partial<Workspace>,
});
},
[]
);
const handleSubmit = async () => {
const handleSubmit = async (): Promise<void> => {
try {
if (!state.localSettings.name?.trim()) {
notifications.show({
@@ -97,8 +117,14 @@ const Settings = () => {
return;
}
await updateSettings(state.localSettings);
dispatch({ type: 'MARK_SAVED' });
// Save with current Mantine theme
const settingsToSave = {
...state.localSettings,
theme: colorScheme as Theme,
};
await updateSettings(settingsToSave);
dispatch({ type: SettingsActionType.MARK_SAVED });
notifications.show({
message: 'Settings saved successfully',
color: 'green',
@@ -107,25 +133,31 @@ const Settings = () => {
} catch (error) {
console.error('Failed to save settings:', error);
notifications.show({
message: 'Failed to save settings: ' + error.message,
message:
'Failed to save settings: ' +
(error instanceof Error ? error.message : String(error)),
color: 'red',
});
}
};
const handleClose = useCallback(() => {
// Revert theme to saved state
if (state.initialSettings.theme) {
updateColorScheme(state.initialSettings.theme);
}
setSettingsModalVisible(false);
}, [setSettingsModalVisible]);
}, [setSettingsModalVisible, state.initialSettings.theme, updateColorScheme]);
return (
<Modal
opened={settingsModalVisible}
onClose={handleClose}
title={<Title order={2}>Settings</Title>}
title={<Title order={2}>Workspace Settings</Title>}
centered
size="lg"
>
<Stack spacing="xl">
<Stack gap="xl">
{state.hasUnsavedChanges && (
<Badge color="yellow" variant="light">
Unsaved Changes
@@ -136,23 +168,7 @@ const Settings = () => {
defaultValue={['general', 'appearance', 'editor', 'git', 'danger']}
multiple
styles={(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],
},
},
...getAccordionStyles(theme),
chevron: {
'&[data-rotate]': {
transform: 'rotate(180deg)',
@@ -164,7 +180,7 @@ const Settings = () => {
<AccordionControl>General</AccordionControl>
<Accordion.Panel>
<GeneralSettings
name={state.localSettings.name}
name={state.localSettings.name || ''}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
@@ -173,12 +189,7 @@ const Settings = () => {
<Accordion.Item value="appearance">
<AccordionControl>Appearance</AccordionControl>
<Accordion.Panel>
<AppearanceSettings
themeSettings={state.localSettings.theme}
onThemeChange={(newTheme) =>
handleInputChange('theme', newTheme)
}
/>
<AppearanceSettings />
</Accordion.Panel>
</Accordion.Item>
@@ -186,10 +197,14 @@ const Settings = () => {
<AccordionControl>Editor</AccordionControl>
<Accordion.Panel>
<EditorSettings
autoSave={state.localSettings.autoSave}
onAutoSaveChange={(value) =>
autoSave={state.localSettings.autoSave || false}
onAutoSaveChange={(value: boolean) =>
handleInputChange('autoSave', value)
}
showHiddenFiles={state.localSettings.showHiddenFiles || false}
onShowHiddenFilesChange={(value: boolean) =>
handleInputChange('showHiddenFiles', value)
}
/>
</Accordion.Panel>
</Accordion.Item>
@@ -198,12 +213,16 @@ const Settings = () => {
<AccordionControl>Git Integration</AccordionControl>
<Accordion.Panel>
<GitSettings
gitEnabled={state.localSettings.gitEnabled}
gitUrl={state.localSettings.gitUrl}
gitUser={state.localSettings.gitUser}
gitToken={state.localSettings.gitToken}
gitAutoCommit={state.localSettings.gitAutoCommit}
gitCommitMsgTemplate={state.localSettings.gitCommitMsgTemplate}
gitEnabled={state.localSettings.gitEnabled || false}
gitUrl={state.localSettings.gitUrl || ''}
gitUser={state.localSettings.gitUser || ''}
gitToken={state.localSettings.gitToken || ''}
gitAutoCommit={state.localSettings.gitAutoCommit || false}
gitCommitMsgTemplate={
state.localSettings.gitCommitMsgTemplate || ''
}
gitCommitName={state.localSettings.gitCommitName || ''}
gitCommitEmail={state.localSettings.gitCommitEmail || ''}
onInputChange={handleInputChange}
/>
</Accordion.Panel>
@@ -221,11 +240,11 @@ const Settings = () => {
<Button variant="default" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSubmit}>Save Changes</Button>
<Button onClick={() => void handleSubmit()}>Save Changes</Button>
</Group>
</Stack>
</Modal>
);
};
export default Settings;
export default WorkspaceSettings;

View File

@@ -0,0 +1,770 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import React from 'react';
import { AuthProvider, useAuth } from './AuthContext';
import { UserRole, type User } from '@/types/models';
// Set up mocks before imports are used
vi.mock('@/api/auth', () => {
return {
login: vi.fn(),
logout: vi.fn(),
refreshToken: vi.fn(),
getCurrentUser: vi.fn(),
};
});
vi.mock('@mantine/notifications', () => {
return {
notifications: {
show: vi.fn(),
},
};
});
// Import the mocks after they've been defined
import {
login as mockLogin,
logout as mockLogout,
refreshToken as mockRefreshToken,
getCurrentUser as mockGetCurrentUser,
} from '@/api/auth';
import { notifications } from '@mantine/notifications';
// Get reference to the mocked notifications.show function
const mockNotificationsShow = notifications.show as unknown as ReturnType<
typeof vi.fn
>;
// Mock user data
const mockUser: User = {
id: 1,
email: 'test@example.com',
displayName: 'Test User',
role: UserRole.Editor,
createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1,
};
// Helper wrapper component for testing
const createWrapper = () => {
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
Wrapper.displayName = 'AuthProviderTestWrapper';
return Wrapper;
};
describe('AuthContext', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('AuthProvider initialization', () => {
it('initializes with null user and loading state', async () => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Not authenticated')
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.user).toBeNull();
expect(result.current.loading).toBe(true);
expect(result.current.initialized).toBe(false);
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
});
it('provides all expected functions', async () => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Not authenticated')
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
expect(typeof result.current.login).toBe('function');
expect(typeof result.current.logout).toBe('function');
expect(typeof result.current.refreshToken).toBe('function');
expect(typeof result.current.refreshUser).toBe('function');
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
});
it('loads current user on mount when authenticated', async () => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
mockUser
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
expect(result.current.user).toEqual(mockUser);
expect(result.current.loading).toBe(false);
expect(mockGetCurrentUser).toHaveBeenCalledTimes(1);
});
it('handles initialization error gracefully', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Network error')
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
expect(result.current.user).toBeNull();
expect(result.current.loading).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to initialize auth:',
expect.any(Error)
);
consoleSpy.mockRestore();
});
});
describe('useAuth hook', () => {
it('throws error when used outside AuthProvider', () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
expect(() => {
renderHook(() => useAuth());
}).toThrow('useAuth must be used within an AuthProvider');
consoleSpy.mockRestore();
});
it('returns auth context when used within provider', async () => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Not authenticated')
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
expect(result.current).toBeDefined();
expect(typeof result.current).toBe('object');
});
it('maintains function stability across re-renders', async () => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Not authenticated')
);
const wrapper = createWrapper();
const { result, rerender } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
const initialFunctions = {
login: result.current.login,
logout: result.current.logout,
refreshToken: result.current.refreshToken,
refreshUser: result.current.refreshUser,
};
rerender();
expect(result.current.login).toBe(initialFunctions.login);
expect(result.current.logout).toBe(initialFunctions.logout);
expect(result.current.refreshToken).toBe(initialFunctions.refreshToken);
expect(result.current.refreshUser).toBe(initialFunctions.refreshUser);
});
});
describe('login functionality', () => {
beforeEach(() => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Not authenticated')
);
});
it('logs in user successfully', async () => {
(mockLogin as ReturnType<typeof vi.fn>).mockResolvedValue(mockUser);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
let loginResult: boolean | undefined;
await act(async () => {
loginResult = await result.current.login(
'test@example.com',
'password123'
);
});
expect(loginResult).toBe(true);
expect(result.current.user).toEqual(mockUser);
expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password123');
expect(mockNotificationsShow).toHaveBeenCalledWith({
title: 'Success',
message: 'Logged in successfully',
color: 'green',
});
});
it('handles login failure with error message', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
(mockLogin as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Invalid credentials')
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
let loginResult: boolean | undefined;
await act(async () => {
loginResult = await result.current.login(
'test@example.com',
'wrongpassword'
);
});
expect(loginResult).toBe(false);
expect(result.current.user).toBeNull();
expect(consoleSpy).toHaveBeenCalledWith(
'Login failed:',
expect.any(Error)
);
expect(mockNotificationsShow).toHaveBeenCalledWith({
title: 'Error',
message: 'Invalid credentials',
color: 'red',
});
consoleSpy.mockRestore();
});
it('handles multiple login attempts', async () => {
(mockLogin as ReturnType<typeof vi.fn>)
.mockRejectedValueOnce(new Error('First attempt failed'))
.mockResolvedValueOnce(mockUser);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
// First attempt fails
let firstResult: boolean | undefined;
await act(async () => {
firstResult = await result.current.login(
'test@example.com',
'wrongpassword'
);
});
expect(firstResult).toBe(false);
expect(result.current.user).toBeNull();
// Second attempt succeeds
let secondResult: boolean | undefined;
await act(async () => {
secondResult = await result.current.login(
'test@example.com',
'correctpassword'
);
});
expect(secondResult).toBe(true);
expect(result.current.user).toEqual(mockUser);
});
});
describe('logout functionality', () => {
it('logs out user successfully', async () => {
// Start with authenticated user
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
mockUser
);
(mockLogout as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.user).toEqual(mockUser);
});
await act(async () => {
await result.current.logout();
});
expect(result.current.user).toBeNull();
expect(mockLogout).toHaveBeenCalledTimes(1);
});
it('clears user state even when logout API fails', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Start with authenticated user
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
mockUser
);
(mockLogout as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Logout failed')
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.user).toEqual(mockUser);
});
await act(async () => {
await result.current.logout();
});
expect(result.current.user).toBeNull();
expect(consoleSpy).toHaveBeenCalledWith(
'Logout failed:',
expect.any(Error)
);
consoleSpy.mockRestore();
});
it('handles logout when user is already null', async () => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Not authenticated')
);
(mockLogout as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
await act(async () => {
await result.current.logout();
});
expect(result.current.user).toBeNull();
expect(mockLogout).toHaveBeenCalledTimes(1);
});
});
describe('refreshToken functionality', () => {
it('refreshes token successfully', async () => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Not authenticated')
);
(mockRefreshToken as ReturnType<typeof vi.fn>).mockResolvedValue(true);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
let refreshResult: boolean | undefined;
await act(async () => {
refreshResult = await result.current.refreshToken();
});
expect(refreshResult).toBe(true);
expect(mockRefreshToken).toHaveBeenCalledTimes(1);
});
it('handles token refresh failure and logs out user', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Start with authenticated user
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
mockUser
);
(mockRefreshToken as ReturnType<typeof vi.fn>).mockResolvedValue(false);
(mockLogout as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.user).toEqual(mockUser);
});
let refreshResult: boolean | undefined;
await act(async () => {
refreshResult = await result.current.refreshToken();
});
expect(refreshResult).toBe(false);
expect(result.current.user).toBeNull();
expect(mockLogout).toHaveBeenCalledTimes(1);
consoleSpy.mockRestore();
});
it('handles token refresh API error and logs out user', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Start with authenticated user
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
mockUser
);
(mockRefreshToken as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Refresh failed')
);
(mockLogout as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.user).toEqual(mockUser);
});
let refreshResult: boolean | undefined;
await act(async () => {
refreshResult = await result.current.refreshToken();
});
expect(refreshResult).toBe(false);
expect(result.current.user).toBeNull();
expect(consoleSpy).toHaveBeenCalledWith(
'Token refresh failed:',
expect.any(Error)
);
expect(mockLogout).toHaveBeenCalledTimes(1);
consoleSpy.mockRestore();
});
});
describe('refreshUser functionality', () => {
it('refreshes user data successfully', async () => {
// Start with authenticated user
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
mockUser
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.user).toEqual(mockUser);
});
// Mock updated user data
const updatedUser = { ...mockUser, displayName: 'Updated User' };
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
updatedUser
);
await act(async () => {
await result.current.refreshUser();
});
expect(result.current.user).toEqual(updatedUser);
expect(mockGetCurrentUser).toHaveBeenCalledTimes(2); // Once on init, once on refresh
});
it('handles user refresh failure', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Start with authenticated user
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
mockUser
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.user).toEqual(mockUser);
});
// Mock refresh failure
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Refresh user failed')
);
await act(async () => {
await result.current.refreshUser();
});
// User should remain the same after failed refresh
expect(result.current.user).toEqual(mockUser);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to refresh user data:',
expect.any(Error)
);
consoleSpy.mockRestore();
});
});
describe('authentication state transitions', () => {
it('transitions from unauthenticated to authenticated', async () => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Not authenticated')
);
(mockLogin as ReturnType<typeof vi.fn>).mockResolvedValue(mockUser);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
expect(result.current.user).toBeNull();
await act(async () => {
await result.current.login('test@example.com', 'password123');
});
expect(result.current.user).toEqual(mockUser);
});
it('transitions from authenticated to unauthenticated', async () => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
mockUser
);
(mockLogout as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.user).toEqual(mockUser);
});
await act(async () => {
await result.current.logout();
});
expect(result.current.user).toBeNull();
});
it('handles user data updates while authenticated', async () => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
mockUser
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.user).toEqual(mockUser);
});
// Simulate user profile update
const updatedUser = { ...mockUser, displayName: 'Updated Name' };
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
updatedUser
);
await act(async () => {
await result.current.refreshUser();
});
expect(result.current.user).toEqual(updatedUser);
});
});
describe('context value structure', () => {
it('provides expected context interface', async () => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Not authenticated')
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
// Check boolean and object values
expect(result.current.user).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.initialized).toBe(true);
// Check function types
expect(typeof result.current.login).toBe('function');
expect(typeof result.current.logout).toBe('function');
expect(typeof result.current.refreshToken).toBe('function');
expect(typeof result.current.refreshUser).toBe('function');
});
it('provides correct context when authenticated', async () => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
mockUser
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
// Check boolean and object values
expect(result.current.user).toEqual(mockUser);
expect(result.current.loading).toBe(false);
expect(result.current.initialized).toBe(true);
// Check function types
expect(typeof result.current.login).toBe('function');
expect(typeof result.current.logout).toBe('function');
expect(typeof result.current.refreshToken).toBe('function');
expect(typeof result.current.refreshUser).toBe('function');
});
});
describe('loading states', () => {
it('shows loading during initialization', async () => {
let resolveGetCurrentUser: (value: User) => void;
const pendingPromise = new Promise<User>((resolve) => {
resolveGetCurrentUser = resolve;
});
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockReturnValue(
pendingPromise
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.loading).toBe(true);
expect(result.current.initialized).toBe(false);
await act(async () => {
resolveGetCurrentUser!(mockUser);
await pendingPromise;
});
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
});
it('clears loading after initialization completes', async () => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue(
mockUser
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.initialized).toBe(true);
});
});
it('clears loading after initialization fails', async () => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Init failed')
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.initialized).toBe(true);
});
});
});
describe('error handling', () => {
it('handles invalid user data during initialization', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Use a more precise type for testing
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockResolvedValue({
invalid: 'user',
} as unknown as User);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
expect(result.current.user).toEqual({ invalid: 'user' });
consoleSpy.mockRestore();
});
});
describe('concurrent operations', () => {
it('handles concurrent login attempts', async () => {
(mockGetCurrentUser as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('Not authenticated')
);
(mockLogin as ReturnType<typeof vi.fn>).mockResolvedValue(mockUser);
const wrapper = createWrapper();
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.initialized).toBe(true);
});
// Make concurrent login calls
const [result1, result2] = await act(async () => {
return Promise.all([
result.current.login('test@example.com', 'password123'),
result.current.login('test@example.com', 'password123'),
]);
});
expect(result1).toBe(true);
expect(result2).toBe(true);
expect(result.current.user).toEqual(mockUser);
expect(mockLogin).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,131 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
} from 'react';
import { notifications } from '@mantine/notifications';
import {
login as apiLogin,
logout as apiLogout,
refreshToken as apiRefreshToken,
getCurrentUser,
} from '@/api/auth';
import type { User } from '@/types/models';
interface AuthContextType {
user: User | null;
loading: boolean;
initialized: boolean;
login: (email: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
refreshToken: () => Promise<boolean>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | null>(null);
interface AuthProviderProps {
children: React.ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [initialized, setInitialized] = useState<boolean>(false);
// Load user data on mount
useEffect(() => {
const initializeAuth = async (): Promise<void> => {
try {
const userData = await getCurrentUser();
setUser(userData);
} catch (error) {
console.error('Failed to initialize auth:', error);
} finally {
setLoading(false);
setInitialized(true);
}
};
void initializeAuth();
}, []);
const login = useCallback(
async (email: string, password: string): Promise<boolean> => {
try {
const userData = await apiLogin(email, password);
setUser(userData);
notifications.show({
title: 'Success',
message: 'Logged in successfully',
color: 'green',
});
return true;
} catch (error) {
console.error('Login failed:', error);
notifications.show({
title: 'Error',
message: error instanceof Error ? error.message : 'Login failed',
color: 'red',
});
return false;
}
},
[]
);
const logout = useCallback(async (): Promise<void> => {
try {
await apiLogout();
} catch (error) {
console.error('Logout failed:', error);
} finally {
setUser(null);
}
}, []);
const refreshToken = useCallback(async (): Promise<boolean> => {
try {
const success = await apiRefreshToken();
if (!success) {
await logout();
}
return success;
} catch (error) {
console.error('Token refresh failed:', error);
await logout();
return false;
}
}, [logout]);
const refreshUser = useCallback(async (): Promise<void> => {
try {
const userData = await getCurrentUser();
setUser(userData);
} catch (error) {
console.error('Failed to refresh user data:', error);
}
}, []);
const value: AuthContextType = {
user,
loading,
initialized,
login,
logout,
refreshToken,
refreshUser,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

Some files were not shown because too many files have changed in this diff Show More