Преглед на файлове

test: front-end setup jest, testing-library, msw & test components

Nicolas Meienberger преди 2 години
родител
ревизия
c4bda4eb07
променени са 95 файла, в които са добавени 3903 реда и са изтрити 419 реда
  1. 1 12
      docker-compose.dev.yml
  2. 2 18
      docker-compose.rc.yml
  3. 2 18
      docker-compose.yml
  4. 5 7
      packages/dashboard/.eslintrc.js
  5. 1 1
      packages/dashboard/.gitignore
  6. 16 9
      packages/dashboard/jest.config.js
  7. 1 1
      packages/dashboard/next.config.js
  8. 20 1
      packages/dashboard/package.json
  9. BIN
      packages/dashboard/public/error.png
  10. 303 0
      packages/dashboard/public/mockServiceWorker.js
  11. 5 1
      packages/dashboard/src/components/AppLogo/AppLogo.tsx
  12. 0 0
      packages/dashboard/src/components/AppLogo/index.ts
  13. 1 1
      packages/dashboard/src/components/AppTile/AppTile.tsx
  14. 11 5
      packages/dashboard/src/components/Layout/Layout.tsx
  15. 11 1
      packages/dashboard/src/components/StatusScreen/StatusScreen.tsx
  16. 42 0
      packages/dashboard/src/components/hoc/AuthProvider/AuthProvider.test.tsx
  17. 74 0
      packages/dashboard/src/components/hoc/StatusProvider/StatusProvider.test.tsx
  18. 7 2
      packages/dashboard/src/components/hoc/StatusProvider/StatusProvider.tsx
  19. 70 0
      packages/dashboard/src/components/hoc/ToastProvider/ToastProvider.test.tsx
  20. 50 0
      packages/dashboard/src/components/ui/Button/Button.test.tsx
  21. 33 0
      packages/dashboard/src/components/ui/DataGrid/DataGrid.test.tsx
  22. 0 0
      packages/dashboard/src/components/ui/DataGrid/index.ts
  23. 28 0
      packages/dashboard/src/components/ui/EmptyPage/EmptyPage.test.tsx
  24. 13 3
      packages/dashboard/src/components/ui/EmptyPage/EmptyPage.tsx
  25. 4 0
      packages/dashboard/src/components/ui/ErrorPage/ErrorPage.module.scss
  26. 34 0
      packages/dashboard/src/components/ui/ErrorPage/ErrorPage.test.tsx
  27. 39 0
      packages/dashboard/src/components/ui/ErrorPage/ErrorPage.tsx
  28. 1 0
      packages/dashboard/src/components/ui/ErrorPage/index.ts
  29. 81 0
      packages/dashboard/src/components/ui/Header/Header.test.tsx
  30. 24 9
      packages/dashboard/src/components/ui/Header/Header.tsx
  31. 105 0
      packages/dashboard/src/components/ui/Input/Input.test.tsx
  32. 3 2
      packages/dashboard/src/components/ui/Input/Input.tsx
  33. 141 0
      packages/dashboard/src/components/ui/Modal/Modal.test.tsx
  34. 19 3
      packages/dashboard/src/components/ui/Modal/Modal.tsx
  35. 5 1
      packages/dashboard/src/components/ui/Modal/ModalBody.tsx
  36. 5 1
      packages/dashboard/src/components/ui/Modal/ModalFooter.tsx
  37. 5 1
      packages/dashboard/src/components/ui/Modal/ModalHeader.tsx
  38. 50 0
      packages/dashboard/src/components/ui/NavBar/NavBar.test.tsx
  39. 7 8
      packages/dashboard/src/components/ui/NavBar/NavBar.tsx
  40. 59 0
      packages/dashboard/src/components/ui/Switch/Switch.test.tsx
  41. 2 2
      packages/dashboard/src/components/ui/Switch/Switch.tsx
  42. 34 0
      packages/dashboard/src/components/ui/Toast/Toast.test.tsx
  43. 1 1
      packages/dashboard/src/components/ui/Toast/Toast.tsx
  44. 1 5
      packages/dashboard/src/core/helpers/url-helpers.ts
  45. 0 6
      packages/dashboard/src/core/types.ts
  46. 282 232
      packages/dashboard/src/generated/graphql.tsx
  47. 6 1
      packages/dashboard/src/hooks/useCachedRessources.ts
  48. 4 0
      packages/dashboard/src/mocks/browser.ts
  49. 57 0
      packages/dashboard/src/mocks/fixtures/app.fixtures.ts
  50. 133 0
      packages/dashboard/src/mocks/handlers.ts
  51. 173 0
      packages/dashboard/src/mocks/handlers/appHandlers.ts
  52. 13 0
      packages/dashboard/src/mocks/index.ts
  53. 4 0
      packages/dashboard/src/mocks/server.ts
  54. 1 1
      packages/dashboard/src/modules/AppStore/components/AppStoreTable/AppStoreTable.loading.tsx
  55. 1 1
      packages/dashboard/src/modules/AppStore/components/AppStoreTable/AppStoreTable.tsx
  56. 57 0
      packages/dashboard/src/modules/AppStore/pages/AppStorePage/AppStorePage.test.tsx
  57. 4 1
      packages/dashboard/src/modules/AppStore/pages/AppStorePage/AppStorePage.tsx
  58. 83 0
      packages/dashboard/src/modules/Apps/components/AppActions/AppActions.test.tsx
  59. 12 15
      packages/dashboard/src/modules/Apps/components/AppActions/AppActions.tsx
  60. 1 0
      packages/dashboard/src/modules/Apps/components/AppActions/index.ts
  61. 81 0
      packages/dashboard/src/modules/Apps/components/InstallForm/InstallForm.test.tsx
  62. 17 7
      packages/dashboard/src/modules/Apps/components/InstallForm/InstallForm.tsx
  63. 1 0
      packages/dashboard/src/modules/Apps/components/InstallForm/index.ts
  64. 61 0
      packages/dashboard/src/modules/Apps/components/InstallModal/InstallModal.test.tsx
  65. 4 4
      packages/dashboard/src/modules/Apps/components/InstallModal/InstallModal.tsx
  66. 1 0
      packages/dashboard/src/modules/Apps/components/InstallModal/index.ts
  67. 48 0
      packages/dashboard/src/modules/Apps/components/UpdateModal/UpdateModal.test.tsx
  68. 4 4
      packages/dashboard/src/modules/Apps/components/UpdateModal/UpdateModal.tsx
  69. 1 0
      packages/dashboard/src/modules/Apps/components/UpdateModal/index.ts
  70. 153 0
      packages/dashboard/src/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.test.tsx
  71. 2 2
      packages/dashboard/src/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.tsx
  72. 63 0
      packages/dashboard/src/modules/Apps/pages/AppDetailsPage/AppDetailsPage.test.tsx
  73. 3 1
      packages/dashboard/src/modules/Apps/pages/AppDetailsPage/AppDetailsPage.tsx
  74. 104 0
      packages/dashboard/src/modules/Apps/pages/AppsPage/AppsPage.test.tsx
  75. 9 3
      packages/dashboard/src/modules/Apps/pages/AppsPage/AppsPage.tsx
  76. 1 0
      packages/dashboard/src/modules/Apps/utils/validators/index.ts
  77. 256 0
      packages/dashboard/src/modules/Apps/utils/validators/validators.test.tsx
  78. 2 2
      packages/dashboard/src/modules/Apps/utils/validators/validators.ts
  79. 10 1
      packages/dashboard/src/modules/Auth/components/AuthFormLayout/AuthFormLayout.tsx
  80. 7 1
      packages/dashboard/src/modules/Auth/components/LoginForm/LoginForm.tsx
  81. 100 0
      packages/dashboard/src/modules/Auth/containers/LoginContainer/LoginContainer.test.tsx
  82. 78 0
      packages/dashboard/src/modules/Auth/containers/RegisterContainer/RegisterContainer.test.tsx
  83. 2 1
      packages/dashboard/src/modules/Auth/containers/RegisterContainer/RegisterContainer.tsx
  84. 27 0
      packages/dashboard/src/modules/Dashboard/containers/Dashboard.test.tsx
  85. 11 0
      packages/dashboard/src/modules/Dashboard/pages/DashboardPage/DashboardPage.test.tsx
  86. 1 1
      packages/dashboard/src/modules/Settings/components/RestartModal/RestartModal.tsx
  87. 122 0
      packages/dashboard/src/modules/Settings/containers/SettingsContainer/SettingsContainer.test.tsx
  88. 21 0
      packages/dashboard/src/modules/Settings/pages/SettingsPage/SettingsPage.test.tsx
  89. 3 1
      packages/dashboard/src/modules/Settings/pages/SettingsPage/SettingsPage.tsx
  90. 5 0
      packages/dashboard/src/pages/_app.tsx
  91. 2 0
      packages/dashboard/src/state/toastStore.ts
  92. 40 0
      packages/dashboard/tests/jest.setup.tsx
  93. 31 0
      packages/dashboard/tests/test-utils.tsx
  94. 2 1
      packages/dashboard/tsconfig.json
  95. 483 20
      pnpm-lock.yaml

+ 1 - 12
docker-compose.dev.yml

@@ -111,22 +111,11 @@ services:
       # - /dashboard/.next
     labels:
       traefik.enable: true
-      traefik.http.routers.dashboard-redirect.rule: PathPrefix("/")
-      traefik.http.routers.dashboard-redirect.entrypoints: web
-      traefik.http.routers.dashboard-redirect.middlewares: redirect-middleware
-      traefik.http.routers.dashboard-redirect.service: dashboard
-      traefik.http.services.dashboard-redirect.loadbalancer.server.port: 3000
-
       # Web
-      traefik.http.routers.dashboard.rule: PathPrefix("/dashboard")
+      traefik.http.routers.dashboard.rule: PathPrefix("/")
       traefik.http.routers.dashboard.service: dashboard
       traefik.http.routers.dashboard.entrypoints: web
       traefik.http.services.dashboard.loadbalancer.server.port: 3000
-      # Middlewares
-      traefik.http.middlewares.redirect-middleware.redirectregex.regex: .*
-      traefik.http.middlewares.redirect-middleware.redirectregex.replacement: /dashboard
-  
-  
 
 networks:
   tipi_main_network:

+ 2 - 18
docker-compose.rc.yml

@@ -106,33 +106,17 @@ services:
       NODE_ENV: production
     labels:
       traefik.enable: true
-      traefik.http.routers.dashboard-redirect.rule: PathPrefix("/")
-      traefik.http.routers.dashboard-redirect.entrypoints: web
-      traefik.http.routers.dashboard-redirect.middlewares: redirect-middleware
-      traefik.http.routers.dashboard-redirect.service: dashboard
-      traefik.http.services.dashboard-redirect.loadbalancer.server.port: 3000
-
-      traefik.http.routers.dashboard-redirect-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/`)
-      traefik.http.routers.dashboard-redirect-secure.entrypoints: websecure
-      traefik.http.routers.dashboard-redirect-secure.middlewares: redirect-middleware
-      traefik.http.routers.dashboard-redirect-secure.service: dashboard
-      traefik.http.routers.dashboard-redirect-secure.tls.certresolver: myresolver
-      traefik.http.services.dashboard-redirect-secure.loadbalancer.server.port: 3000
-
       # Web
-      traefik.http.routers.dashboard.rule: PathPrefix("/dashboard")
+      traefik.http.routers.dashboard.rule: PathPrefix("/")
       traefik.http.routers.dashboard.service: dashboard
       traefik.http.routers.dashboard.entrypoints: web
       traefik.http.services.dashboard.loadbalancer.server.port: 3000
       # Websecure
-      traefik.http.routers.dashboard-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/dashboard`)
+      traefik.http.routers.dashboard-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/`)
       traefik.http.routers.dashboard-secure.service: dashboard-secure
       traefik.http.routers.dashboard-secure.entrypoints: websecure
       traefik.http.routers.dashboard-secure.tls.certresolver: myresolver
       traefik.http.services.dashboard-secure.loadbalancer.server.port: 3000
-      # Middlewares
-      traefik.http.middlewares.redirect-middleware.redirectregex.regex: .*
-      traefik.http.middlewares.redirect-middleware.redirectregex.replacement: /dashboard
 
 networks:
   tipi_main_network:

+ 2 - 18
docker-compose.yml

@@ -107,33 +107,17 @@ services:
       NODE_ENV: production
     labels:
       traefik.enable: true
-      traefik.http.routers.dashboard-redirect.rule: PathPrefix("/")
-      traefik.http.routers.dashboard-redirect.entrypoints: web
-      traefik.http.routers.dashboard-redirect.middlewares: redirect-middleware
-      traefik.http.routers.dashboard-redirect.service: dashboard
-      traefik.http.services.dashboard-redirect.loadbalancer.server.port: 3000
-
-      traefik.http.routers.dashboard-redirect-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/`)
-      traefik.http.routers.dashboard-redirect-secure.entrypoints: websecure
-      traefik.http.routers.dashboard-redirect-secure.middlewares: redirect-middleware
-      traefik.http.routers.dashboard-redirect-secure.service: dashboard
-      traefik.http.routers.dashboard-redirect-secure.tls.certresolver: myresolver
-      traefik.http.services.dashboard-redirect-secure.loadbalancer.server.port: 3000
-
       # Web
-      traefik.http.routers.dashboard.rule: PathPrefix("/dashboard")
+      traefik.http.routers.dashboard.rule: PathPrefix("/")
       traefik.http.routers.dashboard.service: dashboard
       traefik.http.routers.dashboard.entrypoints: web
       traefik.http.services.dashboard.loadbalancer.server.port: 3000
       # Websecure
-      traefik.http.routers.dashboard-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/dashboard`)
+      traefik.http.routers.dashboard-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/`)
       traefik.http.routers.dashboard-secure.service: dashboard-secure
       traefik.http.routers.dashboard-secure.entrypoints: websecure
       traefik.http.routers.dashboard-secure.tls.certresolver: myresolver
       traefik.http.services.dashboard-secure.loadbalancer.server.port: 3000
-      # Middlewares
-      traefik.http.middlewares.redirect-middleware.redirectregex.regex: .*
-      traefik.http.middlewares.redirect-middleware.redirectregex.replacement: /dashboard
 
 networks:
   tipi_main_network:

+ 5 - 7
packages/dashboard/.eslintrc.js

@@ -1,10 +1,9 @@
 module.exports = {
-  plugins: ['@typescript-eslint', 'import', 'react'],
+  plugins: ['@typescript-eslint', 'import', 'react', 'jest'],
   extends: [
     'plugin:@typescript-eslint/recommended',
     'next/core-web-vitals',
     'next',
-    // 'plugin:react-hooks/recommended',
     'airbnb',
     'airbnb-typescript',
     'eslint:recommended',
@@ -20,22 +19,21 @@ module.exports = {
     tsconfigRootDir: __dirname,
   },
   rules: {
-    // 'arrow-body-style': 0,
     'no-restricted-exports': 0,
-    // 'max-len': [1, { code: 200 }],
-    // 'import/extensions': ['error', 'ignorePackages', { js: 'never', jsx: 'never', ts: 'never', tsx: 'never' }],
     'react/display-name': 0,
     'react/prop-types': 0,
     'react/function-component-definition': 0,
     'react/require-default-props': 0,
     'import/prefer-default-export': 0,
     'react/jsx-props-no-spreading': 0,
-    // '@typescript-eslint/no-misused-promises': 0,
-    // '@typescript-eslint/no-unsafe-assignment': 0,
     'react/no-unused-prop-types': 0,
     'react/button-has-type': 0,
+    'import/no-extraneous-dependencies': ['error', { devDependencies: ['**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', '**/mocks/**', 'tests/**'] }],
   },
   globals: {
     JSX: true,
   },
+  env: {
+    'jest/globals': true,
+  },
 };

+ 1 - 1
packages/dashboard/.gitignore

@@ -32,4 +32,4 @@ yarn-error.log*
 .vercel
 
 # typescript
-*.tsbuildinfo
+*.tsbuildinfo

+ 16 - 9
packages/dashboard/jest.config.js

@@ -1,11 +1,18 @@
-/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
-module.exports = {
-  verbose: true,
-  // testEnvironment: 'node',
-  testMatch: ['**/__tests__/**/*.test.ts'],
-  // setupFiles: ['<rootDir>/tests/dotenv-config.ts'],
+const nextJest = require('next/jest');
+
+const createJestConfig = nextJest({
+  // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
+  dir: './',
+});
+
+// Add any custom config to be passed to Jest
+const customJestConfig = {
+  setupFilesAfterEnv: ['<rootDir>/tests/jest.setup.tsx'],
+  testEnvironment: 'jest-environment-jsdom',
   collectCoverage: true,
-  collectCoverageFrom: ['src/**/*.{ts,tsx}'],
-  // coverageProvider: 'v8',
-  passWithNoTests: true,
+  collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/index.ts', '!**/src/pages/**/*.{ts,tsx}', '!**/src/mocks/**', '!**/src/core/apollo/**'],
+  testMatch: ['<rootDir>/src/**/*.{spec,test}.{ts,tsx}'],
 };
+
+// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
+module.exports = createJestConfig(customJestConfig);

+ 1 - 1
packages/dashboard/next.config.js

@@ -2,7 +2,7 @@
 const nextConfig = {
   output: 'standalone',
   reactStrictMode: true,
-  basePath: '/dashboard',
+  swcMinify: true,
 };
 
 module.exports = nextConfig;

+ 20 - 1
packages/dashboard/package.json

@@ -5,6 +5,7 @@
   "scripts": {
     "test": "jest --colors",
     "dev": "next dev",
+    "dev:msw": "NEXT_PUBLIC_API_MOCKING=enabled next dev",
     "build": "next build",
     "start": "next start",
     "lint": "next lint",
@@ -19,6 +20,7 @@
     "clsx": "^1.1.1",
     "graphql": "^15.8.0",
     "graphql-tag": "^2.12.6",
+    "isomorphic-fetch": "^3.0.0",
     "next": "13.0.3",
     "react": "18.2.0",
     "react-dom": "18.2.0",
@@ -40,27 +42,44 @@
   },
   "devDependencies": {
     "@babel/core": "^7.0.0",
+    "@faker-js/faker": "^7.3.0",
     "@graphql-codegen/cli": "^2.6.2",
     "@graphql-codegen/typescript": "^2.5.1",
     "@graphql-codegen/typescript-operations": "^2.4.2",
     "@graphql-codegen/typescript-react-apollo": "^3.2.16",
+    "@testing-library/dom": "^8.19.0",
+    "@testing-library/jest-dom": "^5.16.5",
+    "@testing-library/react": "^13.4.0",
+    "@testing-library/user-event": "^14.4.3",
+    "@types/isomorphic-fetch": "^0.0.36",
+    "@types/jest": "^27.5.0",
     "@types/node": "17.0.31",
     "@types/react": "18.0.8",
     "@types/react-dom": "18.0.3",
     "@types/semver": "^7.3.12",
+    "@types/testing-library__jest-dom": "^5.14.5",
     "@types/validator": "^13.7.2",
     "@typescript-eslint/eslint-plugin": "^5.18.0",
     "@typescript-eslint/parser": "^5.0.0",
+    "concurrently": "^7.1.0",
     "eslint": "8.12.0",
     "eslint-config-airbnb": "^19.0.4",
     "eslint-config-airbnb-typescript": "^17.0.0",
     "eslint-config-next": "12.1.4",
     "eslint-plugin-import": "^2.25.3",
+    "eslint-plugin-jest": "^27.1.6",
     "eslint-plugin-jsx-a11y": "^6.6.1",
     "eslint-plugin-react": "^7.31.10",
     "eslint-plugin-react-hooks": "^4.6.0",
     "jest": "^28.1.0",
+    "jest-environment-jsdom": "^29.3.1",
+    "msw": "^0.49.1",
+    "next-router-mock": "^0.8.0",
     "ts-jest": "^28.0.2",
-    "typescript": "4.6.4"
+    "typescript": "4.6.4",
+    "whatwg-fetch": "^3.6.2"
+  },
+  "msw": {
+    "workerDirectory": "public"
   }
 }

BIN
packages/dashboard/public/error.png


+ 303 - 0
packages/dashboard/public/mockServiceWorker.js

@@ -0,0 +1,303 @@
+/* eslint-disable */
+/* tslint:disable */
+
+/**
+ * Mock Service Worker (0.49.1).
+ * @see https://github.com/mswjs/msw
+ * - Please do NOT modify this file.
+ * - Please do NOT serve this file on production.
+ */
+
+const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
+const activeClientIds = new Set()
+
+self.addEventListener('install', function () {
+  self.skipWaiting()
+})
+
+self.addEventListener('activate', function (event) {
+  event.waitUntil(self.clients.claim())
+})
+
+self.addEventListener('message', async function (event) {
+  const clientId = event.source.id
+
+  if (!clientId || !self.clients) {
+    return
+  }
+
+  const client = await self.clients.get(clientId)
+
+  if (!client) {
+    return
+  }
+
+  const allClients = await self.clients.matchAll({
+    type: 'window',
+  })
+
+  switch (event.data) {
+    case 'KEEPALIVE_REQUEST': {
+      sendToClient(client, {
+        type: 'KEEPALIVE_RESPONSE',
+      })
+      break
+    }
+
+    case 'INTEGRITY_CHECK_REQUEST': {
+      sendToClient(client, {
+        type: 'INTEGRITY_CHECK_RESPONSE',
+        payload: INTEGRITY_CHECKSUM,
+      })
+      break
+    }
+
+    case 'MOCK_ACTIVATE': {
+      activeClientIds.add(clientId)
+
+      sendToClient(client, {
+        type: 'MOCKING_ENABLED',
+        payload: true,
+      })
+      break
+    }
+
+    case 'MOCK_DEACTIVATE': {
+      activeClientIds.delete(clientId)
+      break
+    }
+
+    case 'CLIENT_CLOSED': {
+      activeClientIds.delete(clientId)
+
+      const remainingClients = allClients.filter((client) => {
+        return client.id !== clientId
+      })
+
+      // Unregister itself when there are no more clients
+      if (remainingClients.length === 0) {
+        self.registration.unregister()
+      }
+
+      break
+    }
+  }
+})
+
+self.addEventListener('fetch', function (event) {
+  const { request } = event
+  const accept = request.headers.get('accept') || ''
+
+  // Bypass server-sent events.
+  if (accept.includes('text/event-stream')) {
+    return
+  }
+
+  // Bypass navigation requests.
+  if (request.mode === 'navigate') {
+    return
+  }
+
+  // Opening the DevTools triggers the "only-if-cached" request
+  // that cannot be handled by the worker. Bypass such requests.
+  if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
+    return
+  }
+
+  // Bypass all requests when there are no active clients.
+  // Prevents the self-unregistered worked from handling requests
+  // after it's been deleted (still remains active until the next reload).
+  if (activeClientIds.size === 0) {
+    return
+  }
+
+  // Generate unique request ID.
+  const requestId = Math.random().toString(16).slice(2)
+
+  event.respondWith(
+    handleRequest(event, requestId).catch((error) => {
+      if (error.name === 'NetworkError') {
+        console.warn(
+          '[MSW] Successfully emulated a network error for the "%s %s" request.',
+          request.method,
+          request.url,
+        )
+        return
+      }
+
+      // At this point, any exception indicates an issue with the original request/response.
+      console.error(
+        `\
+[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
+        request.method,
+        request.url,
+        `${error.name}: ${error.message}`,
+      )
+    }),
+  )
+})
+
+async function handleRequest(event, requestId) {
+  const client = await resolveMainClient(event)
+  const response = await getResponse(event, client, requestId)
+
+  // Send back the response clone for the "response:*" life-cycle events.
+  // Ensure MSW is active and ready to handle the message, otherwise
+  // this message will pend indefinitely.
+  if (client && activeClientIds.has(client.id)) {
+    ;(async function () {
+      const clonedResponse = response.clone()
+      sendToClient(client, {
+        type: 'RESPONSE',
+        payload: {
+          requestId,
+          type: clonedResponse.type,
+          ok: clonedResponse.ok,
+          status: clonedResponse.status,
+          statusText: clonedResponse.statusText,
+          body:
+            clonedResponse.body === null ? null : await clonedResponse.text(),
+          headers: Object.fromEntries(clonedResponse.headers.entries()),
+          redirected: clonedResponse.redirected,
+        },
+      })
+    })()
+  }
+
+  return response
+}
+
+// Resolve the main client for the given event.
+// Client that issues a request doesn't necessarily equal the client
+// that registered the worker. It's with the latter the worker should
+// communicate with during the response resolving phase.
+async function resolveMainClient(event) {
+  const client = await self.clients.get(event.clientId)
+
+  if (client?.frameType === 'top-level') {
+    return client
+  }
+
+  const allClients = await self.clients.matchAll({
+    type: 'window',
+  })
+
+  return allClients
+    .filter((client) => {
+      // Get only those clients that are currently visible.
+      return client.visibilityState === 'visible'
+    })
+    .find((client) => {
+      // Find the client ID that's recorded in the
+      // set of clients that have registered the worker.
+      return activeClientIds.has(client.id)
+    })
+}
+
+async function getResponse(event, client, requestId) {
+  const { request } = event
+  const clonedRequest = request.clone()
+
+  function passthrough() {
+    // Clone the request because it might've been already used
+    // (i.e. its body has been read and sent to the client).
+    const headers = Object.fromEntries(clonedRequest.headers.entries())
+
+    // Remove MSW-specific request headers so the bypassed requests
+    // comply with the server's CORS preflight check.
+    // Operate with the headers as an object because request "Headers"
+    // are immutable.
+    delete headers['x-msw-bypass']
+
+    return fetch(clonedRequest, { headers })
+  }
+
+  // Bypass mocking when the client is not active.
+  if (!client) {
+    return passthrough()
+  }
+
+  // Bypass initial page load requests (i.e. static assets).
+  // The absence of the immediate/parent client in the map of the active clients
+  // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
+  // and is not ready to handle requests.
+  if (!activeClientIds.has(client.id)) {
+    return passthrough()
+  }
+
+  // Bypass requests with the explicit bypass header.
+  // Such requests can be issued by "ctx.fetch()".
+  if (request.headers.get('x-msw-bypass') === 'true') {
+    return passthrough()
+  }
+
+  // Notify the client that a request has been intercepted.
+  const clientMessage = await sendToClient(client, {
+    type: 'REQUEST',
+    payload: {
+      id: requestId,
+      url: request.url,
+      method: request.method,
+      headers: Object.fromEntries(request.headers.entries()),
+      cache: request.cache,
+      mode: request.mode,
+      credentials: request.credentials,
+      destination: request.destination,
+      integrity: request.integrity,
+      redirect: request.redirect,
+      referrer: request.referrer,
+      referrerPolicy: request.referrerPolicy,
+      body: await request.text(),
+      bodyUsed: request.bodyUsed,
+      keepalive: request.keepalive,
+    },
+  })
+
+  switch (clientMessage.type) {
+    case 'MOCK_RESPONSE': {
+      return respondWithMock(clientMessage.data)
+    }
+
+    case 'MOCK_NOT_FOUND': {
+      return passthrough()
+    }
+
+    case 'NETWORK_ERROR': {
+      const { name, message } = clientMessage.data
+      const networkError = new Error(message)
+      networkError.name = name
+
+      // Rejecting a "respondWith" promise emulates a network error.
+      throw networkError
+    }
+  }
+
+  return passthrough()
+}
+
+function sendToClient(client, message) {
+  return new Promise((resolve, reject) => {
+    const channel = new MessageChannel()
+
+    channel.port1.onmessage = (event) => {
+      if (event.data && event.data.error) {
+        return reject(event.data.error)
+      }
+
+      resolve(event.data)
+    }
+
+    client.postMessage(message, [channel.port2])
+  })
+}
+
+function sleep(timeMs) {
+  return new Promise((resolve) => {
+    setTimeout(resolve, timeMs)
+  })
+}
+
+async function respondWithMock(response) {
+  await sleep(response.delay)
+  return new Response(response.body, response)
+}

+ 5 - 1
packages/dashboard/src/components/AppLogo/AppLogo.tsx

@@ -4,7 +4,11 @@ import { getUrl } from '../../core/helpers/url-helpers';
 import styles from './AppLogo.module.scss';
 
 export const AppLogo: React.FC<{ id?: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
-  const logoUrl = id ? `/api/apps/${id}/metadata/logo.jpg` : getUrl('placeholder.png');
+  let logoUrl = id ? `/api/apps/${id}/metadata/logo.jpg` : getUrl('placeholder.png');
+
+  if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') {
+    logoUrl = getUrl('placeholder.png');
+  }
 
   return (
     <div aria-label={alt} className={clsx(styles.dropShadow, className)} style={{ width: size, height: size }}>

+ 0 - 0
packages/dashboard/src/components/AppLogo/index.tsx → packages/dashboard/src/components/AppLogo/index.ts


+ 1 - 1
packages/dashboard/src/components/AppTile/AppTile.tsx

@@ -10,7 +10,7 @@ import styles from './AppTile.module.scss';
 type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;
 
 export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => (
-  <div className="col-sm-6 col-lg-4">
+  <div data-testid={`app-tile-${app.id}`} className="col-sm-6 col-lg-4">
     <div className="card card-sm card-link">
       <Link href={`/apps/${app.id}`} className="nav-link" passHref>
         <div className="card-body">

+ 11 - 5
packages/dashboard/src/components/Layout/Layout.tsx

@@ -3,7 +3,8 @@ import Link from 'next/link';
 import React, { useEffect } from 'react';
 import clsx from 'clsx';
 import ReactTooltip from 'react-tooltip';
-import { useRefreshTokenQuery } from '../../generated/graphql';
+import semver from 'semver';
+import { useRefreshTokenQuery, useVersionQuery } from '../../generated/graphql';
 import { Header } from '../ui/Header';
 import styles from './Layout.module.scss';
 
@@ -17,6 +18,9 @@ interface IProps {
 
 export const Layout: React.FC<IProps> = ({ children, breadcrumbs, title, actions }) => {
   const { data } = useRefreshTokenQuery({ fetchPolicy: 'network-only' });
+  const { data: dataVersion } = useVersionQuery({ nextFetchPolicy: 'network-only' });
+  const defaultVersion = '0.0.0';
+  const isLatest = semver.gte(dataVersion?.version.current || defaultVersion, dataVersion?.version.latest || defaultVersion);
 
   useEffect(() => {
     if (data?.refreshToken?.token) {
@@ -32,8 +36,10 @@ export const Layout: React.FC<IProps> = ({ children, breadcrumbs, title, actions
     return (
       <ol className="breadcrumb" aria-label="breadcrumbs">
         {breadcrumbs.map((breadcrumb) => (
-          <li key={breadcrumb.name} className={clsx('breadcrumb-item', { active: breadcrumb.current })}>
-            <Link href={breadcrumb.href}>{breadcrumb.name}</Link>
+          <li key={breadcrumb.name} data-testid="breadcrumb-item" className={clsx('breadcrumb-item', { active: breadcrumb.current })}>
+            <Link data-testid="breadcrumb-link" href={breadcrumb.href}>
+              {breadcrumb.name}
+            </Link>
           </li>
         ))}
       </ol>
@@ -41,12 +47,12 @@ export const Layout: React.FC<IProps> = ({ children, breadcrumbs, title, actions
   };
 
   return (
-    <div className="page">
+    <div data-testid={`${title?.toLowerCase().split(' ').join('-')}-layout`} className="page">
       <Head>
         <title>{title} - Tipi</title>
       </Head>
       <ReactTooltip offset={{ right: 3 }} effect="solid" place="bottom" />
-      <Header />
+      <Header isUpdateAvailable={!isLatest} />
       <div className="page-wrapper">
         <div className="page-header d-print-none">
           <div className="container-xl">

+ 11 - 1
packages/dashboard/src/components/StatusScreen/StatusScreen.tsx

@@ -10,7 +10,17 @@ interface IProps {
 export const StatusScreen: React.FC<IProps> = ({ title, subtitle }) => (
   <div className="page page-center">
     <div className="container container-tight py-4 d-flex align-items-center flex-column">
-      <Image alt="Tipi log" className="mb-3" layout="intrinsic" src={getUrl('tipi.png')} height={50} width={50} />
+      <Image
+        alt="Tipi log"
+        className="mb-3"
+        src={getUrl('tipi.png')}
+        height={50}
+        width={50}
+        style={{
+          maxWidth: '100%',
+          height: 'auto',
+        }}
+      />
       <h1 className="text-center mb-1">{title}</h1>
       <div className="text-center text-muted mb-3">{subtitle}</div>
       <div className="spinner-border spinner-border-sm text-muted" />

+ 42 - 0
packages/dashboard/src/components/hoc/AuthProvider/AuthProvider.test.tsx

@@ -0,0 +1,42 @@
+import { graphql } from 'msw';
+import React from 'react';
+import { render, screen, waitFor } from '../../../../tests/test-utils';
+import { server } from '../../../mocks/server';
+import { AuthProvider } from './AuthProvider';
+
+describe('Test: AuthProvider', () => {
+  it('should render login form if user is not logged in', async () => {
+    render(
+      <AuthProvider>
+        <div>Should not render</div>
+      </AuthProvider>,
+    );
+    await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
+    expect(screen.queryByText('Should not render')).not.toBeInTheDocument();
+  });
+
+  it('should render children if user is logged in', async () => {
+    server.use(graphql.query('Me', (req, res, ctx) => res(ctx.data({ me: { id: '1' } }))));
+
+    render(
+      <AuthProvider>
+        <div>Should render</div>
+      </AuthProvider>,
+    );
+
+    await waitFor(() => expect(screen.getByText('Should render')).toBeInTheDocument());
+  });
+
+  it('should render register form if app is not configured', async () => {
+    server.use(graphql.query('Configured', (req, res, ctx) => res(ctx.data({ isConfigured: false }))));
+
+    render(
+      <AuthProvider>
+        <div>Should not render</div>
+      </AuthProvider>,
+    );
+
+    await waitFor(() => expect(screen.getByText('Register')).toBeInTheDocument());
+    expect(screen.queryByText('Should not render')).not.toBeInTheDocument();
+  });
+});

+ 74 - 0
packages/dashboard/src/components/hoc/StatusProvider/StatusProvider.test.tsx

@@ -0,0 +1,74 @@
+import { rest } from 'msw';
+import React from 'react';
+import { render, screen, waitFor } from '../../../../tests/test-utils';
+import { server } from '../../../mocks/server';
+import { StatusProvider } from './StatusProvider';
+
+const reloadFn = jest.fn();
+
+jest.mock('next/router', () => {
+  const actualRouter = jest.requireActual('next-router-mock');
+
+  return {
+    ...actualRouter,
+    reload: () => reloadFn(),
+  };
+});
+
+describe('Test: StatusProvider', () => {
+  it("should render it's children when system is RUNNING", async () => {
+    render(
+      <StatusProvider>
+        <div>system running</div>
+      </StatusProvider>,
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('system running')).toBeInTheDocument();
+    });
+  });
+
+  it('should render StatusScreen when system is RESTARTING', async () => {
+    server.use(rest.get('/api/status', (req, res, ctx) => res(ctx.delay(200), ctx.status(200), ctx.json({ status: 'RESTARTING' }))));
+    render(
+      <StatusProvider>
+        <div>system running</div>
+      </StatusProvider>,
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Your system is restarting...')).toBeInTheDocument();
+    });
+  });
+
+  it('should render StatusScreen when system is UPDATING', async () => {
+    server.use(rest.get('/api/status', (req, res, ctx) => res(ctx.delay(200), ctx.status(200), ctx.json({ status: 'UPDATING' }))));
+    render(
+      <StatusProvider>
+        <div>system running</div>
+      </StatusProvider>,
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Your system is updating...')).toBeInTheDocument();
+    });
+  });
+
+  it('should reload the page when system is RUNNING after being something else than RUNNING', async () => {
+    server.use(rest.get('/api/status', (req, res, ctx) => res(ctx.delay(200), ctx.status(200), ctx.json({ status: 'UPDATING' }))));
+    render(
+      <StatusProvider>
+        <div>system running</div>
+      </StatusProvider>,
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Your system is updating...')).toBeInTheDocument();
+    });
+
+    server.use(rest.get('/api/status', (req, res, ctx) => res(ctx.delay(200), ctx.status(200), ctx.json({ status: 'RUNNING' }))));
+    await waitFor(() => {
+      expect(reloadFn).toHaveBeenCalled();
+    });
+  });
+});

+ 7 - 2
packages/dashboard/src/components/hoc/StatusProvider/StatusProvider.tsx

@@ -1,5 +1,6 @@
 import React, { ReactElement, useEffect, useState } from 'react';
 import useSWR from 'swr';
+import router from 'next/router';
 import { SystemStatus } from '../../../state/systemStore';
 import { StatusScreen } from '../../StatusScreen';
 
@@ -11,12 +12,12 @@ const fetcher = (url: string) => fetch(url).then((res) => res.json());
 
 export const StatusProvider: React.FC<IProps> = ({ children }) => {
   const [s, setS] = useState<SystemStatus>(SystemStatus.RUNNING);
-  const { data } = useSWR<{ status: SystemStatus }>('/api/status', fetcher, { refreshInterval: 1000 });
+  const { data, isValidating } = useSWR<{ status: SystemStatus }>('/api/status', fetcher, { refreshInterval: 1000 });
 
   useEffect(() => {
     // If previous was not running and current is running, we need to refresh the page
     if (data?.status === SystemStatus.RUNNING && s !== SystemStatus.RUNNING) {
-      window.location.reload();
+      router.reload();
     }
 
     if (data?.status === SystemStatus.RUNNING) {
@@ -30,6 +31,10 @@ export const StatusProvider: React.FC<IProps> = ({ children }) => {
     }
   }, [data?.status, s]);
 
+  if (isValidating && !data?.status) {
+    return <StatusScreen title="" subtitle="" />;
+  }
+
   if (s === SystemStatus.RESTARTING) {
     return <StatusScreen title="Your system is restarting..." subtitle="Please do not refresh this page" />;
   }

+ 70 - 0
packages/dashboard/src/components/hoc/ToastProvider/ToastProvider.test.tsx

@@ -0,0 +1,70 @@
+import React from 'react';
+import { act, render, renderHook, screen, waitFor } from '../../../../tests/test-utils';
+import { useToastStore } from '../../../state/toastStore';
+import { ToastProvider } from './ToastProvider';
+
+describe('Test: ToastProvider', () => {
+  it("should render it's children", async () => {
+    render(
+      <ToastProvider>
+        <div>children</div>
+      </ToastProvider>,
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('children')).toBeInTheDocument();
+    });
+  });
+
+  it('should render Toasts', async () => {
+    render(
+      <ToastProvider>
+        <div>children</div>
+      </ToastProvider>,
+    );
+    const { result } = renderHook(() => useToastStore());
+
+    act(() => {
+      result.current.addToast({
+        status: 'success',
+        title: 'title',
+        description: 'description',
+        id: 'id',
+      });
+    });
+
+    await waitFor(() => {
+      expect(screen.getByText('title')).toBeInTheDocument();
+    });
+  });
+
+  it('should remove Toasts when the close button is clicked', async () => {
+    render(
+      <ToastProvider>
+        <div>children</div>
+      </ToastProvider>,
+    );
+    const { result } = renderHook(() => useToastStore());
+
+    act(() => {
+      result.current.addToast({
+        status: 'success',
+        title: 'title',
+        description: 'description',
+        id: 'id',
+      });
+    });
+
+    await waitFor(() => {
+      expect(screen.getByText('title')).toBeInTheDocument();
+    });
+
+    act(() => {
+      screen.getByTestId('toast-close-button').click();
+    });
+
+    await waitFor(() => {
+      expect(screen.queryByText('title')).not.toBeInTheDocument();
+    });
+  });
+});

+ 50 - 0
packages/dashboard/src/components/ui/Button/Button.test.tsx

@@ -0,0 +1,50 @@
+import React from 'react';
+import { render, fireEvent, cleanup } from '@testing-library/react';
+import '@testing-library/jest-dom/extend-expect';
+import { Button } from './Button';
+
+afterEach(cleanup);
+
+describe('Button component', () => {
+  it('should render without crashing', () => {
+    const { container } = render(<Button>Click me</Button>);
+    expect(container).toBeTruthy();
+  });
+
+  it('should render children correctly', () => {
+    const { getByText } = render(<Button>Click me</Button>);
+    expect(getByText('Click me')).toBeInTheDocument();
+  });
+
+  it('should apply className prop correctly', () => {
+    const { container } = render(<Button className="test-class">Click me</Button>);
+    expect(container.querySelector('button')).toHaveClass('test-class');
+  });
+
+  it('should render spinner when loading prop is true', () => {
+    const { container } = render(<Button loading>Click me</Button>);
+    expect(container.querySelector('.spinner-border')).toBeInTheDocument();
+  });
+
+  it('should disable button when disabled prop is true', () => {
+    const { container } = render(<Button disabled>Click me</Button>);
+    expect(container.querySelector('button')).toBeDisabled();
+  });
+
+  it('should set type correctly', () => {
+    const { container } = render(<Button type="submit">Click me</Button>);
+    expect(container.querySelector('button')).toHaveAttribute('type', 'submit');
+  });
+
+  it('should applies width correctly', () => {
+    const { container } = render(<Button width={100}>Click me</Button>);
+    expect(container.querySelector('button')).toHaveStyle('width: 100px');
+  });
+
+  it('should call onClick callback when clicked', () => {
+    const onClick = jest.fn();
+    const { container } = render(<Button onClick={onClick}>Click me</Button>);
+    fireEvent.click(container.querySelector('button') as HTMLButtonElement);
+    expect(onClick).toHaveBeenCalled();
+  });
+});

+ 33 - 0
packages/dashboard/src/components/ui/DataGrid/DataGrid.test.tsx

@@ -0,0 +1,33 @@
+import React from 'react';
+import { render } from '../../../../tests/test-utils';
+import { DataGrid } from './DataGrid';
+import { DataGridItem } from './DataGridItem';
+
+describe('DataGrid', () => {
+  it('renders its children', () => {
+    const { getByText } = render(
+      <DataGrid>
+        <p>Test child</p>
+      </DataGrid>,
+    );
+
+    expect(getByText('Test child')).toBeInTheDocument();
+  });
+});
+
+describe('DataGridItem', () => {
+  it('renders its children', () => {
+    const { getByText } = render(
+      <DataGridItem title="">
+        <p>Test child</p>
+      </DataGridItem>,
+    );
+
+    expect(getByText('Test child')).toBeInTheDocument();
+  });
+
+  it('renders the correct title', () => {
+    const { getByText } = render(<DataGridItem title="Test Title">Hello</DataGridItem>);
+    expect(getByText('Test Title')).toBeInTheDocument();
+  });
+});

+ 0 - 0
packages/dashboard/src/components/ui/DataGrid/index.tsx → packages/dashboard/src/components/ui/DataGrid/index.ts


+ 28 - 0
packages/dashboard/src/components/ui/EmptyPage/EmptyPage.test.tsx

@@ -0,0 +1,28 @@
+import React from 'react';
+import { fireEvent, render } from '../../../../tests/test-utils';
+import { EmptyPage } from './EmptyPage';
+
+describe('<EmptyPage />', () => {
+  it('should render the title and subtitle', () => {
+    const { getByText } = render(<EmptyPage title="Title" subtitle="Subtitle" />);
+
+    expect(getByText('Title')).toBeInTheDocument();
+    expect(getByText('Subtitle')).toBeInTheDocument();
+  });
+
+  it('should render the action button and trigger the onAction callback', () => {
+    const onAction = jest.fn();
+    const { getByText } = render(<EmptyPage title="Title" onAction={onAction} actionLabel="Action" />);
+
+    expect(getByText('Action')).toBeInTheDocument();
+
+    fireEvent.click(getByText('Action'));
+    expect(onAction).toHaveBeenCalled();
+  });
+
+  it('should not render the action button if onAction is not provided', () => {
+    const { queryByText } = render(<EmptyPage title="Title" actionLabel="Action" />);
+
+    expect(queryByText('Action')).not.toBeInTheDocument();
+  });
+});

+ 13 - 3
packages/dashboard/src/components/ui/EmptyPage/EmptyPage.tsx

@@ -12,13 +12,23 @@ interface IProps {
 }
 
 export const EmptyPage: React.FC<IProps> = ({ title, subtitle, onAction, actionLabel }) => (
-  <div className="card empty">
-    <Image src={getUrl('empty.svg')} alt="Empty box" height="80" width="80" className={styles.emptyImage} />
+  <div data-testid="empty-page" className="card empty">
+    <Image
+      src={getUrl('empty.svg')}
+      alt="Empty box"
+      height="80"
+      width="80"
+      className={styles.emptyImage}
+      style={{
+        maxWidth: '100%',
+        height: 'auto',
+      }}
+    />
     <p className="empty-title">{title}</p>
     <p className="empty-subtitle text-muted">{subtitle}</p>
     <div className="empty-action">
       {onAction && (
-        <Button onClick={onAction} className="btn-primary">
+        <Button data-testid="empty-page-action" onClick={onAction} className="btn-primary">
           {actionLabel}
         </Button>
       )}

+ 4 - 0
packages/dashboard/src/components/ui/ErrorPage/ErrorPage.module.scss

@@ -0,0 +1,4 @@
+.emptyImage {
+  height: 50px;
+  width: 50px;
+}

+ 34 - 0
packages/dashboard/src/components/ui/ErrorPage/ErrorPage.test.tsx

@@ -0,0 +1,34 @@
+import React from 'react';
+import { fireEvent, render, screen } from '../../../../tests/test-utils';
+import { ErrorPage } from './ErrorPage';
+
+describe('ErrorPage', () => {
+  it('should render the error message', () => {
+    const errorMessage = 'There was an error';
+    render(<ErrorPage error={errorMessage} />);
+
+    expect(screen.getByText(errorMessage)).toBeInTheDocument();
+  });
+
+  it('should render the retry button when onRetry is provided', () => {
+    const onRetry = jest.fn();
+    render(<ErrorPage onRetry={onRetry} />);
+
+    expect(screen.getByTestId('error-page-action')).toBeInTheDocument();
+  });
+
+  it('should not render the retry button when onRetry is not provided', () => {
+    render(<ErrorPage />);
+
+    expect(screen.queryByTestId('error-page-action')).not.toBeInTheDocument();
+  });
+
+  it('should call the onRetry callback when the retry button is clicked', () => {
+    const onRetry = jest.fn();
+    render(<ErrorPage onRetry={onRetry} />);
+
+    fireEvent.click(screen.getByTestId('error-page-action'));
+
+    expect(onRetry).toHaveBeenCalledTimes(1);
+  });
+});

+ 39 - 0
packages/dashboard/src/components/ui/ErrorPage/ErrorPage.tsx

@@ -0,0 +1,39 @@
+import { IconRotateClockwise } from '@tabler/icons';
+import clsx from 'clsx';
+import Image from 'next/image';
+import React from 'react';
+import { getUrl } from '../../../core/helpers/url-helpers';
+import { Button } from '../Button';
+import styles from './ErrorPage.module.scss';
+
+interface IProps {
+  error?: string;
+  onRetry?: () => void;
+  actionLabel?: string;
+}
+
+export const ErrorPage: React.FC<IProps> = ({ error, onRetry }) => (
+  <div data-testid="error-page" className="card empty">
+    <Image
+      src={getUrl('error.png')}
+      alt="Empty box"
+      height="100"
+      width="100"
+      className={clsx(styles.emptyImage, 'mb-3 mt-2')}
+      style={{
+        maxWidth: '100%',
+        height: 'auto',
+      }}
+    />
+    <p className="empty-title">An error occured</p>
+    <p className="empty-subtitle text-muted">{error}</p>
+    <div className="empty-action">
+      {onRetry && (
+        <Button data-testid="error-page-action" onClick={onRetry} className="btn-danger">
+          <IconRotateClockwise />
+          Retry
+        </Button>
+      )}
+    </div>
+  </div>
+);

+ 1 - 0
packages/dashboard/src/components/ui/ErrorPage/index.ts

@@ -0,0 +1 @@
+export { ErrorPage } from './ErrorPage';

+ 81 - 0
packages/dashboard/src/components/ui/Header/Header.test.tsx

@@ -0,0 +1,81 @@
+import React from 'react';
+import { fireEvent, render, renderHook, screen } from '../../../../tests/test-utils';
+import { useUIStore } from '../../../state/uiStore';
+import { Header } from './Header';
+
+const logoutFn = jest.fn();
+const reloadFn = jest.fn();
+
+jest.mock('../../../generated/graphql', () => ({
+  useLogoutMutation: () => [logoutFn],
+}));
+
+jest.mock('next/router', () => {
+  const actualRouter = jest.requireActual('next-router-mock');
+
+  return {
+    ...actualRouter,
+    reload: () => reloadFn(),
+  };
+});
+
+describe('Header', () => {
+  it('renders without crashing', () => {
+    const { container } = render(<Header />);
+    expect(container).toBeInTheDocument();
+  });
+
+  it('renders the brand logo', () => {
+    const { container } = render(<Header />);
+    expect(container).toHaveTextContent('Tipi');
+    expect(container).toContainElement(screen.getByAltText('Tipi logo'));
+  });
+
+  it('renders the dark mode toggle', () => {
+    const { container } = render(<Header />);
+    const darkModeToggle = container.querySelector('[data-tip="Dark mode"]');
+    expect(darkModeToggle).toContainElement(screen.getByTestId('icon-moon'));
+  });
+
+  it('renders the light mode toggle', () => {
+    const { container } = render(<Header />);
+    const lightModeToggle = container.querySelector('[data-tip="Light mode"]');
+    expect(lightModeToggle).toContainElement(screen.getByTestId('icon-sun'));
+  });
+
+  it('Should toggle the dark mode on click of the dark mode toggle', () => {
+    const { result } = renderHook(() => useUIStore());
+
+    const { container } = render(<Header />);
+    const darkModeToggle = container.querySelector('[data-tip="Dark mode"]');
+    fireEvent.click(darkModeToggle as Element);
+
+    expect(result.current.darkMode).toBe(true);
+  });
+
+  it('Should toggle the dark mode on click of the light mode toggle', () => {
+    const { result } = renderHook(() => useUIStore());
+
+    const { container } = render(<Header />);
+    const lightModeToggle = container.querySelector('[data-tip="Light mode"]');
+    fireEvent.click(lightModeToggle as Element);
+
+    expect(result.current.darkMode).toBe(false);
+  });
+
+  it('Should call the logout mutation on logout', () => {
+    const { container } = render(<Header />);
+    const logoutButton = container.querySelector('[data-tip="Log out"]');
+    fireEvent.click(logoutButton as Element);
+
+    expect(logoutFn).toHaveBeenCalled();
+  });
+
+  it('Should reload the page with next/router on logout', () => {
+    const { container } = render(<Header />);
+    const logoutButton = container.querySelector('[data-tip="Log out"]');
+    fireEvent.click(logoutButton as Element);
+
+    expect(reloadFn).toHaveBeenCalledTimes(1);
+  });
+});

+ 24 - 9
packages/dashboard/src/components/ui/Header/Header.tsx

@@ -2,19 +2,24 @@ import React from 'react';
 import { IconBrandGithub, IconHeart, IconLogout, IconMoon, IconSun } from '@tabler/icons';
 import Image from 'next/image';
 import clsx from 'clsx';
+import router from 'next/router';
 import { getUrl } from '../../../core/helpers/url-helpers';
 import { useUIStore } from '../../../state/uiStore';
 import { NavBar } from '../NavBar';
 import { useLogoutMutation } from '../../../generated/graphql';
 
-export const Header: React.FC = () => {
+interface IProps {
+  isUpdateAvailable?: boolean;
+}
+
+export const Header: React.FC<IProps> = ({ isUpdateAvailable }) => {
   const { setDarkMode } = useUIStore();
   const [logout] = useLogoutMutation();
 
   const handleLogout = async () => {
     await logout();
     localStorage.removeItem('token');
-    window.location.reload();
+    router.reload();
   };
 
   return (
@@ -23,17 +28,27 @@ export const Header: React.FC = () => {
         <button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu">
           <span className="navbar-toggler-icon" />
         </button>
-        <a href="/dashboard">
+        <a href="/">
           <h1 className="navbar-brand d-none-navbar-horizontal pe-0 pe-md-3">
-            <Image alt="Tipi logo" className={clsx('navbar-brand-image me-3')} width={100} height={100} src={getUrl('tipi.png')} />
+            <Image
+              alt="Tipi logo"
+              className={clsx('navbar-brand-image me-3')}
+              width={100}
+              height={100}
+              src={getUrl('tipi.png')}
+              style={{
+                maxWidth: '30px',
+                height: 'auto',
+              }}
+            />
             Tipi
           </h1>
         </a>
         <div className="navbar-nav flex-row order-md-last">
-          <div className="nav-item d-none d-xl-flex me-3">
+          <div className="nav-item d-none d-lg-flex me-3">
             <div className="btn-list">
               <a href="https://github.com/meienberger/runtipi" target="_blank" rel="noreferrer" className="btn btn-dark">
-                <IconBrandGithub className="me-1 icon" size={24} />
+                <IconBrandGithub data-testid="icon-github" className="me-1 icon" size={24} />
                 Source code
               </a>
               <a href="https://github.com/meienberger/runtipi?sponsor=1" target="_blank" rel="noreferrer" className="btn btn-dark">
@@ -44,17 +59,17 @@ export const Header: React.FC = () => {
           </div>
           <div className="d-flex">
             <div onClick={() => setDarkMode(true)} role="button" aria-hidden="true" className="nav-link px-0 hide-theme-dark cursor-pointer" data-tip="Dark mode">
-              <IconMoon size={24} />
+              <IconMoon data-testid="icon-moon" size={24} />
             </div>
             <div onClick={() => setDarkMode(false)} aria-hidden="true" className="nav-link px-0 hide-theme-light cursor-pointer" data-tip="Light mode">
-              <IconSun size={24} />
+              <IconSun data-testid="icon-sun" size={24} />
             </div>
             <div onClick={handleLogout} tabIndex={0} onKeyPress={handleLogout} role="button" className="nav-link px-0 cursor-pointer" data-tip="Log out">
               <IconLogout size={24} />
             </div>
           </div>
         </div>
-        <NavBar />
+        <NavBar isUpdateAvailable={isUpdateAvailable} />
       </div>
     </header>
   );

+ 105 - 0
packages/dashboard/src/components/ui/Input/Input.test.tsx

@@ -0,0 +1,105 @@
+import React from 'react';
+import '@testing-library/jest-dom/extend-expect';
+import { Input } from './Input';
+import { fireEvent, render, waitFor } from '../../../../tests/test-utils';
+
+describe('Input', () => {
+  it('should render without errors', () => {
+    const { container } = render(<Input name="test-input" />);
+    expect(container).toBeTruthy();
+  });
+
+  it('should render the label if provided', () => {
+    const { getByLabelText } = render(<Input name="test-input" label="Test Label" />);
+    const input = getByLabelText('Test Label');
+    expect(input).toBeTruthy();
+  });
+
+  it('should render the placeholder if provided', () => {
+    const { getByPlaceholderText } = render(<Input name="test-input" placeholder="Test Placeholder" />);
+    const input = getByPlaceholderText('Test Placeholder');
+    expect(input).toBeTruthy();
+  });
+
+  it('should render the error message if provided', () => {
+    const { getByText } = render(<Input name="test-input" error="Test Error" />);
+    const error = getByText('Test Error');
+    expect(error).toBeTruthy();
+  });
+
+  it('should call onChange when the input value is changed', async () => {
+    const onChange = jest.fn();
+    const { getByLabelText } = render(<Input name="test-input" label="Test Label" onChange={onChange} />);
+    const input = getByLabelText('Test Label');
+    fireEvent.change(input, { target: { value: 'changed' } });
+    await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
+  });
+
+  it('should call onBlur when the input is blurred', async () => {
+    const onBlur = jest.fn();
+    const { getByLabelText } = render(<Input name="test-input" label="Test Label" onBlur={onBlur} />);
+    const input = getByLabelText('Test Label');
+    fireEvent.blur(input);
+    await waitFor(() => expect(onBlur).toHaveBeenCalledTimes(1));
+  });
+
+  it('should set the input type if provided', () => {
+    const { getByLabelText } = render(<Input name="test-input" label="Test Label" type="password" />);
+    const input = getByLabelText('Test Label') as HTMLInputElement;
+    expect(input.type).toBe('password');
+  });
+
+  it('should set the input value if provided', () => {
+    const { getByLabelText } = render(<Input name="test-input" label="Test Label" value="Test Value" onChange={jest.fn} />);
+    const input = getByLabelText('Test Label') as HTMLInputElement;
+    expect(input.value).toBe('Test Value');
+  });
+
+  it('should apply the className prop to the container div', () => {
+    const { container } = render(<Input name="test-input" className="test-class" />);
+    expect(container.firstChild).toHaveClass('test-class');
+  });
+
+  it('should apply the isInvalid prop to the input element', () => {
+    const { getByLabelText } = render(<Input name="test-input" label="Test Label" isInvalid />);
+    const input = getByLabelText('Test Label');
+    expect(input).toHaveClass('is-invalid', 'is-invalid-lite');
+  });
+
+  it('should apply the disabled prop to the input element', () => {
+    const { getByLabelText } = render(<Input name="test-input" label="Test Label" disabled />);
+    const input = getByLabelText('Test Label');
+    expect(input).toBeDisabled();
+  });
+
+  it('should set the input name attribute if provided', () => {
+    const { getByLabelText } = render(<Input name="test-input" label="Test Label" />);
+    const input = getByLabelText('Test Label');
+    expect(input).toHaveAttribute('name', 'test-input');
+  });
+
+  it('should set the input id attribute if provided', () => {
+    const { getByLabelText } = render(<Input name="test-input" label="Test Label" />);
+    const input = getByLabelText('Test Label');
+    expect(input).toHaveAttribute('id', 'test-input');
+  });
+
+  it('should set the input ref if provided', () => {
+    const ref = React.createRef<HTMLInputElement>();
+    const { getByLabelText } = render(<Input name="test-input" label="Test Label" ref={ref} />);
+    const input = getByLabelText('Test Label');
+    expect(input).toEqual(ref.current);
+  });
+
+  it('should set the input type attribute to "text" if not provided or if an invalid value is provided', () => {
+    const { getByLabelText } = render(<Input name="test-input" label="Test Label" />);
+    const input1 = getByLabelText('Test Label') as HTMLInputElement;
+    expect(input1.type).toBe('text');
+  });
+
+  it('should set the input placeholder attribute if provided', () => {
+    const { getByLabelText } = render(<Input name="test-input" label="Test Label" placeholder="Test Placeholder" />);
+    const input = getByLabelText('Test Label');
+    expect(input).toHaveAttribute('placeholder', 'Test Placeholder');
+  });
+});

+ 3 - 2
packages/dashboard/src/components/ui/Input/Input.tsx

@@ -15,7 +15,7 @@ interface IProps {
   value?: string;
 }
 
-export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, placeholder, error, type = 'text', className, value }, ref) => (
+export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, placeholder, error, type = 'text', className, value, isInvalid, disabled }, ref) => (
   <div className={clsx(className)}>
     {label && (
       <label htmlFor={name} className="form-label">
@@ -23,6 +23,7 @@ export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onB
       </label>
     )}
     <input
+      disabled={disabled}
       name={name}
       id={name}
       onBlur={onBlur}
@@ -30,7 +31,7 @@ export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onB
       value={value}
       type={type}
       ref={ref}
-      className={clsx('form-control', { 'is-invalid is-invalid-lite': error })}
+      className={clsx('form-control', { 'is-invalid is-invalid-lite': error || isInvalid })}
       placeholder={placeholder}
     />
     {error && <div className="invalid-feedback">{error}</div>}

+ 141 - 0
packages/dashboard/src/components/ui/Modal/Modal.test.tsx

@@ -0,0 +1,141 @@
+import React from 'react';
+import '@testing-library/jest-dom/extend-expect';
+import { fireEvent, render } from '../../../../tests/test-utils';
+import { Modal } from './Modal';
+import { ModalBody } from './ModalBody';
+import { ModalFooter } from './ModalFooter';
+import { ModalHeader } from './ModalHeader';
+
+describe('Modal component', () => {
+  it('should render without errors', () => {
+    const { container } = render(
+      <Modal onClose={() => {}}>
+        <p>Test modal content</p>
+      </Modal>,
+    );
+    expect(container).toBeTruthy();
+  });
+
+  it('should not be visible by default', () => {
+    const { queryByTestId } = render(
+      <Modal onClose={() => {}}>
+        <p>Test modal content</p>
+      </Modal>,
+    );
+    // display should be none
+    expect(queryByTestId('modal')).toHaveStyle('display: none');
+  });
+
+  it('should be visible when `isOpen` prop is true', () => {
+    const { getByTestId } = render(
+      <Modal onClose={() => {}} isOpen>
+        <p>Test modal content</p>
+      </Modal>,
+    );
+    // display should be block
+    expect(getByTestId('modal')).toHaveStyle('display: block');
+  });
+
+  it('should not be visible when `isOpen` prop is false', () => {
+    const { queryByTestId } = render(
+      <Modal onClose={() => {}}>
+        <p>Test modal content</p>
+      </Modal>,
+    );
+    expect(queryByTestId('modal')).toHaveStyle('display: none');
+  });
+
+  it('should call the `onClose` prop when the close button is clicked', () => {
+    const onClose = jest.fn();
+    const { getByLabelText } = render(
+      <Modal onClose={onClose} isOpen>
+        <p>Test modal content</p>
+      </Modal>,
+    );
+    fireEvent.click(getByLabelText('Close'));
+    expect(onClose).toHaveBeenCalled();
+  });
+
+  it('should call the `onClose` callback when user clicks outside of the modal', () => {
+    const onClose = jest.fn();
+    const { container } = render(
+      <Modal onClose={onClose} isOpen>
+        <p>Test modal content</p>
+      </Modal>,
+    );
+    fireEvent.click(container);
+    expect(onClose).toHaveBeenCalled();
+  });
+
+  it('should have the correct `size` class when the `size` prop is passed', () => {
+    const { getByTestId } = render(
+      <Modal onClose={() => {}} isOpen size="sm">
+        <p>Test modal content</p>
+      </Modal>,
+    );
+    expect(getByTestId('modal')).toHaveClass('modal-sm');
+  });
+
+  it('should have the correct `type` class when the `type` prop is passed', () => {
+    const { getByTestId } = render(
+      <Modal onClose={() => {}} isOpen type="primary">
+        <p>Test modal content</p>
+      </Modal>,
+    );
+    expect(getByTestId('modal-status')).toHaveClass('bg-primary');
+    expect(getByTestId('modal-status')).not.toHaveClass('d-none');
+  });
+
+  it('should render the modal content as a child of the modal', () => {
+    const { getByTestId, getByText } = render(
+      <Modal onClose={() => {}} isOpen>
+        <p>Test modal content</p>
+      </Modal>,
+    );
+    expect(getByTestId('modal')).toContainElement(getByText('Test modal content'));
+  });
+
+  it('should call the `onClose` callback when the escape key is pressed', () => {
+    const onClose = jest.fn();
+    render(
+      <Modal onClose={onClose} isOpen>
+        <p>Test modal content</p>
+      </Modal>,
+    );
+    fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' });
+    expect(onClose).toHaveBeenCalled();
+  });
+
+  it('should correctly render with ModalBody', () => {
+    const { getByTestId } = render(
+      <Modal onClose={() => {}} isOpen>
+        <ModalBody>
+          <p>Test modal content</p>
+        </ModalBody>
+      </Modal>,
+    );
+    expect(getByTestId('modal-body')).toBeInTheDocument();
+  });
+
+  it('should correctly render with ModalFooter', () => {
+    const { getByTestId } = render(
+      <Modal onClose={() => {}} isOpen>
+        <ModalFooter>
+          <p>Test modal content</p>
+        </ModalFooter>
+      </Modal>,
+    );
+    expect(getByTestId('modal-footer')).toBeInTheDocument();
+  });
+
+  it('should correctly render with ModalHeader', () => {
+    const { getByTestId } = render(
+      <Modal onClose={() => {}} isOpen>
+        <ModalHeader>
+          <p>Test modal content</p>
+        </ModalHeader>
+      </Modal>,
+    );
+    expect(getByTestId('modal-header')).toBeInTheDocument();
+  });
+});

+ 19 - 3
packages/dashboard/src/components/ui/Modal/Modal.tsx

@@ -35,12 +35,28 @@ export const Modal: React.FC<IProps> = ({ children, isOpen, onClose, size = 'lg'
     return () => document.removeEventListener('click', handleClickOutside, true);
   }, [handleClickOutside]);
 
+  // Close on escape
+  const handleEscape = useCallback(
+    (event: KeyboardEvent) => {
+      if (event.key === 'Escape') {
+        onClose();
+      }
+    },
+    [onClose],
+  );
+
+  // Close on escape
+  useEffect(() => {
+    document.addEventListener('keydown', handleEscape, true);
+    return () => document.removeEventListener('keydown', handleEscape, true);
+  }, [handleEscape]);
+
   return (
-    <div className={clsx('modal modal-sm', styles.dimmedBackground)} tabIndex={-1} style={style}>
+    <div data-testid="modal" className={clsx('modal modal-sm', styles.dimmedBackground)} tabIndex={-1} style={style} role="dialog">
       <div ref={setModal} className={clsx(`modal-dialog modal-dialog-centered modal-${size}`, styles.zoomIn)} role="document">
         <div className="shadow modal-content">
-          <button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close" onClick={onClose} />
-          <div className={clsx('modal-status', { [`bg-${type}`]: Boolean(type), 'd-none': !type })} />
+          <button data-testid="modal-close-button" type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close" onClick={onClose} />
+          <div data-testid="modal-status" className={clsx('modal-status', { [`bg-${type}`]: Boolean(type), 'd-none': !type })} />
           {children}
         </div>
       </div>

+ 5 - 1
packages/dashboard/src/components/ui/Modal/ModalBody.tsx

@@ -6,4 +6,8 @@ interface IProps {
   className?: string;
 }
 
-export const ModalBody: React.FC<IProps> = ({ children, className }) => <div className={clsx('modal-body', className)}>{children}</div>;
+export const ModalBody: React.FC<IProps> = ({ children, className }) => (
+  <div data-testid="modal-body" className={clsx('modal-body', className)}>
+    {children}
+  </div>
+);

+ 5 - 1
packages/dashboard/src/components/ui/Modal/ModalFooter.tsx

@@ -4,4 +4,8 @@ interface IProps {
   children: React.ReactNode;
 }
 
-export const ModalFooter: React.FC<IProps> = ({ children }) => <div className="modal-footer">{children}</div>;
+export const ModalFooter: React.FC<IProps> = ({ children }) => (
+  <div data-testid="modal-footer" className="modal-footer">
+    {children}
+  </div>
+);

+ 5 - 1
packages/dashboard/src/components/ui/Modal/ModalHeader.tsx

@@ -4,4 +4,8 @@ interface IProps {
   children: React.ReactNode;
 }
 
-export const ModalHeader: React.FC<IProps> = ({ children }) => <div className="modal-header">{children}</div>;
+export const ModalHeader: React.FC<IProps> = ({ children }) => (
+  <div data-testid="modal-header" className="modal-header">
+    {children}
+  </div>
+);

+ 50 - 0
packages/dashboard/src/components/ui/NavBar/NavBar.test.tsx

@@ -0,0 +1,50 @@
+import { useRouter } from 'next/router';
+import React from 'react';
+import { render } from '../../../../tests/test-utils';
+import { NavBar } from './NavBar';
+
+jest.mock('next/router', () => ({
+  useRouter: jest.fn(),
+}));
+
+describe('<NavBar />', () => {
+  beforeEach(() => {
+    (useRouter as jest.Mock).mockImplementation(() => ({
+      pathname: '/',
+    }));
+  });
+
+  it('should render the navbar items', () => {
+    const { getByText } = render(<NavBar isUpdateAvailable />);
+
+    expect(getByText('Dashboard')).toBeInTheDocument();
+    expect(getByText('My Apps')).toBeInTheDocument();
+    expect(getByText('App Store')).toBeInTheDocument();
+    expect(getByText('Settings')).toBeInTheDocument();
+  });
+
+  it('should highlight the active navbar item', () => {
+    (useRouter as jest.Mock).mockImplementation(() => ({
+      pathname: '/app-store',
+    }));
+
+    const { getByTestId } = render(<NavBar isUpdateAvailable />);
+    const activeItem = getByTestId('nav-item-app-store');
+    const inactiveItem = getByTestId('nav-item-settings');
+
+    expect(activeItem.classList.contains('active')).toBe(true);
+    expect(inactiveItem.classList.contains('active')).toBe(false);
+  });
+
+  it('should render the update available badge', () => {
+    const { getByText } = render(<NavBar isUpdateAvailable />);
+
+    expect(getByText('Update available')).toBeInTheDocument();
+  });
+
+  it('should not render the update available badge', () => {
+    const { queryByText } = render(<NavBar isUpdateAvailable={false} />);
+
+    expect(queryByText('Update available')).toBeNull();
+  });
+});

+ 7 - 8
packages/dashboard/src/components/ui/NavBar/NavBar.tsx

@@ -3,22 +3,21 @@ import clsx from 'clsx';
 import Link from 'next/link';
 import { useRouter } from 'next/router';
 import React from 'react';
-import semver from 'semver';
-import { useVersionQuery } from '../../../generated/graphql';
 
-export const NavBar: React.FC = () => {
-  const { data } = useVersionQuery();
+interface IProps {
+  isUpdateAvailable?: boolean;
+}
+
+export const NavBar: React.FC<IProps> = ({ isUpdateAvailable }) => {
   const router = useRouter();
   const path = router.pathname.split('/')[1];
-  const defaultVersion = '0.0.0';
-  const isLatest = semver.gte(data?.version.current || defaultVersion, data?.version.latest || defaultVersion);
 
   const renderItem = (title: string, name: string, Icon: TablerIcon) => {
     const isActive = path === name;
     const itemClass = clsx('nav-item', { active: isActive, 'border-primary': isActive, 'border-bottom-wide': isActive });
 
     return (
-      <li className={itemClass}>
+      <li data-testid={`nav-item-${name}`} className={itemClass}>
         <Link href={`/${name}`} className="nav-link" passHref>
           <span className="nav-link-icon d-md-none d-lg-inline-block">
             <Icon size={24} />
@@ -38,7 +37,7 @@ export const NavBar: React.FC = () => {
           {renderItem('App Store', 'app-store', IconBrandAppstore)}
           {renderItem('Settings', 'settings', IconSettings)}
         </ul>
-        {!isLatest && <span className="ms-2 badge bg-green d-none d-lg-block">Update available</span>}
+        {Boolean(isUpdateAvailable) && <span className="ms-2 badge bg-green d-none d-lg-block">Update available</span>}
       </div>
     </div>
   );

+ 59 - 0
packages/dashboard/src/components/ui/Switch/Switch.test.tsx

@@ -0,0 +1,59 @@
+import React from 'react';
+
+import '@testing-library/jest-dom/extend-expect';
+
+import { Switch } from './Switch';
+import { fireEvent, render } from '../../../../tests/test-utils';
+
+describe('Switch', () => {
+  it('renders the label', () => {
+    const label = 'Test Label';
+    const { getByText } = render(<Switch label={label} />);
+
+    expect(getByText(label)).toBeInTheDocument();
+  });
+
+  it('renders the className', () => {
+    const className = 'test-class';
+    const { container } = render(<Switch className={className} />);
+    const switchContainer = container.querySelector('.test-class');
+
+    expect(switchContainer).toBeInTheDocument();
+  });
+
+  it('renders the checked state', () => {
+    const { container } = render(<Switch checked onChange={jest.fn} />);
+    const checkbox = container.querySelector('input[type="checkbox"]');
+
+    expect(checkbox).toBeChecked();
+  });
+
+  it('triggers onChange event when clicked', () => {
+    const onChange = jest.fn();
+    const { container } = render(<Switch onChange={onChange} />);
+    const checkbox = container.querySelector('input[type="checkbox"]') as Element;
+
+    fireEvent.click(checkbox);
+
+    expect(onChange).toHaveBeenCalled();
+  });
+
+  it('triggers onBlur event when blurred', () => {
+    const onBlur = jest.fn();
+    const { container } = render(<Switch onBlur={onBlur} />);
+    const checkbox = container.querySelector('input[type="checkbox"]') as Element;
+
+    fireEvent.blur(checkbox);
+
+    expect(onBlur).toHaveBeenCalled();
+  });
+
+  it('should change the checked state when clicked', () => {
+    const { container } = render(<Switch onChange={jest.fn} />);
+    const checkbox = container.querySelector('input[type="checkbox"]') as Element;
+
+    fireEvent.click(checkbox);
+
+    expect(checkbox).toBeChecked();
+  });
+});

+ 2 - 2
packages/dashboard/src/components/ui/Switch/Switch.tsx

@@ -11,8 +11,8 @@ interface IProps {
 
 export const Switch = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, checked, className }, ref) => (
   <div className={className}>
-    <label htmlFor={`switch-${name}`} className="form-check form-switch">
-      <input name={name} ref={ref} onChange={onChange} onBlur={onBlur} className="form-check-input" type="checkbox" checked={checked} />
+    <label htmlFor={name} aria-labelledby={name} className="form-check form-switch">
+      <input id={name} name={name} ref={ref} onChange={onChange} onBlur={onBlur} className="form-check-input" type="checkbox" checked={checked} />
       <span className="form-check-label">{label}</span>
     </label>
   </div>

+ 34 - 0
packages/dashboard/src/components/ui/Toast/Toast.test.tsx

@@ -0,0 +1,34 @@
+import React from 'react';
+import { fireEvent, render } from '../../../../tests/test-utils';
+import { Toast } from './Toast';
+
+describe('Toast', () => {
+  it('renders the correct title', () => {
+    const { getByText } = render(<Toast id="toast-1" title="Test Title" onClose={jest.fn} status="info" />);
+
+    expect(getByText('Test Title')).toBeInTheDocument();
+  });
+
+  it('renders the correct message', () => {
+    const { getByText } = render(<Toast id="toast-1" title="Test Title" message="Test message" onClose={jest.fn} status="info" />);
+
+    expect(getByText('Test message')).toBeInTheDocument();
+  });
+
+  it('renders the correct status', () => {
+    const { container } = render(<Toast id="toast-1" title="Test Title" status="success" onClose={jest.fn} />);
+    const toastElement = container.querySelector('.tipi-toast');
+
+    expect(toastElement).toHaveClass('alert-success');
+  });
+
+  it('calls the correct function when the close button is clicked', () => {
+    const onCloseMock = jest.fn();
+    const { getByLabelText } = render(<Toast id="toast-1" title="Test Title" onClose={onCloseMock} status="info" />);
+    const closeButton = getByLabelText('close');
+
+    fireEvent.click(closeButton);
+
+    expect(onCloseMock).toHaveBeenCalled();
+  });
+});

+ 1 - 1
packages/dashboard/src/components/ui/Toast/Toast.tsx

@@ -46,6 +46,6 @@ export const Toast: React.FC<IProps> = ({ status, onClose, title, message, id })
         {message && <div className="text-white">{message}</div>}
       </div>
     </div>
-    <button onClick={onClose} className="btn-close btn-close-white" data-bs-dismiss="alert" aria-label="close" />
+    <button onClick={onClose} data-testid="toast-close-button" className="btn-close btn-close-white" data-bs-dismiss="alert" aria-label="close" />
   </div>
 );

+ 1 - 5
packages/dashboard/src/core/helpers/url-helpers.ts

@@ -1,5 +1 @@
-export const getUrl = (url: string) => {
-  const prefix = 'dashboard';
-
-  return `/${prefix}/${url}`;
-};
+export const getUrl = (url: string) => `/${url}`;

+ 0 - 6
packages/dashboard/src/core/types.ts

@@ -1,9 +1,3 @@
-export enum RequestStatus {
-  SUCCESS = 'SUCCESS',
-  ERROR = 'ERROR',
-  LOADING = 'LOADING',
-}
-
 export interface IUser {
   name: string;
   email: string;

+ 282 - 232
packages/dashboard/src/generated/graphql.tsx

@@ -155,42 +155,34 @@ export type Mutation = {
   updateAppConfig: App;
 };
 
-
 export type MutationInstallAppArgs = {
   input: AppInputType;
 };
 
-
 export type MutationLoginArgs = {
   input: UsernamePasswordInput;
 };
 
-
 export type MutationRegisterArgs = {
   input: UsernamePasswordInput;
 };
 
-
 export type MutationStartAppArgs = {
   id: Scalars['String'];
 };
 
-
 export type MutationStopAppArgs = {
   id: Scalars['String'];
 };
 
-
 export type MutationUninstallAppArgs = {
   id: Scalars['String'];
 };
 
-
 export type MutationUpdateAppArgs = {
   id: Scalars['String'];
 };
 
-
 export type MutationUpdateAppConfigArgs = {
   input: AppInputType;
 };
@@ -207,7 +199,6 @@ export type Query = {
   version: VersionResponse;
 };
 
-
 export type QueryGetAppArgs = {
   id: Scalars['String'];
 };
@@ -254,125 +245,184 @@ export type InstallAppMutationVariables = Exact<{
   input: AppInputType;
 }>;
 
-
-export type InstallAppMutation = { __typename?: 'Mutation', installApp: { __typename: 'App', id: string, status: AppStatusEnum } };
+export type InstallAppMutation = { __typename?: 'Mutation'; installApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 export type LoginMutationVariables = Exact<{
   input: UsernamePasswordInput;
 }>;
 
+export type LoginMutation = { __typename?: 'Mutation'; login: { __typename?: 'TokenResponse'; token: string } };
 
-export type LoginMutation = { __typename?: 'Mutation', login: { __typename?: 'TokenResponse', token: string } };
+export type LogoutMutationVariables = Exact<{ [key: string]: never }>;
 
-export type LogoutMutationVariables = Exact<{ [key: string]: never; }>;
-
-
-export type LogoutMutation = { __typename?: 'Mutation', logout: boolean };
+export type LogoutMutation = { __typename?: 'Mutation'; logout: boolean };
 
 export type RegisterMutationVariables = Exact<{
   input: UsernamePasswordInput;
 }>;
 
+export type RegisterMutation = { __typename?: 'Mutation'; register: { __typename?: 'TokenResponse'; token: string } };
 
-export type RegisterMutation = { __typename?: 'Mutation', register: { __typename?: 'TokenResponse', token: string } };
-
-export type RestartMutationVariables = Exact<{ [key: string]: never; }>;
+export type RestartMutationVariables = Exact<{ [key: string]: never }>;
 
-
-export type RestartMutation = { __typename?: 'Mutation', restart: boolean };
+export type RestartMutation = { __typename?: 'Mutation'; restart: boolean };
 
 export type StartAppMutationVariables = Exact<{
   id: Scalars['String'];
 }>;
 
-
-export type StartAppMutation = { __typename?: 'Mutation', startApp: { __typename: 'App', id: string, status: AppStatusEnum } };
+export type StartAppMutation = { __typename?: 'Mutation'; startApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 export type StopAppMutationVariables = Exact<{
   id: Scalars['String'];
 }>;
 
-
-export type StopAppMutation = { __typename?: 'Mutation', stopApp: { __typename: 'App', id: string, status: AppStatusEnum } };
+export type StopAppMutation = { __typename?: 'Mutation'; stopApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 export type UninstallAppMutationVariables = Exact<{
   id: Scalars['String'];
 }>;
 
+export type UninstallAppMutation = { __typename?: 'Mutation'; uninstallApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
-export type UninstallAppMutation = { __typename?: 'Mutation', uninstallApp: { __typename: 'App', id: string, status: AppStatusEnum } };
-
-export type UpdateMutationVariables = Exact<{ [key: string]: never; }>;
+export type UpdateMutationVariables = Exact<{ [key: string]: never }>;
 
-
-export type UpdateMutation = { __typename?: 'Mutation', update: boolean };
+export type UpdateMutation = { __typename?: 'Mutation'; update: boolean };
 
 export type UpdateAppMutationVariables = Exact<{
   id: Scalars['String'];
 }>;
 
-
-export type UpdateAppMutation = { __typename?: 'Mutation', updateApp: { __typename: 'App', id: string, status: AppStatusEnum } };
+export type UpdateAppMutation = { __typename?: 'Mutation'; updateApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 export type UpdateAppConfigMutationVariables = Exact<{
   input: AppInputType;
 }>;
 
-
-export type UpdateAppConfigMutation = { __typename?: 'Mutation', updateAppConfig: { __typename: 'App', id: string, status: AppStatusEnum } };
+export type UpdateAppConfigMutation = { __typename?: 'Mutation'; updateAppConfig: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 export type GetAppQueryVariables = Exact<{
   appId: Scalars['String'];
 }>;
 
+export type GetAppQuery = {
+  __typename?: 'Query';
+  getApp: {
+    __typename?: 'App';
+    id: string;
+    status: AppStatusEnum;
+    config: any;
+    version?: number | null;
+    exposed: boolean;
+    domain?: string | null;
+    updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
+    info?: {
+      __typename?: 'AppInfo';
+      id: string;
+      port: number;
+      name: string;
+      description: string;
+      available: boolean;
+      version?: string | null;
+      tipi_version: number;
+      short_desc: string;
+      author: string;
+      source: string;
+      categories: Array<AppCategoriesEnum>;
+      url_suffix?: string | null;
+      https?: boolean | null;
+      exposable?: boolean | null;
+      no_gui?: boolean | null;
+      form_fields: Array<{
+        __typename?: 'FormField';
+        type: FieldTypesEnum;
+        label: string;
+        max?: number | null;
+        min?: number | null;
+        hint?: string | null;
+        placeholder?: string | null;
+        required?: boolean | null;
+        env_variable: string;
+      }>;
+    } | null;
+  };
+};
 
-export type GetAppQuery = { __typename?: 'Query', getApp: { __typename?: 'App', id: string, status: AppStatusEnum, config: any, version?: number | null, exposed: boolean, domain?: string | null, updateInfo?: { __typename?: 'UpdateInfo', current: number, latest: number, dockerVersion?: string | null } | null, info?: { __typename?: 'AppInfo', id: string, port: number, name: string, description: string, available: boolean, version?: string | null, tipi_version: number, short_desc: string, author: string, source: string, categories: Array<AppCategoriesEnum>, url_suffix?: string | null, https?: boolean | null, exposable?: boolean | null, no_gui?: boolean | null, form_fields: Array<{ __typename?: 'FormField', type: FieldTypesEnum, label: string, max?: number | null, min?: number | null, hint?: string | null, placeholder?: string | null, required?: boolean | null, env_variable: string }> } | null } };
-
-export type InstalledAppsQueryVariables = Exact<{ [key: string]: never; }>;
-
-
-export type InstalledAppsQuery = { __typename?: 'Query', installedApps: Array<{ __typename?: 'App', id: string, status: AppStatusEnum, config: any, version?: number | null, updateInfo?: { __typename?: 'UpdateInfo', current: number, latest: number, dockerVersion?: string | null } | null, info?: { __typename?: 'AppInfo', id: string, name: string, description: string, tipi_version: number, short_desc: string, https?: boolean | null } | null }> };
-
-export type ConfiguredQueryVariables = Exact<{ [key: string]: never; }>;
-
-
-export type ConfiguredQuery = { __typename?: 'Query', isConfigured: boolean };
-
-export type ListAppsQueryVariables = Exact<{ [key: string]: never; }>;
-
-
-export type ListAppsQuery = { __typename?: 'Query', listAppsInfo: { __typename?: 'ListAppsResonse', total: number, apps: Array<{ __typename?: 'AppInfo', id: string, available: boolean, tipi_version: number, port: number, name: string, version?: string | null, short_desc: string, author: string, categories: Array<AppCategoriesEnum>, https?: boolean | null }> } };
+export type InstalledAppsQueryVariables = Exact<{ [key: string]: never }>;
 
-export type MeQueryVariables = Exact<{ [key: string]: never; }>;
+export type InstalledAppsQuery = {
+  __typename?: 'Query';
+  installedApps: Array<{
+    __typename?: 'App';
+    id: string;
+    status: AppStatusEnum;
+    config: any;
+    version?: number | null;
+    updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
+    info?: { __typename?: 'AppInfo'; id: string; name: string; description: string; tipi_version: number; short_desc: string; https?: boolean | null } | null;
+  }>;
+};
 
+export type ConfiguredQueryVariables = Exact<{ [key: string]: never }>;
 
-export type MeQuery = { __typename?: 'Query', me?: { __typename?: 'User', id: string } | null };
+export type ConfiguredQuery = { __typename?: 'Query'; isConfigured: boolean };
 
-export type RefreshTokenQueryVariables = Exact<{ [key: string]: never; }>;
+export type ListAppsQueryVariables = Exact<{ [key: string]: never }>;
 
+export type ListAppsQuery = {
+  __typename?: 'Query';
+  listAppsInfo: {
+    __typename?: 'ListAppsResonse';
+    total: number;
+    apps: Array<{
+      __typename?: 'AppInfo';
+      id: string;
+      available: boolean;
+      tipi_version: number;
+      port: number;
+      name: string;
+      version?: string | null;
+      short_desc: string;
+      author: string;
+      categories: Array<AppCategoriesEnum>;
+      https?: boolean | null;
+    }>;
+  };
+};
 
-export type RefreshTokenQuery = { __typename?: 'Query', refreshToken?: { __typename?: 'TokenResponse', token: string } | null };
+export type MeQueryVariables = Exact<{ [key: string]: never }>;
 
-export type SystemInfoQueryVariables = Exact<{ [key: string]: never; }>;
+export type MeQuery = { __typename?: 'Query'; me?: { __typename?: 'User'; id: string } | null };
 
+export type RefreshTokenQueryVariables = Exact<{ [key: string]: never }>;
 
-export type SystemInfoQuery = { __typename?: 'Query', systemInfo?: { __typename?: 'SystemInfoResponse', cpu: { __typename?: 'Cpu', load: number }, disk: { __typename?: 'DiskMemory', available: number, used: number, total: number }, memory: { __typename?: 'DiskMemory', available: number, used: number, total: number } } | null };
+export type RefreshTokenQuery = { __typename?: 'Query'; refreshToken?: { __typename?: 'TokenResponse'; token: string } | null };
 
-export type VersionQueryVariables = Exact<{ [key: string]: never; }>;
+export type SystemInfoQueryVariables = Exact<{ [key: string]: never }>;
 
+export type SystemInfoQuery = {
+  __typename?: 'Query';
+  systemInfo?: {
+    __typename?: 'SystemInfoResponse';
+    cpu: { __typename?: 'Cpu'; load: number };
+    disk: { __typename?: 'DiskMemory'; available: number; used: number; total: number };
+    memory: { __typename?: 'DiskMemory'; available: number; used: number; total: number };
+  } | null;
+};
 
-export type VersionQuery = { __typename?: 'Query', version: { __typename?: 'VersionResponse', current: string, latest?: string | null } };
+export type VersionQueryVariables = Exact<{ [key: string]: never }>;
 
+export type VersionQuery = { __typename?: 'Query'; version: { __typename?: 'VersionResponse'; current: string; latest?: string | null } };
 
 export const InstallAppDocument = gql`
-    mutation InstallApp($input: AppInputType!) {
-  installApp(input: $input) {
-    id
-    status
-    __typename
+  mutation InstallApp($input: AppInputType!) {
+    installApp(input: $input) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type InstallAppMutationFn = Apollo.MutationFunction<InstallAppMutation, InstallAppMutationVariables>;
 
 /**
@@ -400,12 +450,12 @@ export type InstallAppMutationHookResult = ReturnType<typeof useInstallAppMutati
 export type InstallAppMutationResult = Apollo.MutationResult<InstallAppMutation>;
 export type InstallAppMutationOptions = Apollo.BaseMutationOptions<InstallAppMutation, InstallAppMutationVariables>;
 export const LoginDocument = gql`
-    mutation Login($input: UsernamePasswordInput!) {
-  login(input: $input) {
-    token
+  mutation Login($input: UsernamePasswordInput!) {
+    login(input: $input) {
+      token
+    }
   }
-}
-    `;
+`;
 export type LoginMutationFn = Apollo.MutationFunction<LoginMutation, LoginMutationVariables>;
 
 /**
@@ -433,10 +483,10 @@ export type LoginMutationHookResult = ReturnType<typeof useLoginMutation>;
 export type LoginMutationResult = Apollo.MutationResult<LoginMutation>;
 export type LoginMutationOptions = Apollo.BaseMutationOptions<LoginMutation, LoginMutationVariables>;
 export const LogoutDocument = gql`
-    mutation Logout {
-  logout
-}
-    `;
+  mutation Logout {
+    logout
+  }
+`;
 export type LogoutMutationFn = Apollo.MutationFunction<LogoutMutation, LogoutMutationVariables>;
 
 /**
@@ -463,12 +513,12 @@ export type LogoutMutationHookResult = ReturnType<typeof useLogoutMutation>;
 export type LogoutMutationResult = Apollo.MutationResult<LogoutMutation>;
 export type LogoutMutationOptions = Apollo.BaseMutationOptions<LogoutMutation, LogoutMutationVariables>;
 export const RegisterDocument = gql`
-    mutation Register($input: UsernamePasswordInput!) {
-  register(input: $input) {
-    token
+  mutation Register($input: UsernamePasswordInput!) {
+    register(input: $input) {
+      token
+    }
   }
-}
-    `;
+`;
 export type RegisterMutationFn = Apollo.MutationFunction<RegisterMutation, RegisterMutationVariables>;
 
 /**
@@ -496,10 +546,10 @@ export type RegisterMutationHookResult = ReturnType<typeof useRegisterMutation>;
 export type RegisterMutationResult = Apollo.MutationResult<RegisterMutation>;
 export type RegisterMutationOptions = Apollo.BaseMutationOptions<RegisterMutation, RegisterMutationVariables>;
 export const RestartDocument = gql`
-    mutation Restart {
-  restart
-}
-    `;
+  mutation Restart {
+    restart
+  }
+`;
 export type RestartMutationFn = Apollo.MutationFunction<RestartMutation, RestartMutationVariables>;
 
 /**
@@ -526,14 +576,14 @@ export type RestartMutationHookResult = ReturnType<typeof useRestartMutation>;
 export type RestartMutationResult = Apollo.MutationResult<RestartMutation>;
 export type RestartMutationOptions = Apollo.BaseMutationOptions<RestartMutation, RestartMutationVariables>;
 export const StartAppDocument = gql`
-    mutation StartApp($id: String!) {
-  startApp(id: $id) {
-    id
-    status
-    __typename
+  mutation StartApp($id: String!) {
+    startApp(id: $id) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type StartAppMutationFn = Apollo.MutationFunction<StartAppMutation, StartAppMutationVariables>;
 
 /**
@@ -561,14 +611,14 @@ export type StartAppMutationHookResult = ReturnType<typeof useStartAppMutation>;
 export type StartAppMutationResult = Apollo.MutationResult<StartAppMutation>;
 export type StartAppMutationOptions = Apollo.BaseMutationOptions<StartAppMutation, StartAppMutationVariables>;
 export const StopAppDocument = gql`
-    mutation StopApp($id: String!) {
-  stopApp(id: $id) {
-    id
-    status
-    __typename
+  mutation StopApp($id: String!) {
+    stopApp(id: $id) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type StopAppMutationFn = Apollo.MutationFunction<StopAppMutation, StopAppMutationVariables>;
 
 /**
@@ -596,14 +646,14 @@ export type StopAppMutationHookResult = ReturnType<typeof useStopAppMutation>;
 export type StopAppMutationResult = Apollo.MutationResult<StopAppMutation>;
 export type StopAppMutationOptions = Apollo.BaseMutationOptions<StopAppMutation, StopAppMutationVariables>;
 export const UninstallAppDocument = gql`
-    mutation UninstallApp($id: String!) {
-  uninstallApp(id: $id) {
-    id
-    status
-    __typename
+  mutation UninstallApp($id: String!) {
+    uninstallApp(id: $id) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type UninstallAppMutationFn = Apollo.MutationFunction<UninstallAppMutation, UninstallAppMutationVariables>;
 
 /**
@@ -631,10 +681,10 @@ export type UninstallAppMutationHookResult = ReturnType<typeof useUninstallAppMu
 export type UninstallAppMutationResult = Apollo.MutationResult<UninstallAppMutation>;
 export type UninstallAppMutationOptions = Apollo.BaseMutationOptions<UninstallAppMutation, UninstallAppMutationVariables>;
 export const UpdateDocument = gql`
-    mutation Update {
-  update
-}
-    `;
+  mutation Update {
+    update
+  }
+`;
 export type UpdateMutationFn = Apollo.MutationFunction<UpdateMutation, UpdateMutationVariables>;
 
 /**
@@ -661,14 +711,14 @@ export type UpdateMutationHookResult = ReturnType<typeof useUpdateMutation>;
 export type UpdateMutationResult = Apollo.MutationResult<UpdateMutation>;
 export type UpdateMutationOptions = Apollo.BaseMutationOptions<UpdateMutation, UpdateMutationVariables>;
 export const UpdateAppDocument = gql`
-    mutation UpdateApp($id: String!) {
-  updateApp(id: $id) {
-    id
-    status
-    __typename
+  mutation UpdateApp($id: String!) {
+    updateApp(id: $id) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type UpdateAppMutationFn = Apollo.MutationFunction<UpdateAppMutation, UpdateAppMutationVariables>;
 
 /**
@@ -696,14 +746,14 @@ export type UpdateAppMutationHookResult = ReturnType<typeof useUpdateAppMutation
 export type UpdateAppMutationResult = Apollo.MutationResult<UpdateAppMutation>;
 export type UpdateAppMutationOptions = Apollo.BaseMutationOptions<UpdateAppMutation, UpdateAppMutationVariables>;
 export const UpdateAppConfigDocument = gql`
-    mutation UpdateAppConfig($input: AppInputType!) {
-  updateAppConfig(input: $input) {
-    id
-    status
-    __typename
+  mutation UpdateAppConfig($input: AppInputType!) {
+    updateAppConfig(input: $input) {
+      id
+      status
+      __typename
+    }
   }
-}
-    `;
+`;
 export type UpdateAppConfigMutationFn = Apollo.MutationFunction<UpdateAppConfigMutation, UpdateAppConfigMutationVariables>;
 
 /**
@@ -731,49 +781,49 @@ export type UpdateAppConfigMutationHookResult = ReturnType<typeof useUpdateAppCo
 export type UpdateAppConfigMutationResult = Apollo.MutationResult<UpdateAppConfigMutation>;
 export type UpdateAppConfigMutationOptions = Apollo.BaseMutationOptions<UpdateAppConfigMutation, UpdateAppConfigMutationVariables>;
 export const GetAppDocument = gql`
-    query GetApp($appId: String!) {
-  getApp(id: $appId) {
-    id
-    status
-    config
-    version
-    exposed
-    domain
-    updateInfo {
-      current
-      latest
-      dockerVersion
-    }
-    info {
+  query GetApp($appId: String!) {
+    getApp(id: $appId) {
       id
-      port
-      name
-      description
-      available
+      status
+      config
       version
-      tipi_version
-      short_desc
-      author
-      source
-      categories
-      url_suffix
-      https
-      exposable
-      no_gui
-      form_fields {
-        type
-        label
-        max
-        min
-        hint
-        placeholder
-        required
-        env_variable
+      exposed
+      domain
+      updateInfo {
+        current
+        latest
+        dockerVersion
+      }
+      info {
+        id
+        port
+        name
+        description
+        available
+        version
+        tipi_version
+        short_desc
+        author
+        source
+        categories
+        url_suffix
+        https
+        exposable
+        no_gui
+        form_fields {
+          type
+          label
+          max
+          min
+          hint
+          placeholder
+          required
+          env_variable
+        }
       }
     }
   }
-}
-    `;
+`;
 
 /**
  * __useGetAppQuery__
@@ -803,28 +853,28 @@ export type GetAppQueryHookResult = ReturnType<typeof useGetAppQuery>;
 export type GetAppLazyQueryHookResult = ReturnType<typeof useGetAppLazyQuery>;
 export type GetAppQueryResult = Apollo.QueryResult<GetAppQuery, GetAppQueryVariables>;
 export const InstalledAppsDocument = gql`
-    query InstalledApps {
-  installedApps {
-    id
-    status
-    config
-    version
-    updateInfo {
-      current
-      latest
-      dockerVersion
-    }
-    info {
+  query InstalledApps {
+    installedApps {
       id
-      name
-      description
-      tipi_version
-      short_desc
-      https
+      status
+      config
+      version
+      updateInfo {
+        current
+        latest
+        dockerVersion
+      }
+      info {
+        id
+        name
+        description
+        tipi_version
+        short_desc
+        https
+      }
     }
   }
-}
-    `;
+`;
 
 /**
  * __useInstalledAppsQuery__
@@ -853,10 +903,10 @@ export type InstalledAppsQueryHookResult = ReturnType<typeof useInstalledAppsQue
 export type InstalledAppsLazyQueryHookResult = ReturnType<typeof useInstalledAppsLazyQuery>;
 export type InstalledAppsQueryResult = Apollo.QueryResult<InstalledAppsQuery, InstalledAppsQueryVariables>;
 export const ConfiguredDocument = gql`
-    query Configured {
-  isConfigured
-}
-    `;
+  query Configured {
+    isConfigured
+  }
+`;
 
 /**
  * __useConfiguredQuery__
@@ -885,24 +935,24 @@ export type ConfiguredQueryHookResult = ReturnType<typeof useConfiguredQuery>;
 export type ConfiguredLazyQueryHookResult = ReturnType<typeof useConfiguredLazyQuery>;
 export type ConfiguredQueryResult = Apollo.QueryResult<ConfiguredQuery, ConfiguredQueryVariables>;
 export const ListAppsDocument = gql`
-    query ListApps {
-  listAppsInfo {
-    apps {
-      id
-      available
-      tipi_version
-      port
-      name
-      version
-      short_desc
-      author
-      categories
-      https
+  query ListApps {
+    listAppsInfo {
+      apps {
+        id
+        available
+        tipi_version
+        port
+        name
+        version
+        short_desc
+        author
+        categories
+        https
+      }
+      total
     }
-    total
   }
-}
-    `;
+`;
 
 /**
  * __useListAppsQuery__
@@ -931,12 +981,12 @@ export type ListAppsQueryHookResult = ReturnType<typeof useListAppsQuery>;
 export type ListAppsLazyQueryHookResult = ReturnType<typeof useListAppsLazyQuery>;
 export type ListAppsQueryResult = Apollo.QueryResult<ListAppsQuery, ListAppsQueryVariables>;
 export const MeDocument = gql`
-    query Me {
-  me {
-    id
+  query Me {
+    me {
+      id
+    }
   }
-}
-    `;
+`;
 
 /**
  * __useMeQuery__
@@ -965,12 +1015,12 @@ export type MeQueryHookResult = ReturnType<typeof useMeQuery>;
 export type MeLazyQueryHookResult = ReturnType<typeof useMeLazyQuery>;
 export type MeQueryResult = Apollo.QueryResult<MeQuery, MeQueryVariables>;
 export const RefreshTokenDocument = gql`
-    query RefreshToken {
-  refreshToken {
-    token
+  query RefreshToken {
+    refreshToken {
+      token
+    }
   }
-}
-    `;
+`;
 
 /**
  * __useRefreshTokenQuery__
@@ -999,24 +1049,24 @@ export type RefreshTokenQueryHookResult = ReturnType<typeof useRefreshTokenQuery
 export type RefreshTokenLazyQueryHookResult = ReturnType<typeof useRefreshTokenLazyQuery>;
 export type RefreshTokenQueryResult = Apollo.QueryResult<RefreshTokenQuery, RefreshTokenQueryVariables>;
 export const SystemInfoDocument = gql`
-    query SystemInfo {
-  systemInfo {
-    cpu {
-      load
-    }
-    disk {
-      available
-      used
-      total
-    }
-    memory {
-      available
-      used
-      total
+  query SystemInfo {
+    systemInfo {
+      cpu {
+        load
+      }
+      disk {
+        available
+        used
+        total
+      }
+      memory {
+        available
+        used
+        total
+      }
     }
   }
-}
-    `;
+`;
 
 /**
  * __useSystemInfoQuery__
@@ -1045,13 +1095,13 @@ export type SystemInfoQueryHookResult = ReturnType<typeof useSystemInfoQuery>;
 export type SystemInfoLazyQueryHookResult = ReturnType<typeof useSystemInfoLazyQuery>;
 export type SystemInfoQueryResult = Apollo.QueryResult<SystemInfoQuery, SystemInfoQueryVariables>;
 export const VersionDocument = gql`
-    query Version {
-  version {
-    current
-    latest
+  query Version {
+    version {
+      current
+      latest
+    }
   }
-}
-    `;
+`;
 
 /**
  * __useVersionQuery__
@@ -1078,4 +1128,4 @@ export function useVersionLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<Ve
 }
 export type VersionQueryHookResult = ReturnType<typeof useVersionQuery>;
 export type VersionLazyQueryHookResult = ReturnType<typeof useVersionLazyQuery>;
-export type VersionQueryResult = Apollo.QueryResult<VersionQuery, VersionQueryVariables>;
+export type VersionQueryResult = Apollo.QueryResult<VersionQuery, VersionQueryVariables>;

+ 6 - 1
packages/dashboard/src/hooks/useCachedRessources.ts

@@ -1,6 +1,7 @@
 import { useEffect, useState } from 'react';
 import { ApolloClient } from '@apollo/client';
 import { createApolloClient } from '../core/apollo/client';
+import { initMocks } from '../mocks';
 
 interface IReturnProps {
   client?: ApolloClient<unknown>;
@@ -11,7 +12,11 @@ export default function useCachedResources(): IReturnProps {
   const [isLoadingComplete, setLoadingComplete] = useState(false);
   const [client, setClient] = useState<ApolloClient<unknown>>();
 
-  function loadResourcesAndDataAsync() {
+  async function loadResourcesAndDataAsync() {
+    if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') {
+      await initMocks();
+    }
+
     try {
       const restoredClient = createApolloClient();
 

+ 4 - 0
packages/dashboard/src/mocks/browser.ts

@@ -0,0 +1,4 @@
+import { setupWorker } from 'msw';
+import { handlers } from './handlers';
+
+export const worker = setupWorker(...handlers);

+ 57 - 0
packages/dashboard/src/mocks/fixtures/app.fixtures.ts

@@ -0,0 +1,57 @@
+import { faker } from '@faker-js/faker';
+import { App, AppCategoriesEnum, AppInfo, AppStatusEnum } from '../../generated/graphql';
+
+const randomCategory = (): AppCategoriesEnum[] => {
+  const categories = Object.values(AppCategoriesEnum);
+  const randomIndex = faker.datatype.number({ min: 0, max: categories.length - 1 });
+  return [categories[randomIndex]];
+};
+
+export const createApp = (overrides?: Partial<AppInfo>): AppInfo => {
+  const name = faker.random.word();
+  return {
+    id: name.toLowerCase(),
+    name,
+    description: faker.random.words(),
+    author: faker.random.word(),
+    available: true,
+    categories: randomCategory(),
+    form_fields: [],
+    port: faker.datatype.number({ min: 1000, max: 9999 }),
+    short_desc: faker.random.words(),
+    tipi_version: 1,
+    version: faker.system.semver(),
+    source: faker.internet.url(),
+    https: false,
+    no_gui: false,
+    exposable: true,
+    url_suffix: '',
+    ...overrides,
+  };
+};
+
+type CreateAppEntityParams = {
+  overrides?: Omit<Partial<App>, 'info'>;
+  overridesInfo?: Partial<AppInfo>;
+  status?: AppStatusEnum;
+};
+
+export const createAppEntity = (params: CreateAppEntityParams) => {
+  const { overrides, overridesInfo, status = AppStatusEnum.Running } = params;
+
+  const id = faker.random.word().toLowerCase();
+  const app = createApp({ id, ...overridesInfo });
+  return {
+    id,
+    status,
+    info: app,
+    config: {},
+    exposed: false,
+    updateInfo: null,
+    domain: null,
+    version: 1,
+    ...overrides,
+  };
+};
+
+export const createAppsRandomly = (count: number): AppInfo[] => Array.from({ length: count }).map(() => createApp());

+ 133 - 0
packages/dashboard/src/mocks/handlers.ts

@@ -0,0 +1,133 @@
+import { graphql, rest } from 'msw';
+import {
+  ConfiguredQuery,
+  LoginMutation,
+  LogoutMutationResult,
+  MeQuery,
+  RefreshTokenQuery,
+  RegisterMutation,
+  RegisterMutationVariables,
+  UsernamePasswordInput,
+  VersionQuery,
+  SystemInfoQuery,
+} from '../generated/graphql';
+import appHandlers from './handlers/appHandlers';
+
+const restHandlers = [
+  rest.get('/api/status', (req, res, ctx) =>
+    res(
+      ctx.delay(200),
+      ctx.status(200),
+      ctx.json({
+        status: 'RUNNING',
+      }),
+    ),
+  ),
+];
+const graphqlHandlers = [
+  // Handles a "Login" mutation
+  graphql.mutation('Login', (req, res, ctx) => {
+    const { username } = req.variables as UsernamePasswordInput;
+    sessionStorage.setItem('is-authenticated', username);
+
+    const result: LoginMutation = {
+      login: { token: 'token' },
+    };
+
+    return res(ctx.delay(), ctx.data(result));
+  }),
+
+  // Handles a "Logout" mutation
+  graphql.mutation('Logout', (req, res, ctx) => {
+    sessionStorage.removeItem('is-authenticated');
+
+    const result: LogoutMutationResult['data'] = {
+      logout: true,
+    };
+
+    return res(ctx.delay(), ctx.data(result));
+  }),
+
+  // Handles me query
+  graphql.query('Me', (req, res, ctx) => {
+    const isAuthenticated = sessionStorage.getItem('is-authenticated');
+    if (!isAuthenticated) {
+      return res(ctx.errors([{ message: 'Not authenticated' }]));
+    }
+    const result: MeQuery = {
+      me: { id: '1' },
+    };
+
+    return res(ctx.delay(), ctx.data(result));
+  }),
+
+  graphql.query('RefreshToken', (req, res, ctx) => {
+    const result: RefreshTokenQuery = {
+      refreshToken: { token: 'token' },
+    };
+
+    return res(ctx.delay(), ctx.data(result));
+  }),
+
+  graphql.mutation('Register', (req, res, ctx) => {
+    const {
+      input: { username },
+    } = req.variables as RegisterMutationVariables;
+
+    const result: RegisterMutation = {
+      register: { token: 'token' },
+    };
+
+    if (username === 'error@error.com') {
+      return res(ctx.errors([{ message: 'Username is already taken' }]));
+    }
+
+    return res(ctx.data(result));
+  }),
+  appHandlers.listApps,
+  appHandlers.getApp,
+  appHandlers.installedApps,
+  appHandlers.installApp,
+  graphql.query('Version', (req, res, ctx) => {
+    const result: VersionQuery = {
+      version: {
+        current: '1.0.0',
+        latest: '1.0.0',
+      },
+    };
+
+    return res(ctx.data(result));
+  }),
+
+  graphql.query('Configured', (req, res, ctx) => {
+    const result: ConfiguredQuery = {
+      isConfigured: true,
+    };
+
+    return res(ctx.data(result));
+  }),
+
+  graphql.query('SystemInfo', (req, res, ctx) => {
+    const result: SystemInfoQuery = {
+      systemInfo: {
+        cpu: {
+          load: 50,
+        },
+        disk: {
+          available: 1000000000,
+          total: 2000000000,
+          used: 1000000000,
+        },
+        memory: {
+          available: 1000000000,
+          total: 2000000000,
+          used: 1000000000,
+        },
+      },
+    };
+
+    return res(ctx.data(result));
+  }),
+];
+
+export const handlers = [...graphqlHandlers, ...restHandlers];

+ 173 - 0
packages/dashboard/src/mocks/handlers/appHandlers.ts

@@ -0,0 +1,173 @@
+import { graphql } from 'msw';
+import { faker } from '@faker-js/faker';
+import { createAppsRandomly } from '../fixtures/app.fixtures';
+import { AppInputType, AppStatusEnum, GetAppQuery, InstallAppMutation, InstalledAppsQuery, ListAppsQuery } from '../../generated/graphql';
+
+// eslint-disable-next-line no-promise-executor-return
+const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+export const notEmpty = <TValue>(value: TValue | null | undefined): value is TValue => value !== null && value !== undefined;
+
+const removeDuplicates = <T extends { id: string }>(array: T[]) =>
+  array.filter((a, i) => {
+    const index = array.findIndex((_a) => _a.id === a.id);
+    return index === i;
+  });
+
+export const mockedApps = removeDuplicates(createAppsRandomly(faker.datatype.number({ min: 20, max: 30 })));
+
+export const mockInstalledAppIds = mockedApps.slice(0, faker.datatype.number({ min: 5, max: 8 })).map((a) => a.id);
+const stoppedAppsIds = mockInstalledAppIds.slice(0, faker.datatype.number({ min: 1, max: 3 }));
+
+/**
+ * GetApp handler
+ */
+const getApp = graphql.query('GetApp', (req, res, ctx) => {
+  const { appId } = req.variables as { appId: string };
+
+  const app = mockedApps.find((a) => a.id === appId);
+
+  if (!app) {
+    return res(ctx.errors([{ message: 'App not found' }]));
+  }
+
+  const isInstalled = mockInstalledAppIds.includes(appId);
+
+  let status = AppStatusEnum.Missing;
+  if (isInstalled) {
+    status = AppStatusEnum.Running;
+  }
+  if (isInstalled && stoppedAppsIds.includes(appId)) {
+    status = AppStatusEnum.Stopped;
+  }
+
+  const result: GetAppQuery = {
+    getApp: {
+      id: app.id,
+      status,
+      info: app,
+      __typename: 'App',
+      config: {},
+      exposed: false,
+      updateInfo: null,
+      domain: null,
+      version: 1,
+    },
+  };
+
+  return res(ctx.data(result));
+});
+
+const getAppError = graphql.query('GetApp', (req, res, ctx) => res(ctx.errors([{ message: 'test-error' }])));
+
+/**
+ * ListApps handler
+ */
+const listApps = graphql.query('ListApps', async (req, res, ctx) => {
+  const result: ListAppsQuery = {
+    listAppsInfo: {
+      apps: mockedApps,
+      total: mockedApps.length,
+    },
+  };
+
+  await wait(100);
+
+  return res(ctx.data(result));
+});
+
+const listAppsEmpty = graphql.query('ListApps', (req, res, ctx) => {
+  const result: ListAppsQuery = {
+    listAppsInfo: {
+      apps: [],
+      total: 0,
+    },
+  };
+  return res(ctx.data(result));
+});
+
+const listAppsError = graphql.query('ListApps', (req, res, ctx) => res(ctx.errors([{ message: 'test-error' }])));
+
+/**
+ * InstalledApps handler
+ */
+const installedApps = graphql.query('InstalledApps', (req, res, ctx) => {
+  const apps: InstalledAppsQuery['installedApps'] = mockInstalledAppIds
+    .map((id) => {
+      const app = mockedApps.find((a) => a.id === id);
+      if (!app) return null;
+
+      let status = AppStatusEnum.Running;
+      if (stoppedAppsIds.includes(id)) {
+        status = AppStatusEnum.Stopped;
+      }
+
+      return {
+        __typename: 'App' as const,
+        id: app.id,
+        status,
+        config: {},
+        info: app,
+        version: 1,
+        updateInfo: null,
+      };
+    })
+    .filter(notEmpty);
+
+  const result: InstalledAppsQuery = {
+    installedApps: apps,
+  };
+
+  return res(ctx.data(result));
+});
+
+const installedAppsEmpty = graphql.query('InstalledApps', (req, res, ctx) => {
+  const result: InstalledAppsQuery = {
+    installedApps: [],
+  };
+
+  return res(ctx.data(result));
+});
+
+const installedAppsError = graphql.query('InstalledApps', (req, res, ctx) => res(ctx.errors([{ message: 'test-error' }])));
+
+const installedAppsNoInfo = graphql.query('InstalledApps', (req, res, ctx) => {
+  const result: InstalledAppsQuery = {
+    installedApps: [
+      {
+        __typename: 'App' as const,
+        id: 'app-id',
+        status: AppStatusEnum.Running,
+        config: {},
+        info: null,
+        version: 1,
+        updateInfo: null,
+      },
+    ],
+  };
+  return res(ctx.data(result));
+});
+
+/**
+ * Install app handler
+ */
+const installApp = graphql.mutation('InstallApp', (req, res, ctx) => {
+  const { input } = req.variables as { input: AppInputType };
+
+  const app = mockedApps.find((a) => a.id === input.id);
+
+  if (!app) {
+    return res(ctx.errors([{ message: 'App not found' }]));
+  }
+
+  const result: InstallAppMutation = {
+    installApp: {
+      __typename: 'App' as const,
+      id: app.id,
+      status: AppStatusEnum.Running,
+    },
+  };
+
+  return res(ctx.data(result));
+});
+
+export default { getApp, getAppError, listApps, listAppsEmpty, listAppsError, installedApps, installedAppsEmpty, installedAppsError, installedAppsNoInfo, installApp };

+ 13 - 0
packages/dashboard/src/mocks/index.ts

@@ -0,0 +1,13 @@
+async function initMocks() {
+  if (typeof window === 'undefined') {
+    const { server } = await import('./server');
+    server.listen();
+  } else {
+    const { worker } = await import('./browser');
+    worker.start();
+  }
+}
+
+initMocks();
+
+export { initMocks };

+ 4 - 0
packages/dashboard/src/mocks/server.ts

@@ -0,0 +1,4 @@
+import { setupServer } from 'msw/node';
+import { handlers } from './handlers';
+
+export const server = setupServer(...handlers);

+ 1 - 1
packages/dashboard/src/modules/AppStore/components/AppStoreTable/AppStoreTable.loading.tsx

@@ -5,7 +5,7 @@ const AppStoreTableLoading: React.FC = () => {
   const elements = Array.from({ length: 30 }, (_, i) => i);
 
   return (
-    <div className="row row-cards">
+    <div data-testid="app-store-table-loading" className="row row-cards">
       {elements.map((n) => (
         <AppStoreTileLoading key={n} />
       ))}

+ 1 - 1
packages/dashboard/src/modules/AppStore/components/AppStoreTable/AppStoreTable.tsx

@@ -16,7 +16,7 @@ const AppStoreTable: React.FC<IProps> = ({ data, loading }) => {
   }
 
   return (
-    <div className="row row-cards">
+    <div data-testid="app-store-table" className="row row-cards">
       {data.map((app) => (
         <AppStoreTile key={app.id} app={app} />
       ))}

+ 57 - 0
packages/dashboard/src/modules/AppStore/pages/AppStorePage/AppStorePage.test.tsx

@@ -0,0 +1,57 @@
+import React from 'react';
+import { render, screen, waitFor } from '../../../../../tests/test-utils';
+import appHandlers from '../../../../mocks/handlers/appHandlers';
+import { server } from '../../../../mocks/server';
+import { AppStorePage } from './AppStorePage';
+
+describe('Test: AppStorePage', () => {
+  it('should render error state when error occurs', async () => {
+    // Arrange
+    server.use(appHandlers.listAppsError);
+    render(<AppStorePage />);
+
+    // Assert
+    await waitFor(() => {
+      expect(screen.getByText('An error occured')).toBeInTheDocument();
+    });
+  });
+
+  it('should render', async () => {
+    // Arrange
+    render(<AppStorePage />);
+    expect(screen.getByTestId('app-store-layout')).toBeInTheDocument();
+  });
+
+  it('should render app store table', async () => {
+    // Arrange
+    render(<AppStorePage />);
+    expect(screen.getByTestId('app-store-layout')).toBeInTheDocument();
+
+    // Assert
+    await waitFor(() => {
+      expect(screen.getByTestId('app-store-table')).toBeInTheDocument();
+    });
+  });
+
+  it('should render app store table loading when data is not here', async () => {
+    // Arrange
+    render(<AppStorePage />);
+    expect(screen.getByTestId('app-store-layout')).toBeInTheDocument();
+
+    // Assert
+    await waitFor(() => {
+      expect(screen.getByTestId('app-store-table-loading')).toBeInTheDocument();
+    });
+  });
+
+  it('should render empty state when no apps are available', async () => {
+    // Arrange
+    server.use(appHandlers.listAppsEmpty);
+    render(<AppStorePage />);
+
+    // Assert
+    await waitFor(() => {
+      expect(screen.getByText('No app found')).toBeInTheDocument();
+    });
+  });
+});

+ 4 - 1
packages/dashboard/src/modules/AppStore/pages/AppStorePage/AppStorePage.tsx

@@ -10,9 +10,11 @@ import { sortTable } from '../../helpers/table.helpers';
 import { Layout } from '../../../../components/Layout';
 import { EmptyPage } from '../../../../components/ui/EmptyPage';
 import AppStoreContainer from '../../containers/AppStoreContainer';
+import { ErrorPage } from '../../../../components/ui/ErrorPage';
 
 export const AppStorePage: NextPage = () => {
-  const { loading, data } = useListAppsQuery();
+  const { loading, data, error } = useListAppsQuery();
+
   const { setCategory, setSearch, category, search, sort, sortDirection } = useAppStoreState();
 
   const actions = (
@@ -31,6 +33,7 @@ export const AppStorePage: NextPage = () => {
     <Layout loading={loading && !data} title="App Store" actions={actions}>
       {(tableData.length > 0 || loading) && <AppStoreContainer loading={loading} apps={tableData} />}
       {tableData.length === 0 && <EmptyPage title="No app found" subtitle="Try to refine your search" />}
+      {error && <ErrorPage error={error.message} />}
     </Layout>
   );
 };

+ 83 - 0
packages/dashboard/src/modules/Apps/components/AppActions/AppActions.test.tsx

@@ -0,0 +1,83 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+import React from 'react';
+import '@testing-library/jest-dom/extend-expect';
+import { AppActions } from './AppActions';
+import { AppInfo, AppStatusEnum } from '../../../../generated/graphql';
+import { cleanup, fireEvent, render, screen } from '../../../../../tests/test-utils';
+
+afterEach(cleanup);
+
+describe('Test: AppActions', () => {
+  const app = {
+    name: 'My App',
+    form_fields: [],
+    exposable: [],
+  } as unknown as AppInfo;
+
+  it('should render the correct buttons when app status is stopped', () => {
+    // Arrange
+    const onStart = jest.fn();
+    const onRemove = jest.fn();
+    // @ts-expect-error
+    const { getByText } = render(<AppActions status={AppStatusEnum.Stopped} app={app} onStart={onStart} onUninstall={onRemove} />);
+
+    // Act
+    fireEvent.click(getByText('Start'));
+    fireEvent.click(getByText('Remove'));
+
+    // Assert
+    expect(getByText('Start')).toBeInTheDocument();
+    expect(getByText('Remove')).toBeInTheDocument();
+    expect(onStart).toHaveBeenCalled();
+    expect(onRemove).toHaveBeenCalled();
+  });
+
+  it('should render the correct buttons when app status is running', () => {
+    // @ts-expect-error
+    const { getByText } = render(<AppActions status={AppStatusEnum.Running} app={app} />);
+    expect(getByText('Stop')).toBeInTheDocument();
+    expect(getByText('Open')).toBeInTheDocument();
+    expect(getByText('Settings')).toBeInTheDocument();
+  });
+
+  it('should render the correct buttons when app status is starting', () => {
+    // @ts-expect-error
+    render(<AppActions status={AppStatusEnum.Starting} app={app} />);
+    expect(screen.getByText('Cancel')).toBeInTheDocument();
+    expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
+  });
+
+  it('should render the correct buttons when app status is stopping', () => {
+    // @ts-expect-error
+    render(<AppActions status={AppStatusEnum.Stopping} app={app} />);
+    expect(screen.getByText('Cancel')).toBeInTheDocument();
+    expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
+  });
+
+  it('should render the correct buttons when app status is removing', () => {
+    // @ts-expect-error
+    render(<AppActions status={AppStatusEnum.Uninstalling} app={app} />);
+    expect(screen.getByText('Cancel')).toBeInTheDocument();
+    expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
+  });
+
+  it('should render the correct buttons when app status is installing', () => {
+    // @ts-ignore
+    render(<AppActions status={AppStatusEnum.Installing} app={app} />);
+    expect(screen.getByText('Cancel')).toBeInTheDocument();
+    expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
+  });
+
+  it('should render the correct buttons when app status is updating', () => {
+    // @ts-expect-error
+    render(<AppActions status={AppStatusEnum.Updating} app={app} />);
+    expect(screen.getByText('Cancel')).toBeInTheDocument();
+    expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
+  });
+
+  it('should render the correct buttons when app status is missing', () => {
+    // @ts-expect-error
+    render(<AppActions status={AppStatusEnum.Missing} app={app} />);
+    expect(screen.getByText('Install')).toBeInTheDocument();
+  });
+});

+ 12 - 15
packages/dashboard/src/modules/Apps/components/AppActions.tsx → packages/dashboard/src/modules/Apps/components/AppActions/AppActions.tsx

@@ -2,8 +2,8 @@ import { IconDownload, IconExternalLink, IconPlayerPause, IconPlayerPlay, IconSe
 import clsx from 'clsx';
 import React from 'react';
 
-import { Button } from '../../../components/ui/Button';
-import { AppInfo, AppStatusEnum } from '../../../generated/graphql';
+import { Button } from '../../../../components/ui/Button';
+import { AppInfo, AppStatusEnum } from '../../../../generated/graphql';
 
 interface IProps {
   app: AppInfo;
@@ -32,7 +32,7 @@ const ActionButton: React.FC<BtnProps> = (props) => {
   const { Icon, onClick, title, loading, color, width = 140 } = props;
 
   return (
-    <Button loading={loading} onClick={onClick} width={width} className={clsx('me-2 px-4 mt-2', [`btn-${color}`])}>
+    <Button loading={loading} data-testid={`action-button-${title?.toLowerCase()}`} onClick={onClick} width={width} className={clsx('me-2 px-4 mt-2', [`btn-${color}`])}>
       {title}
       {Icon && <Icon className="ms-1" size={14} />}
     </Button>
@@ -44,15 +44,15 @@ export const AppActions: React.FC<IProps> = ({ app, status, onInstall, onUninsta
 
   const buttons: JSX.Element[] = [];
 
-  const StartButton = <ActionButton Icon={IconPlayerPlay} onClick={onStart} title="Start" color="success" />;
-  const RemoveButton = <ActionButton Icon={IconTrash} onClick={onUninstall} title="Remove" color="danger" />;
-  const SettingsButton = <ActionButton Icon={IconSettings} onClick={onUpdateSettings} title="Settings" />;
-  const StopButton = <ActionButton Icon={IconPlayerPause} onClick={onStop} title="Stop" color="danger" />;
-  const OpenButton = <ActionButton Icon={IconExternalLink} onClick={onOpen} title="Open" />;
-  const LoadingButtion = <ActionButton loading onClick={() => null} color="success" />;
-  const CancelButton = <ActionButton Icon={IconX} onClick={onCancel} title="Cancel" />;
-  const InstallButton = <ActionButton onClick={onInstall} title="Install" color="success" />;
-  const UpdateButton = <ActionButton Icon={IconDownload} onClick={onUpdate} width={null} title="Update" color="success" />;
+  const StartButton = <ActionButton key="start" Icon={IconPlayerPlay} onClick={onStart} title="Start" color="success" />;
+  const RemoveButton = <ActionButton key="remove" Icon={IconTrash} onClick={onUninstall} title="Remove" color="danger" />;
+  const SettingsButton = <ActionButton key="settings" Icon={IconSettings} onClick={onUpdateSettings} title="Settings" />;
+  const StopButton = <ActionButton key="stop" Icon={IconPlayerPause} onClick={onStop} title="Stop" color="danger" />;
+  const OpenButton = <ActionButton key="open" Icon={IconExternalLink} onClick={onOpen} title="Open" />;
+  const LoadingButtion = <ActionButton key="loading" loading onClick={() => null} color="success" title="Loading" />;
+  const CancelButton = <ActionButton key="cancel" Icon={IconX} onClick={onCancel} title="Cancel" />;
+  const InstallButton = <ActionButton key="install" onClick={onInstall} title="Install" color="success" />;
+  const UpdateButton = <ActionButton key="update" Icon={IconDownload} onClick={onUpdate} width={null} title="Update" color="success" />;
 
   switch (status) {
     case AppStatusEnum.Stopped:
@@ -80,9 +80,6 @@ export const AppActions: React.FC<IProps> = ({ app, status, onInstall, onUninsta
     case AppStatusEnum.Uninstalling:
     case AppStatusEnum.Starting:
     case AppStatusEnum.Stopping:
-      buttons.push(LoadingButtion, CancelButton);
-      break;
-
     case AppStatusEnum.Updating:
       buttons.push(LoadingButtion, CancelButton);
       break;

+ 1 - 0
packages/dashboard/src/modules/Apps/components/AppActions/index.ts

@@ -0,0 +1 @@
+export { AppActions } from './AppActions';

+ 81 - 0
packages/dashboard/src/modules/Apps/components/InstallForm/InstallForm.test.tsx

@@ -0,0 +1,81 @@
+import React from 'react';
+import { fireEvent, render, screen, waitFor } from '../../../../../tests/test-utils';
+import { FieldTypesEnum, FormField } from '../../../../generated/graphql';
+import { InstallForm } from './InstallForm';
+
+describe('Test: InstallForm', () => {
+  it('should render the form', () => {
+    render(<InstallForm formFields={[]} onSubmit={jest.fn} />);
+
+    expect(screen.getByText('Install')).toBeInTheDocument();
+  });
+
+  it('should render fields with correct types', () => {
+    const formFields: FormField[] = [
+      { env_variable: 'test', label: 'test', type: FieldTypesEnum.Text },
+      { env_variable: 'test2', label: 'test2', type: FieldTypesEnum.Password },
+      { env_variable: 'test3', label: 'test3', type: FieldTypesEnum.Email },
+      { env_variable: 'test4', label: 'test4', type: FieldTypesEnum.Url },
+      { env_variable: 'test5', label: 'test5', type: FieldTypesEnum.Number },
+    ];
+
+    render(<InstallForm formFields={formFields} onSubmit={jest.fn} />);
+
+    expect(screen.getByLabelText('test')).toBeInTheDocument();
+    expect(screen.getByLabelText('test2')).toBeInTheDocument();
+    expect(screen.getByLabelText('test3')).toBeInTheDocument();
+    expect(screen.getByLabelText('test4')).toBeInTheDocument();
+    expect(screen.getByLabelText('test5')).toBeInTheDocument();
+  });
+
+  it('should call submit function with correct values', async () => {
+    const formFields: FormField[] = [{ env_variable: 'test-env', label: 'test-field', type: FieldTypesEnum.Text }];
+
+    const onSubmit = jest.fn();
+
+    render(<InstallForm formFields={formFields} onSubmit={onSubmit} />);
+
+    fireEvent.change(screen.getByLabelText('test-field'), { target: { value: 'test' } });
+    screen.getByText('Install').click();
+
+    await waitFor(() => {
+      expect(onSubmit).toHaveBeenCalledWith({
+        'test-env': 'test',
+      });
+    });
+  });
+
+  it('should show validation error when required field is empty', async () => {
+    const formFields: FormField[] = [{ env_variable: 'test-env', label: 'test-field', type: FieldTypesEnum.Text, required: true }];
+
+    const onSubmit = jest.fn();
+
+    render(<InstallForm formFields={formFields} onSubmit={onSubmit} />);
+
+    screen.getByText('Install').click();
+
+    await waitFor(() => {
+      expect(screen.getByText('test-field is required')).toBeInTheDocument();
+    });
+  });
+
+  it('should pre-fill fields if initialValues are provided', () => {
+    const formFields: FormField[] = [{ env_variable: 'test-env', label: 'test-field', type: FieldTypesEnum.Text, required: true }];
+
+    const onSubmit = jest.fn();
+
+    render(<InstallForm formFields={formFields} onSubmit={onSubmit} initalValues={{ 'test-env': 'test' }} />);
+
+    expect(screen.getByLabelText('test-field')).toHaveValue('test');
+  });
+
+  it('should render expose switch when app is exposable', () => {
+    const formFields: FormField[] = [{ env_variable: 'test-env', label: 'test-field', type: FieldTypesEnum.Text, required: true }];
+
+    const onSubmit = jest.fn();
+
+    render(<InstallForm formFields={formFields} onSubmit={onSubmit} exposable />);
+
+    expect(screen.getByLabelText('Expose app')).toBeInTheDocument();
+  });
+});

+ 17 - 7
packages/dashboard/src/modules/Apps/components/InstallForm.tsx → packages/dashboard/src/modules/Apps/components/InstallForm/InstallForm.tsx

@@ -1,11 +1,11 @@
 import React, { useEffect } from 'react';
 import { useForm } from 'react-hook-form';
 
-import { AppInfo, FormField } from '../../../generated/graphql';
-import { Button } from '../../../components/ui/Button';
-import { Switch } from '../../../components/ui/Switch';
-import { Input } from '../../../components/ui/Input';
-import { validateAppConfig } from '../utils/validators';
+import { AppInfo, FormField } from '../../../../generated/graphql';
+import { Button } from '../../../../components/ui/Button';
+import { Switch } from '../../../../components/ui/Switch';
+import { Input } from '../../../../components/ui/Input';
+import { validateAppConfig } from '../../utils/validators';
 
 interface IProps {
   formFields: AppInfo['form_fields'];
@@ -44,7 +44,15 @@ export const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValu
   }, [initalValues, setValue]);
 
   const renderField = (field: FormField) => (
-    <Input {...register(field.env_variable)} label={field.label} error={errors[field.env_variable]?.message} disabled={loading} className="mb-3" placeholder={field.hint || field.label} />
+    <Input
+      key={field.env_variable}
+      {...register(field.env_variable)}
+      label={field.label}
+      error={errors[field.env_variable]?.message}
+      disabled={loading}
+      className="mb-3"
+      placeholder={field.hint || field.label}
+    />
   );
 
   const renderExposeForm = () => (
@@ -75,8 +83,10 @@ export const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValu
     }
   };
 
+  const name = initalValues ? 'update' : 'install';
+
   return (
-    <form className="flex flex-col" onSubmit={handleSubmit(validate)}>
+    <form data-testid={`${name}-form`} className="flex flex-col" onSubmit={handleSubmit(validate)}>
       {formFields.filter(typeFilter).map(renderField)}
       {exposable && renderExposeForm()}
       <Button type="submit" className="btn-success">

+ 1 - 0
packages/dashboard/src/modules/Apps/components/InstallForm/index.ts

@@ -0,0 +1 @@
+export { InstallForm } from './InstallForm';

+ 61 - 0
packages/dashboard/src/modules/Apps/components/InstallModal/InstallModal.test.tsx

@@ -0,0 +1,61 @@
+import React from 'react';
+import { InstallModal } from './InstallModal';
+import { FieldTypesEnum } from '../../../../generated/graphql';
+import { fireEvent, render, screen, waitFor } from '../../../../../tests/test-utils';
+
+describe('InstallModal', () => {
+  const app = {
+    name: 'My App',
+    form_fields: [
+      { name: 'hostname', label: 'Hostname', type: FieldTypesEnum.Text, required: true, env_variable: 'test_hostname' },
+      { name: 'password', label: 'Password', type: FieldTypesEnum.Text, required: true, env_variable: 'test_password' },
+    ],
+    exposable: true,
+  };
+
+  it('renders with the correct title', () => {
+    render(<InstallModal app={app} isOpen onClose={jest.fn()} onSubmit={jest.fn()} />);
+
+    expect(screen.getByText(`Install ${app.name}`)).toBeInTheDocument();
+  });
+
+  it('renders the InstallForm with the correct props', () => {
+    render(<InstallModal app={app} isOpen onClose={jest.fn()} onSubmit={jest.fn()} />);
+
+    expect(screen.getByLabelText(app.form_fields[0].label)).toBeInTheDocument();
+    expect(screen.getByLabelText(app.form_fields[1].label)).toBeInTheDocument();
+  });
+
+  it('calls onClose when the close button is clicked', () => {
+    const onClose = jest.fn();
+    render(<InstallModal app={app} isOpen onClose={onClose} onSubmit={jest.fn()} />);
+
+    fireEvent.click(screen.getByTestId('modal-close-button'));
+    expect(onClose).toHaveBeenCalled();
+  });
+
+  it('calls onSubmit with the correct values when the form is submitted', async () => {
+    const onSubmit = jest.fn();
+    render(<InstallModal app={app} isOpen onClose={jest.fn()} onSubmit={onSubmit} />);
+
+    const hostnameInput = screen.getByLabelText(app.form_fields[0].label);
+    const passwordInput = screen.getByLabelText(app.form_fields[1].label);
+
+    fireEvent.change(hostnameInput, { target: { value: 'test-hostname' } });
+    expect(hostnameInput).toHaveValue('test-hostname');
+    fireEvent.change(passwordInput, { target: { value: 'test-password' } });
+    expect(passwordInput).toHaveValue('test-password');
+
+    fireEvent.click(screen.getByText('Install'));
+
+    await waitFor(() => {
+      expect(onSubmit).toHaveBeenCalled();
+    });
+
+    expect(onSubmit).toHaveBeenCalledWith({
+      test_hostname: 'test-hostname',
+      test_password: 'test-password',
+      exposed: false,
+    });
+  });
+});

+ 4 - 4
packages/dashboard/src/modules/Apps/components/InstallModal.tsx → packages/dashboard/src/modules/Apps/components/InstallModal/InstallModal.tsx

@@ -1,10 +1,10 @@
 import React from 'react';
-import { InstallForm } from './InstallForm';
-import { AppInfo } from '../../../generated/graphql';
-import { Modal, ModalBody, ModalHeader } from '../../../components/ui/Modal';
+import { InstallForm } from '../InstallForm';
+import { AppInfo } from '../../../../generated/graphql';
+import { Modal, ModalBody, ModalHeader } from '../../../../components/ui/Modal';
 
 interface IProps {
-  app: AppInfo;
+  app: Pick<AppInfo, 'name' | 'form_fields' | 'exposable'>;
   isOpen: boolean;
   onClose: () => void;
   onSubmit: (values: Record<string, any>) => void;

+ 1 - 0
packages/dashboard/src/modules/Apps/components/InstallModal/index.ts

@@ -0,0 +1 @@
+export { InstallModal } from './InstallModal';

+ 48 - 0
packages/dashboard/src/modules/Apps/components/UpdateModal/UpdateModal.test.tsx

@@ -0,0 +1,48 @@
+import React from 'react';
+import { fireEvent, render, screen } from '../../../../../tests/test-utils';
+import { UpdateModal } from './UpdateModal';
+
+describe('UpdateModal', () => {
+  const app = { name: 'My App' };
+  const newVersion = '1.2.3';
+
+  it('renders with the correct title and version number', () => {
+    // Arrange
+    render(<UpdateModal app={app} newVersion={newVersion} isOpen onClose={jest.fn()} onConfirm={jest.fn()} />);
+
+    // Assert
+    expect(screen.getByText(`Update ${app.name} ?`)).toBeInTheDocument();
+    expect(screen.getByText(`${newVersion}`)).toBeInTheDocument();
+  });
+
+  it('should not render when isOpen is false', () => {
+    // Arrange
+    render(<UpdateModal app={app} newVersion={newVersion} isOpen={false} onClose={jest.fn()} onConfirm={jest.fn()} />);
+    const modal = screen.queryByTestId('modal');
+
+    // Assert (modal should have style display: none)
+    expect(modal).toHaveStyle('display: none');
+  });
+
+  it('calls onClose when the close button is clicked', () => {
+    // Arrange
+    const onClose = jest.fn();
+    render(<UpdateModal app={app} newVersion={newVersion} isOpen onClose={onClose} onConfirm={jest.fn()} />);
+
+    // Act
+    const closeButton = screen.getByTestId('modal-close-button');
+    fireEvent.click(closeButton);
+    expect(onClose).toHaveBeenCalled();
+  });
+
+  it('calls onConfirm when the update button is clicked', () => {
+    // Arrange
+    const onConfirm = jest.fn();
+    render(<UpdateModal app={app} newVersion={newVersion} isOpen onClose={jest.fn()} onConfirm={onConfirm} />);
+
+    // Act
+    const updateButton = screen.getByText('Update');
+    fireEvent.click(updateButton);
+    expect(onConfirm).toHaveBeenCalled();
+  });
+});

+ 4 - 4
packages/dashboard/src/modules/Apps/components/UpdateModal.tsx → packages/dashboard/src/modules/Apps/components/UpdateModal/UpdateModal.tsx

@@ -1,12 +1,12 @@
 import React from 'react';
-import { Button } from '../../../components/ui/Button';
-import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../components/ui/Modal';
+import { Button } from '../../../../components/ui/Button';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../../components/ui/Modal';
 
-import { AppInfo } from '../../../generated/graphql';
+import { AppInfo } from '../../../../generated/graphql';
 
 interface IProps {
   newVersion: string;
-  app: AppInfo;
+  app: Pick<AppInfo, 'name'>;
   isOpen: boolean;
   onClose: () => void;
   onConfirm: () => void;

+ 1 - 0
packages/dashboard/src/modules/Apps/components/UpdateModal/index.ts

@@ -0,0 +1 @@
+export { UpdateModal } from './UpdateModal';

+ 153 - 0
packages/dashboard/src/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.test.tsx

@@ -0,0 +1,153 @@
+import { graphql } from 'msw';
+import React from 'react';
+import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../tests/test-utils';
+import { AppStatusEnum } from '../../../../generated/graphql';
+import { createAppEntity } from '../../../../mocks/fixtures/app.fixtures';
+import { server } from '../../../../mocks/server';
+import { useToastStore } from '../../../../state/toastStore';
+import { AppDetailsContainer } from './AppDetailsContainer';
+
+describe('Test: AppDetailsContainer', () => {
+  describe('Test: UI', () => {
+    it('should render', async () => {
+      // Arrange
+      const app = createAppEntity({});
+      render(<AppDetailsContainer app={app} info={app.info} />);
+
+      // Assert
+      expect(screen.getByText(app.info.short_desc)).toBeInTheDocument();
+    });
+
+    it('should display update button when update is available', async () => {
+      // Arrange
+      const app = createAppEntity({ overrides: { updateInfo: { current: 2, latest: 3 } } });
+      render(<AppDetailsContainer app={app} info={app.info} />);
+
+      // Assert
+      expect(screen.getByTestId('action-button-update')).toBeInTheDocument();
+    });
+
+    it('should display install button when app is not installed', async () => {
+      // Arrange
+      const app = createAppEntity({ overrides: { status: AppStatusEnum.Missing } });
+
+      render(<AppDetailsContainer app={app} info={app.info} />);
+
+      // Assert
+      expect(screen.getByTestId('action-button-install')).toBeInTheDocument();
+    });
+
+    it('should display uninstall and start button when app is stopped', async () => {
+      // Arrange
+      const app = createAppEntity({ overrides: { status: AppStatusEnum.Stopped } });
+
+      render(<AppDetailsContainer app={app} info={app.info} />);
+
+      // Assert
+      expect(screen.getByTestId('action-button-remove')).toBeInTheDocument();
+      expect(screen.getByTestId('action-button-start')).toBeInTheDocument();
+    });
+
+    it('should display stop, open and settings buttons when app is running', async () => {
+      // Arrange
+      const app = createAppEntity({ overrides: { status: AppStatusEnum.Running } });
+      render(<AppDetailsContainer app={app} info={app.info} />);
+
+      // Assert
+      expect(screen.getByTestId('action-button-stop')).toBeInTheDocument();
+      expect(screen.getByTestId('action-button-open')).toBeInTheDocument();
+      expect(screen.getByTestId('action-button-settings')).toBeInTheDocument();
+    });
+
+    it('should not display update button when update is not available', async () => {
+      // Arrange
+      const app = createAppEntity({ overrides: { updateInfo: { current: 3, latest: 3 } } });
+      render(<AppDetailsContainer app={app} info={app.info} />);
+
+      // Assert
+      expect(screen.queryByTestId('action-button-update')).not.toBeInTheDocument();
+    });
+
+    it('should not display open button when app has no_gui set to true', async () => {
+      // Arrange
+      const app = createAppEntity({ overridesInfo: { no_gui: true } });
+      render(<AppDetailsContainer app={app} info={app.info} />);
+
+      // Assert
+      expect(screen.queryByTestId('action-button-open')).not.toBeInTheDocument();
+    });
+  });
+
+  describe('Test: Open app', () => {
+    it('should call window.open with the correct url when open button is clicked', async () => {
+      // Arrange
+      const app = createAppEntity({});
+      const spy = jest.spyOn(window, 'open').mockImplementation(() => null);
+      render(<AppDetailsContainer app={app} info={app.info} />);
+
+      // Act
+      const openButton = screen.getByTestId('action-button-open');
+      openButton.click();
+
+      // Assert
+      expect(spy).toHaveBeenCalledWith(`http://localhost:${app.info.port}`, '_blank', 'noreferrer');
+    });
+
+    it('should open with https when app info has https set to true', async () => {
+      // Arrange
+      const app = createAppEntity({ overridesInfo: { https: true } });
+      const spy = jest.spyOn(window, 'open').mockImplementation(() => null);
+      render(<AppDetailsContainer app={app} info={app.info} />);
+
+      // Act
+      const openButton = screen.getByTestId('action-button-open');
+      openButton.click();
+
+      // Assert
+      expect(spy).toHaveBeenCalledWith(`https://localhost:${app.info.port}`, '_blank', 'noreferrer');
+    });
+  });
+
+  describe('Test: Install app', () => {
+    const installFn = jest.fn();
+    const fakeInstallHandler = graphql.mutation('InstallApp', (req, res, ctx) => {
+      installFn(req.variables);
+      return res(ctx.data({ installApp: { id: 'id', status: '', __typename: '' } }));
+    });
+
+    it('should call install mutation when install form is submitted', async () => {
+      // Arrange
+      server.use(fakeInstallHandler);
+      const app = createAppEntity({ overrides: { status: AppStatusEnum.Missing } });
+      render(<AppDetailsContainer app={app} info={app.info} />);
+
+      // Act
+      const installForm = screen.getByTestId('install-form');
+      fireEvent.submit(installForm);
+
+      await waitFor(() => {
+        expect(installFn).toHaveBeenCalledWith({
+          input: { id: app.id, form: {}, exposed: false, domain: '' },
+        });
+      });
+    });
+
+    it('should display a toast error when install mutation fails', async () => {
+      // Arrange
+      const { result } = renderHook(() => useToastStore());
+      server.use(graphql.mutation('InstallApp', (req, res, ctx) => res(ctx.errors([{ message: 'my big error' }]))));
+      const app = createAppEntity({ overrides: { status: AppStatusEnum.Missing } });
+      render(<AppDetailsContainer app={app} info={app.info} />);
+
+      // Act
+      const installForm = screen.getByTestId('install-form');
+      fireEvent.submit(installForm);
+
+      await waitFor(() => {
+        expect(result.current.toasts).toHaveLength(1);
+        expect(result.current.toasts[0].description).toEqual('my big error');
+        expect(result.current.toasts[0].status).toEqual('error');
+      });
+    });
+  });
+});

+ 2 - 2
packages/dashboard/src/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.tsx

@@ -18,12 +18,12 @@ import {
 } from '../../../../generated/graphql';
 import { AppActions } from '../../components/AppActions';
 import { AppDetailsTabs } from '../../components/AppDetailsTabs';
-import { FormValues } from '../../components/InstallForm';
 import { InstallModal } from '../../components/InstallModal';
 import { StopModal } from '../../components/StopModal';
 import { UninstallModal } from '../../components/UninstallModal';
 import { UpdateModal } from '../../components/UpdateModal';
 import { UpdateSettingsModal } from '../../components/UpdateSettingsModal';
+import { FormValues } from '../../components/InstallForm/InstallForm';
 
 interface IProps {
   app: Pick<App, 'id' | 'updateInfo' | 'config' | 'exposed' | 'domain' | 'status'>;
@@ -147,7 +147,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, info }) => {
   const newVersion = [app?.updateInfo?.dockerVersion ? `${app?.updateInfo?.dockerVersion}` : '', `(${String(app?.updateInfo?.latest)})`].join(' ');
 
   return (
-    <div className="card">
+    <div className="card" data-testid="app-details">
       <InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.close} app={info} />
       <StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.close} app={info} />
       <UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.close} app={info} />

+ 63 - 0
packages/dashboard/src/modules/Apps/pages/AppDetailsPage/AppDetailsPage.test.tsx

@@ -0,0 +1,63 @@
+import React from 'react';
+import { render, screen, waitFor } from '../../../../../tests/test-utils';
+import appHandlers, { mockedApps, mockInstalledAppIds } from '../../../../mocks/handlers/appHandlers';
+import { server } from '../../../../mocks/server';
+import { AppDetailsPage } from './AppDetailsPage';
+
+describe('AppDetailsPage', () => {
+  it('should render', async () => {
+    // Arrange
+    render(<AppDetailsPage appId={mockInstalledAppIds[0]} />);
+    await waitFor(() => {
+      expect(screen.getByTestId('app-details')).toBeInTheDocument();
+    });
+  });
+
+  it('should correctly pass the appId to the AppDetailsContainer', async () => {
+    // Arrange
+    const props = AppDetailsPage.getInitialProps?.({ query: { id: mockInstalledAppIds[0] } } as any);
+
+    // Assert
+    expect(props).toHaveProperty('appId', mockInstalledAppIds[0]);
+  });
+
+  it('should transform the appId to a string', async () => {
+    // Arrange
+    const props = AppDetailsPage.getInitialProps?.({ query: { id: [123] } } as any);
+
+    // Assert
+    expect(props).toHaveProperty('appId', '123');
+  });
+
+  it('should render the error page when an error occurs', async () => {
+    // Arrange
+    server.use(appHandlers.getAppError);
+    render(<AppDetailsPage appId={mockInstalledAppIds[0]} />);
+    await waitFor(() => {
+      expect(screen.getByTestId('error-page')).toBeInTheDocument();
+    });
+
+    // Assert
+    expect(screen.getByText('test-error')).toHaveTextContent('test-error');
+  });
+
+  it('should set the breadcrumb prop of the Layout component to an array containing two elements with the correct name and href properties', async () => {
+    // Arrange
+    const app = mockedApps[0];
+    render(<AppDetailsPage appId={app.id} />);
+    await waitFor(() => {
+      expect(screen.getByTestId('app-details')).toBeInTheDocument();
+    });
+
+    // Act
+    const breadcrumbs = await screen.findAllByTestId('breadcrumb-item');
+    const breadcrumbsLinks = await screen.findAllByTestId('breadcrumb-link');
+
+    // Assert
+    expect(breadcrumbs[0]).toHaveTextContent('Apps');
+    expect(breadcrumbsLinks[0]).toHaveAttribute('href', '/apps');
+
+    expect(breadcrumbs[1]).toHaveTextContent(app.name);
+    expect(breadcrumbsLinks[1]).toHaveAttribute('href', `/apps/${app.id}`);
+  });
+});

+ 3 - 1
packages/dashboard/src/modules/Apps/pages/AppDetailsPage/AppDetailsPage.tsx

@@ -1,6 +1,7 @@
 import { NextPage } from 'next';
 import React from 'react';
 import { Layout } from '../../../../components/Layout';
+import { ErrorPage } from '../../../../components/ui/ErrorPage';
 import { useGetAppQuery } from '../../../../generated/graphql';
 import { AppDetailsContainer } from '../../containers/AppDetailsContainer/AppDetailsContainer';
 
@@ -9,7 +10,7 @@ interface IProps {
 }
 
 export const AppDetailsPage: NextPage<IProps> = ({ appId }) => {
-  const { data, loading } = useGetAppQuery({ variables: { appId }, pollInterval: 3000 });
+  const { data, loading, error } = useGetAppQuery({ variables: { appId }, pollInterval: 3000 });
 
   const breadcrumb = [
     { name: 'Apps', href: '/apps' },
@@ -19,6 +20,7 @@ export const AppDetailsPage: NextPage<IProps> = ({ appId }) => {
   return (
     <Layout breadcrumbs={breadcrumb} loading={!data?.getApp && loading} title={data?.getApp.info?.name}>
       {data?.getApp.info && <AppDetailsContainer app={data?.getApp} info={data.getApp.info} />}
+      {error && <ErrorPage error={error.message} />}
     </Layout>
   );
 };

+ 104 - 0
packages/dashboard/src/modules/Apps/pages/AppsPage/AppsPage.test.tsx

@@ -0,0 +1,104 @@
+import React from 'react';
+import { fireEvent, render, screen, waitFor } from '../../../../../tests/test-utils';
+import appHandlers, { mockInstalledAppIds } from '../../../../mocks/handlers/appHandlers';
+import { server } from '../../../../mocks/server';
+import { AppsPage } from './AppsPage';
+
+const pushFn = jest.fn();
+jest.mock('next/router', () => {
+  const actualRouter = jest.requireActual('next-router-mock');
+
+  return {
+    ...actualRouter,
+    useRouter: () => ({
+      ...actualRouter.useRouter(),
+      push: pushFn,
+    }),
+  };
+});
+
+describe('AppsPage', () => {
+  it('should render', async () => {
+    // Arrange
+    render(<AppsPage />);
+
+    // Assert
+    await waitFor(() => {
+      expect(screen.getByTestId('apps-list')).toBeInTheDocument();
+    });
+  });
+
+  it('should render all installed apps', async () => {
+    // Arrange
+    render(<AppsPage />);
+    await waitFor(() => {
+      expect(screen.getByTestId('apps-list')).toBeInTheDocument();
+    });
+
+    // Assert
+    const displayedAppIds = screen.getAllByTestId(/app-tile-/);
+    expect(displayedAppIds).toHaveLength(mockInstalledAppIds.length);
+  });
+
+  it('Should not render app tile if app info is not available', async () => {
+    // Arrange
+    server.use(appHandlers.installedAppsNoInfo);
+    render(<AppsPage />);
+    await waitFor(() => {
+      expect(screen.getByTestId('apps-list')).toBeInTheDocument();
+    });
+
+    // Assert
+    expect(screen.queryByTestId(/app-tile-/)).not.toBeInTheDocument();
+  });
+});
+
+describe('AppsPage - Empty', () => {
+  beforeEach(() => {
+    server.use(appHandlers.installedAppsEmpty);
+  });
+
+  it('should render empty page if no app is installed', async () => {
+    // Arrange
+    render(<AppsPage />);
+    await waitFor(() => {
+      expect(screen.getByTestId('empty-page')).toBeInTheDocument();
+    });
+
+    // Assert
+    expect(screen.queryByTestId('apps-list')).not.toBeInTheDocument();
+  });
+
+  it('should trigger navigation to app store on click on action button', async () => {
+    // Arrange
+    render(<AppsPage />);
+    await waitFor(() => {
+      expect(screen.getByTestId('empty-page')).toBeInTheDocument();
+    });
+
+    // Act
+    const actionButton = screen.getByTestId('empty-page-action');
+    await fireEvent.click(actionButton);
+
+    // Assert
+    expect(actionButton).toHaveTextContent('Go to app store');
+    expect(pushFn).toHaveBeenCalledWith('/app-store');
+  });
+});
+
+describe('AppsPage - Error', () => {
+  beforeEach(() => {
+    server.use(appHandlers.installedAppsError);
+  });
+
+  it('should render error page if an error occurs', async () => {
+    render(<AppsPage />);
+
+    await waitFor(() => {
+      expect(screen.getByTestId('error-page')).toBeInTheDocument();
+    });
+
+    expect(screen.getByText('test-error')).toHaveTextContent('test-error');
+    expect(screen.queryByTestId('apps-list')).not.toBeInTheDocument();
+  });
+});

+ 9 - 3
packages/dashboard/src/modules/Apps/pages/AppsPage/AppsPage.tsx

@@ -5,9 +5,10 @@ import { AppTile } from '../../../../components/AppTile';
 import { InstalledAppsQuery, useInstalledAppsQuery } from '../../../../generated/graphql';
 import { Layout } from '../../../../components/Layout';
 import { EmptyPage } from '../../../../components/ui/EmptyPage';
+import { ErrorPage } from '../../../../components/ui/ErrorPage';
 
 export const AppsPage: NextPage = () => {
-  const { data, loading } = useInstalledAppsQuery({ pollInterval: 1000 });
+  const { data, loading, error } = useInstalledAppsQuery({ pollInterval: 1000 });
 
   const renderApp = (app: InstalledAppsQuery['installedApps'][0]) => {
     const updateAvailable = Number(app.updateInfo?.current) < Number(app.updateInfo?.latest);
@@ -22,10 +23,15 @@ export const AppsPage: NextPage = () => {
   return (
     <Layout loading={loading || !data?.installedApps} title="My Apps">
       <div>
-        {Boolean(data?.installedApps.length) && <div className="row row-cards">{data?.installedApps.map(renderApp)}</div>}
-        {data?.installedApps.length === 0 && (
+        {Boolean(data?.installedApps.length) && (
+          <div className="row row-cards" data-testid="apps-list">
+            {data?.installedApps.map(renderApp)}
+          </div>
+        )}
+        {!loading && data?.installedApps.length === 0 && (
           <EmptyPage title="No app installed" subtitle="Install an app from the app store to get started" onAction={() => router.push('/app-store')} actionLabel="Go to app store" />
         )}
+        {error && <ErrorPage error={error.message} />}
       </div>
     </Layout>
   );

+ 1 - 0
packages/dashboard/src/modules/Apps/utils/validators/index.ts

@@ -0,0 +1 @@
+export { validateAppConfig } from './validators';

+ 256 - 0
packages/dashboard/src/modules/Apps/utils/validators/validators.test.tsx

@@ -0,0 +1,256 @@
+import { FieldTypesEnum, FormField } from '../../../../generated/graphql';
+import { validateAppConfig, validateField } from './validators';
+
+describe('Test: validateField', () => {
+  it('should return "field label is required" if the field is required and no value is provided', () => {
+    const field: FormField = {
+      label: 'Username',
+      required: true,
+      env_variable: 'test',
+      type: FieldTypesEnum.Text,
+    };
+    const value: string | undefined | boolean = undefined;
+    const result = validateField(field, value);
+    expect(result).toEqual('Username is required');
+  });
+
+  it('should return "field label must be less than field.max characters" if the field type is text and the value is longer than the max value', () => {
+    const field: FormField = {
+      label: 'Description',
+      type: FieldTypesEnum.Text,
+      max: 10,
+      env_variable: 'test',
+    };
+    const value: string | undefined | boolean = 'This value is too long';
+    const result = validateField(field, value);
+    expect(result).toEqual('Description must be less than 10 characters');
+  });
+
+  it('should return "field label must be at least field.min characters" if the field type is text and the value is shorter than the min value', () => {
+    const field: FormField = {
+      label: 'Description',
+      type: FieldTypesEnum.Text,
+      min: 20,
+      env_variable: 'test',
+    };
+    const value: string | undefined | boolean = 'This is too short';
+    const result = validateField(field, value);
+
+    expect(result).toEqual('Description must be at least 20 characters');
+  });
+
+  it('should return "field label must be between field.min and field.max characters" if the field type is password and the value is not between the min and max values', () => {
+    const field: FormField = {
+      label: 'Password',
+      type: FieldTypesEnum.Password,
+      min: 6,
+      max: 10,
+      env_variable: 'test',
+    };
+    const value: string | undefined | boolean = 'pass';
+    const result = validateField(field, value);
+    expect(result).toEqual('Password must be between 6 and 10 characters');
+  });
+
+  it('should return "field label must be a valid email address" if the field type is email and the value is not a valid email', () => {
+    const field: FormField = {
+      label: 'Email',
+      type: FieldTypesEnum.Email,
+      env_variable: 'test',
+    };
+    const value: string | undefined | boolean = 'invalid-email';
+    const result = validateField(field, value);
+    expect(result).toEqual('Email must be a valid email address');
+  });
+
+  it('should return "field label must be a number" if the field type is number and the value is not a number', () => {
+    const field: FormField = {
+      label: 'Age',
+      type: FieldTypesEnum.Number,
+      env_variable: 'test',
+    };
+    const value: string | undefined | boolean = 'not a number';
+    const result = validateField(field, value);
+    expect(result).toEqual('Age must be a number');
+  });
+
+  it('should return "field label must be a valid domain" if the field type is fqdn and the value is not a valid domain', () => {
+    const field: FormField = {
+      label: 'Domain',
+      type: FieldTypesEnum.Fqdn,
+      env_variable: 'test',
+    };
+    const value: string | undefined | boolean = 'not.a.valid.c';
+    const result = validateField(field, value);
+    expect(result).toEqual('Domain must be a valid domain');
+  });
+
+  it('should return "field label must be a valid IP address" if the field type is ip and the value is not a valid IP address', () => {
+    const field: FormField = {
+      label: 'IP Address',
+      type: FieldTypesEnum.Ip,
+      env_variable: 'test',
+    };
+    const value: string | undefined | boolean = 'not a valid IP';
+    const result = validateField(field, value);
+    expect(result).toEqual('IP Address must be a valid IP address');
+  });
+
+  it('should return "field label must be a valid domain or IP address" if the field type is fqdnip and the value is not a valid domain or IP address', () => {
+    const field: FormField = {
+      label: 'Domain or IP',
+      type: FieldTypesEnum.Fqdnip,
+      env_variable: 'test',
+    };
+    const value: string | undefined | boolean = 'not a valid domain or IP';
+    const result = validateField(field, value);
+    expect(result).toEqual('Domain or IP must be a valid domain or IP address');
+  });
+
+  it('should return "field label must be a valid URL" if the field type is url and the value is not a valid URL', () => {
+    const field: FormField = {
+      label: 'Website',
+      type: FieldTypesEnum.Url,
+      env_variable: 'test',
+    };
+    const value: string | undefined | boolean = 'not a valid URL';
+    const result = validateField(field, value);
+    expect(result).toEqual('Website must be a valid URL');
+  });
+
+  it('should return undefined if the field is not required and no value is provided', () => {
+    const field: FormField = {
+      label: 'Username',
+      required: false,
+      env_variable: 'test',
+      type: FieldTypesEnum.Text,
+    };
+    const value: string | undefined | boolean = undefined;
+    const result = validateField(field, value);
+    expect(result).toBeUndefined();
+  });
+
+  it('should return undefined if the value is not a string', () => {
+    const field: FormField = {
+      label: 'Username',
+      required: true,
+      env_variable: 'test',
+      type: FieldTypesEnum.Text,
+    };
+    const value: string | undefined | boolean = true;
+    const result = validateField(field, value);
+    expect(result).toBeUndefined();
+  });
+});
+
+describe('Test: validateAppConfig', () => {
+  it('should return an object containing validation errors for each field in the config', () => {
+    const values = {
+      exposed: true,
+      domain: 'not a valid domain',
+      username: '',
+      password: 'pass',
+      email: 'invalid-email',
+    };
+    const fields: FormField[] = [
+      {
+        label: 'Username',
+        type: FieldTypesEnum.Text,
+        required: true,
+        env_variable: 'username',
+      },
+      {
+        label: 'Password',
+        type: FieldTypesEnum.Password,
+        required: true,
+        min: 6,
+        max: 10,
+        env_variable: 'password',
+      },
+      {
+        label: 'Email',
+        type: FieldTypesEnum.Email,
+        required: true,
+        env_variable: 'email',
+      },
+    ];
+    const result = validateAppConfig(values, fields);
+    expect(result).toEqual({
+      username: 'Username is required',
+      password: 'Password must be between 6 and 10 characters',
+      email: 'Email must be a valid email address',
+      domain: 'not a valid domain must be a valid domain',
+    });
+  });
+
+  it('should return an empty object if all fields are valid', () => {
+    const values = {
+      exposed: true,
+      domain: 'valid.domain',
+      username: 'username',
+      password: 'password',
+      email: 'valid@email.com',
+    };
+    const fields: FormField[] = [
+      {
+        label: 'Username',
+        type: FieldTypesEnum.Text,
+        required: true,
+        env_variable: 'username',
+      },
+      {
+        label: 'Password',
+        type: FieldTypesEnum.Password,
+        required: true,
+        min: 6,
+        max: 10,
+        env_variable: 'password',
+      },
+      {
+        label: 'Email',
+        type: FieldTypesEnum.Email,
+        required: true,
+        env_variable: 'email',
+      },
+    ];
+    const result = validateAppConfig(values, fields);
+    expect(result).toEqual({});
+  });
+
+  it('should not return validation errors for fields that are not required and no value is provided', () => {
+    const values = {
+      exposed: true,
+      domain: 'valid.domain',
+      username: '',
+    };
+    const fields: FormField[] = [
+      {
+        label: 'Username',
+        type: FieldTypesEnum.Text,
+        required: false,
+        env_variable: 'username',
+      },
+    ];
+    const result = validateAppConfig(values, fields);
+    expect(result).toEqual({});
+  });
+
+  it('should not return validation errors for domain if the app is not exposed', () => {
+    const values = {
+      exposed: false,
+      domain: '',
+      username: 'hello',
+    };
+
+    const fields: FormField[] = [
+      {
+        label: 'Username',
+        type: FieldTypesEnum.Text,
+        required: true,
+        env_variable: 'username',
+      },
+    ];
+    const result = validateAppConfig(values, fields);
+    expect(result).toEqual({});
+  });
+});

+ 2 - 2
packages/dashboard/src/modules/Apps/utils/validators.ts → packages/dashboard/src/modules/Apps/utils/validators/validators.ts

@@ -1,7 +1,7 @@
 import validator from 'validator';
-import { FieldTypesEnum, FormField } from '../../../generated/graphql';
+import { FieldTypesEnum, FormField } from '../../../../generated/graphql';
 
-const validateField = (field: FormField, value: string | undefined | boolean): string | undefined => {
+export const validateField = (field: FormField, value: string | undefined | boolean): string | undefined => {
   if (field.required && !value) {
     return `${field.label} is required`;
   }

+ 10 - 1
packages/dashboard/src/modules/Auth/components/AuthFormLayout/AuthFormLayout.tsx

@@ -10,7 +10,16 @@ export const AuthFormLayout: React.FC<IProps> = ({ children }) => (
   <div className="page page-center">
     <div className="container container-tight py-4">
       <div className="text-center mb-4">
-        <Image alt="Tipi logo" layout="intrinsic" src={getUrl('tipi.png')} height={50} width={50} />
+        <Image
+          alt="Tipi logo"
+          src={getUrl('tipi.png')}
+          height={50}
+          width={50}
+          style={{
+            maxWidth: '100%',
+            height: 'auto',
+          }}
+        />
       </div>
       <div className="card card-md">
         <div className="card-body">{children}</div>

+ 7 - 1
packages/dashboard/src/modules/Auth/components/LoginForm/LoginForm.tsx

@@ -22,16 +22,22 @@ export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
     register,
     handleSubmit,
     formState: { errors },
+    watch,
   } = useForm<FormValues>({
     resolver: zodResolver(schema),
   });
 
+  const watchEmail = watch('email');
+  const watchPassword = watch('password');
+
+  const isDisabled = !watchEmail || !watchPassword;
+
   return (
     <form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
       <h2 className="h2 text-center mb-3">Login to your account</h2>
       <Input {...register('email')} label="Email address" error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder="you@example.com" />
       <Input {...register('password')} label="Password" error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder="Your password" />
-      <Button loading={loading} type="submit" className="btn btn-primary w-100">
+      <Button disabled={isDisabled} loading={loading} type="submit" className="btn btn-primary w-100">
         Login
       </Button>
     </form>

+ 100 - 0
packages/dashboard/src/modules/Auth/containers/LoginContainer/LoginContainer.test.tsx

@@ -0,0 +1,100 @@
+import { faker } from '@faker-js/faker';
+import { graphql } from 'msw';
+import React from 'react';
+import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../tests/test-utils';
+import { useMeQuery } from '../../../../generated/graphql';
+import { server } from '../../../../mocks/server';
+import { useToastStore } from '../../../../state/toastStore';
+import { LoginContainer } from './LoginContainer';
+
+describe('Test: LoginContainer', () => {
+  it('should render without error', () => {
+    // Arrange
+    render(<LoginContainer />);
+
+    // Assert
+    expect(screen.getByText('Login')).toBeInTheDocument();
+  });
+
+  it('should have login button disabled if email and password are not provided', () => {
+    // Arrange
+    render(<LoginContainer />);
+    const loginButton = screen.getByRole('button', { name: 'Login' });
+
+    // Assert
+    expect(loginButton).toBeDisabled();
+  });
+
+  it('should have login button enabled if email and password are provided', () => {
+    // Arrange
+    render(<LoginContainer />);
+    const loginButton = screen.getByRole('button', { name: 'Login' });
+    const emailInput = screen.getByLabelText('Email address');
+    const passwordInput = screen.getByLabelText('Password');
+
+    // Act
+    fireEvent.change(emailInput, { target: { value: faker.internet.email() } });
+    fireEvent.change(passwordInput, { target: { value: faker.internet.password() } });
+
+    // Assert
+    expect(loginButton).toBeEnabled();
+  });
+
+  it('should call login mutation on submit', async () => {
+    // Arrange
+    const email = faker.internet.email();
+    const password = faker.internet.password();
+    const token = faker.datatype.uuid();
+
+    renderHook(() => useMeQuery());
+    const loginFn = jest.fn();
+    const fakeInstallHandler = graphql.mutation('Login', (req, res, ctx) => {
+      loginFn(req.variables.input);
+      sessionStorage.setItem('is-authenticated', email);
+      return res(ctx.data({ login: { token } }));
+    });
+
+    server.use(fakeInstallHandler);
+    render(<LoginContainer />);
+
+    // Act
+    const loginButton = screen.getByRole('button', { name: 'Login' });
+    const emailInput = screen.getByLabelText('Email address');
+    const passwordInput = screen.getByLabelText('Password');
+
+    fireEvent.change(emailInput, { target: { value: email } });
+    fireEvent.change(passwordInput, { target: { value: password } });
+    fireEvent.click(loginButton);
+
+    // Assert
+    await waitFor(() => expect(loginFn).toHaveBeenCalledWith({ username: email, password }));
+    expect(localStorage.getItem('token')).toEqual(token);
+  });
+
+  it('should show error message if login fails', async () => {
+    // Arrange
+    renderHook(() => useMeQuery());
+    const { result } = renderHook(() => useToastStore());
+    const fakeInstallHandler = graphql.mutation('Login', (req, res, ctx) => res(ctx.errors([{ message: 'my big error' }])));
+    server.use(fakeInstallHandler);
+    render(<LoginContainer />);
+
+    // Act
+    const loginButton = screen.getByRole('button', { name: 'Login' });
+    const emailInput = screen.getByLabelText('Email address');
+    const passwordInput = screen.getByLabelText('Password');
+
+    fireEvent.change(emailInput, { target: { value: 'test@test.com' } });
+    fireEvent.change(passwordInput, { target: { value: 'test' } });
+    fireEvent.click(loginButton);
+
+    // Assert
+    await waitFor(() => {
+      expect(result.current.toasts).toHaveLength(1);
+      expect(result.current.toasts[0].description).toEqual('my big error');
+      expect(result.current.toasts[0].status).toEqual('error');
+    });
+    const token = localStorage.getItem('token');
+    expect(token).toBeNull();
+  });
+});

+ 78 - 0
packages/dashboard/src/modules/Auth/containers/RegisterContainer/RegisterContainer.test.tsx

@@ -0,0 +1,78 @@
+import { faker } from '@faker-js/faker';
+import { graphql } from 'msw';
+import React from 'react';
+import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../tests/test-utils';
+import { useMeQuery } from '../../../../generated/graphql';
+import { server } from '../../../../mocks/server';
+import { useToastStore } from '../../../../state/toastStore';
+import { RegisterContainer } from './RegisterContainer';
+
+describe('Test: RegisterContainer', () => {
+  it('should render without error', () => {
+    render(<RegisterContainer />);
+
+    expect(screen.getByText('Register')).toBeInTheDocument();
+  });
+
+  it('should call register mutation on submit', async () => {
+    // Arrange
+    const email = faker.internet.email();
+    const password = faker.internet.password();
+    const token = faker.datatype.uuid();
+
+    renderHook(() => useMeQuery());
+    const registerFn = jest.fn();
+    const fakeRegisterHandler = graphql.mutation('Register', (req, res, ctx) => {
+      registerFn(req.variables.input);
+      sessionStorage.setItem('is-authenticated', email);
+      return res(ctx.data({ register: { token } }));
+    });
+    server.use(fakeRegisterHandler);
+    render(<RegisterContainer />);
+
+    // Act
+    const registerButton = screen.getByRole('button', { name: 'Register' });
+    const emailInput = screen.getByLabelText('Email address');
+    const passwordInput = screen.getByLabelText('Password');
+    const confirmPasswordInput = screen.getByLabelText('Confirm password');
+
+    fireEvent.change(emailInput, { target: { value: email } });
+    fireEvent.change(passwordInput, { target: { value: password } });
+    fireEvent.change(confirmPasswordInput, { target: { value: password } });
+    fireEvent.click(registerButton);
+
+    // Assert
+    await waitFor(() => expect(registerFn).toHaveBeenCalledWith({ username: email, password }));
+    expect(localStorage.getItem('token')).toEqual(token);
+  });
+
+  it('should show toast if register mutation fails', async () => {
+    // Arrange
+    const email = faker.internet.email();
+    const password = faker.internet.password();
+
+    renderHook(() => useMeQuery());
+    const { result } = renderHook(() => useToastStore());
+    const fakeRegisterHandler = graphql.mutation('Register', (req, res, ctx) => res(ctx.errors([{ message: 'my big error' }])));
+    server.use(fakeRegisterHandler);
+    render(<RegisterContainer />);
+
+    // Act
+    const registerButton = screen.getByRole('button', { name: 'Register' });
+    const emailInput = screen.getByLabelText('Email address');
+    const passwordInput = screen.getByLabelText('Password');
+    const confirmPasswordInput = screen.getByLabelText('Confirm password');
+
+    fireEvent.change(emailInput, { target: { value: email } });
+    fireEvent.change(passwordInput, { target: { value: password } });
+    fireEvent.change(confirmPasswordInput, { target: { value: password } });
+    fireEvent.click(registerButton);
+
+    // Assert
+    await waitFor(() => {
+      expect(result.current.toasts).toHaveLength(1);
+      expect(result.current.toasts[0].description).toEqual('my big error');
+      expect(result.current.toasts[0].status).toEqual('error');
+    });
+  });
+});

+ 2 - 1
packages/dashboard/src/modules/Auth/containers/RegisterContainer/RegisterContainer.tsx

@@ -1,3 +1,4 @@
+import router from 'next/router';
 import React, { useState } from 'react';
 import { useRegisterMutation } from '../../../../generated/graphql';
 import { useToastStore } from '../../../../state/toastStore';
@@ -28,7 +29,7 @@ export const RegisterContainer: React.FC = () => {
 
       if (data?.register?.token) {
         localStorage.setItem('token', data.register.token);
-        window.location.reload();
+        router.reload();
       }
     } catch (error) {
       handleError(error);

+ 27 - 0
packages/dashboard/src/modules/Dashboard/containers/Dashboard.test.tsx

@@ -0,0 +1,27 @@
+import { faker } from '@faker-js/faker';
+import React from 'react';
+import { render } from '../../../../tests/test-utils';
+import { SystemInfoResponse } from '../../../generated/graphql';
+import Dashboard from './Dashboard';
+
+describe('Test: Dashboard', () => {
+  it('should render', () => {
+    const data: SystemInfoResponse = {
+      disk: {
+        available: faker.datatype.number(),
+        total: faker.datatype.number(),
+        used: faker.datatype.number(),
+      },
+      memory: {
+        available: faker.datatype.number(),
+        total: faker.datatype.number(),
+        used: faker.datatype.number(),
+      },
+      cpu: {
+        load: faker.datatype.number(),
+      },
+    };
+
+    render(<Dashboard data={data} />);
+  });
+});

+ 11 - 0
packages/dashboard/src/modules/Dashboard/pages/DashboardPage/DashboardPage.test.tsx

@@ -0,0 +1,11 @@
+import React from 'react';
+import { render, screen } from '../../../../../tests/test-utils';
+import { DashboardPage } from './DashboardPage';
+
+describe('Test: DashboardPage', () => {
+  it('should render', async () => {
+    // Arrange
+    render(<DashboardPage />);
+    expect(screen.getByTestId('dashboard-layout')).toBeInTheDocument();
+  });
+});

+ 1 - 1
packages/dashboard/src/modules/Settings/components/RestartModal/RestartModal.tsx

@@ -18,7 +18,7 @@ export const RestartModal: React.FC<IProps> = ({ isOpen, onClose, onConfirm, loa
       <div className="text-muted">Would you like to restart your Tipi server?</div>
     </ModalBody>
     <ModalFooter>
-      <Button onClick={onConfirm} className="btn-danger" loading={loading}>
+      <Button data-testid="settings-modal-restart-button" onClick={onConfirm} className="btn-danger" loading={loading}>
         Restart
       </Button>
     </ModalFooter>

+ 122 - 0
packages/dashboard/src/modules/Settings/containers/SettingsContainer/SettingsContainer.test.tsx

@@ -0,0 +1,122 @@
+import { faker } from '@faker-js/faker';
+import { graphql } from 'msw';
+import React from 'react';
+import { act, fireEvent, render, renderHook, screen, waitFor } from '../../../../../tests/test-utils';
+import { server } from '../../../../mocks/server';
+import { useToastStore } from '../../../../state/toastStore';
+import { SettingsContainer } from './SettingsContainer';
+
+describe('Test: SettingsContainer', () => {
+  it('renders without crashing', () => {
+    const currentVersion = faker.system.semver();
+    render(<SettingsContainer currentVersion={currentVersion} latestVersion={currentVersion} />);
+
+    expect(screen.getByText('Tipi settings')).toBeInTheDocument();
+    expect(screen.getByText('Already up to date')).toBeInTheDocument();
+  });
+
+  it('should make update button disable if current version is equal to latest version', () => {
+    const currentVersion = faker.system.semver();
+    render(<SettingsContainer currentVersion={currentVersion} latestVersion={currentVersion} />);
+
+    expect(screen.getByText('Already up to date')).toBeDisabled();
+  });
+
+  it('should make update button disabled if current version is greater than latest version', () => {
+    const currentVersion = '1.0.0';
+    const latestVersion = '0.0.1';
+    render(<SettingsContainer currentVersion={currentVersion} latestVersion={latestVersion} />);
+
+    expect(screen.getByText('Already up to date')).toBeDisabled();
+  });
+
+  it('should display update button if current version is less than latest version', () => {
+    const currentVersion = '0.0.1';
+    const latestVersion = '1.0.0';
+
+    render(<SettingsContainer currentVersion={currentVersion} latestVersion={latestVersion} />);
+    expect(screen.getByText(`Update to ${latestVersion}`)).toBeInTheDocument();
+    expect(screen.getByText(`Update to ${latestVersion}`)).not.toBeDisabled();
+  });
+
+  it('should call update mutation when update button is clicked', async () => {
+    // Arrange
+
+    localStorage.setItem('token', 'token');
+    const currentVersion = '0.0.1';
+    const latestVersion = '1.0.0';
+    const updateFn = jest.fn();
+    server.use(
+      graphql.mutation('Update', async (req, res, ctx) => {
+        updateFn();
+        return res(ctx.data({ update: true }));
+      }),
+    );
+    render(<SettingsContainer currentVersion={currentVersion} latestVersion={latestVersion} />);
+
+    // Act
+    act(() => screen.getByText(`Update to ${latestVersion}`).click());
+
+    fireEvent.click(screen.getByText('Update'));
+    waitFor(() => expect(updateFn).toHaveBeenCalled());
+    // eslint-disable-next-line no-promise-executor-return
+    await act(() => new Promise((resolve) => setTimeout(resolve, 1500)));
+
+    // Assert
+    const token = localStorage.getItem('token');
+    expect(token).toBe(null);
+  });
+
+  it('should display error toast if update mutation fails', async () => {
+    // Arrange
+    const { result, unmount } = renderHook(() => useToastStore());
+    const currentVersion = '0.0.1';
+    const latestVersion = '1.0.0';
+    const errorMessage = 'My error';
+    server.use(graphql.mutation('Update', async (req, res, ctx) => res(ctx.errors([{ message: errorMessage }]))));
+    render(<SettingsContainer currentVersion={currentVersion} latestVersion={latestVersion} />);
+
+    // Act
+    act(() => screen.getByText(`Update to ${latestVersion}`).click());
+    fireEvent.click(screen.getByText('Update'));
+
+    // Assert
+    await waitFor(() => expect(result.current.toasts[0].description).toBe(errorMessage));
+    unmount();
+  });
+
+  it('should call restart mutation when restart button is clicked', async () => {
+    // Arrange
+    const restartFn = jest.fn();
+    server.use(
+      graphql.mutation('Restart', async (req, res, ctx) => {
+        restartFn();
+        return res(ctx.data({ restart: true }));
+      }),
+    );
+    render(<SettingsContainer currentVersion="1.0.0" latestVersion="1.0.0" />);
+
+    // Act
+    fireEvent.click(screen.getByTestId('settings-modal-restart-button'));
+    waitFor(() => expect(restartFn).toHaveBeenCalled());
+    // eslint-disable-next-line no-promise-executor-return
+    await new Promise((resolve) => setTimeout(resolve, 1500));
+
+    // Assert
+    const token = localStorage.getItem('token');
+    expect(token).toBe(null);
+  });
+
+  it('should display error toast if restart mutation fails', async () => {
+    // Arrange
+    const { result } = renderHook(() => useToastStore());
+    const errorMessage = 'Update error';
+    server.use(graphql.mutation('Restart', async (req, res, ctx) => res(ctx.errors([{ message: errorMessage }]))));
+    render(<SettingsContainer currentVersion="1.0.0" latestVersion="1.0.0" />);
+    // Act
+    fireEvent.click(screen.getByTestId('settings-modal-restart-button'));
+
+    // Assert
+    await waitFor(() => expect(result.current.toasts[0].description).toBe(errorMessage));
+  });
+});

+ 21 - 0
packages/dashboard/src/modules/Settings/pages/SettingsPage/SettingsPage.test.tsx

@@ -0,0 +1,21 @@
+import { graphql } from 'msw';
+import React from 'react';
+import { render, screen, waitFor } from '../../../../../tests/test-utils';
+import { server } from '../../../../mocks/server';
+import { SettingsPage } from './SettingsPage';
+
+describe('Test: SettingsPage', () => {
+  it('should render', async () => {
+    render(<SettingsPage />);
+
+    await waitFor(() => expect(screen.getByText('Tipi settings')).toBeInTheDocument());
+  });
+
+  it('should render error page if version query fails', async () => {
+    server.use(graphql.query('Version', (req, res, ctx) => res(ctx.errors([{ message: 'My error' }]))));
+
+    render(<SettingsPage />);
+
+    await waitFor(() => expect(screen.getByText('My error')).toBeInTheDocument());
+  });
+});

+ 3 - 1
packages/dashboard/src/modules/Settings/pages/SettingsPage/SettingsPage.tsx

@@ -3,13 +3,15 @@ import type { NextPage } from 'next';
 import { useVersionQuery } from '../../../../generated/graphql';
 import { Layout } from '../../../../components/Layout';
 import { SettingsContainer } from '../../containers/SettingsContainer/SettingsContainer';
+import { ErrorPage } from '../../../../components/ui/ErrorPage';
 
 export const SettingsPage: NextPage = () => {
-  const { data, loading } = useVersionQuery();
+  const { data, loading, error } = useVersionQuery();
 
   return (
     <Layout title="Settings" loading={!data?.version && loading}>
       {data?.version && <SettingsContainer currentVersion={data.version.current} latestVersion={data.version.latest} />}
+      {error && <ErrorPage error={error.message} />}
     </Layout>
   );
 };

+ 5 - 0
packages/dashboard/src/pages/_app.tsx

@@ -11,6 +11,11 @@ import { StatusProvider } from '../components/hoc/StatusProvider';
 import { AuthProvider } from '../components/hoc/AuthProvider';
 import { StatusScreen } from '../components/StatusScreen';
 
+if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') {
+  // eslint-disable-next-line global-require
+  require('../mocks');
+}
+
 function MyApp({ Component, pageProps }: AppProps) {
   const { setDarkMode } = useUIStore();
 

+ 2 - 0
packages/dashboard/src/state/toastStore.ts

@@ -13,6 +13,7 @@ type Store = {
   toasts: IToast[];
   addToast: (toast: Omit<IToast, 'id'>) => void;
   removeToast: (id: string) => void;
+  clearToasts: () => void;
 };
 
 export const useToastStore = create<Store>((set) => ({
@@ -31,4 +32,5 @@ export const useToastStore = create<Store>((set) => ({
     }, 5000);
   },
   removeToast: (id: string) => set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })),
+  clearToasts: () => set({ toasts: [] }),
 }));

+ 40 - 0
packages/dashboard/tests/jest.setup.tsx

@@ -0,0 +1,40 @@
+import React from 'react';
+import '@testing-library/jest-dom/extend-expect';
+import 'whatwg-fetch';
+import { server } from '../src/mocks/server';
+import { mockApolloClient } from './test-utils';
+import { useToastStore } from '../src/state/toastStore';
+
+// Mock next/router
+// eslint-disable-next-line global-require
+jest.mock('next/router', () => require('next-router-mock'));
+jest.mock('react-markdown', () => ({
+  __esModule: true,
+  default: () => <div data-testid="markdown" />,
+}));
+jest.mock('remark-breaks', () => () => ({}));
+jest.mock('remark-gfm', () => () => ({}));
+jest.mock('remark-mdx', () => () => ({}));
+
+beforeAll(() => {
+  // Enable the mocking in tests.
+  server.listen();
+});
+
+beforeEach(async () => {
+  useToastStore.getState().clearToasts();
+  // Ensure Apollo cache is cleared between tests.
+  // https://www.apollographql.com/docs/react/api/core/ApolloClient/#ApolloClient.clearStore
+  await mockApolloClient.clearStore();
+  await mockApolloClient.cache.reset();
+});
+
+afterEach(() => {
+  // Reset any runtime handlers tests may use.
+  server.resetHandlers();
+});
+
+afterAll(() => {
+  // Clean up once the tests are done.
+  server.close();
+});

+ 31 - 0
packages/dashboard/tests/test-utils.tsx

@@ -0,0 +1,31 @@
+import React, { FC, ReactElement } from 'react';
+import { render, RenderOptions, renderHook } from '@testing-library/react';
+import { ApolloClient, ApolloProvider, HttpLink, InMemoryCache } from '@apollo/client';
+import fetch from 'isomorphic-fetch';
+import { SWRConfig } from 'swr';
+
+const link = new HttpLink({
+  uri: 'http://localhost:3000/graphql',
+  // Use explicit `window.fetch` so tha outgoing requests
+  // are captured and deferred until the Service Worker is ready.
+  fetch: (...args) => fetch(...args),
+});
+
+// create a mock of Apollo Client
+export const mockApolloClient = new ApolloClient({
+  cache: new InMemoryCache({}),
+  link,
+});
+
+const AllTheProviders: FC<{ children: React.ReactNode }> = ({ children }) => (
+  <SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
+    <ApolloProvider client={mockApolloClient}>{children}</ApolloProvider>
+  </SWRConfig>
+);
+
+const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) => render(ui, { wrapper: AllTheProviders, ...options });
+const customRenderHook = (callback: () => any, options?: Omit<RenderOptions, 'wrapper'>) => renderHook(callback, { wrapper: AllTheProviders, ...options });
+
+export * from '@testing-library/react';
+export { customRender as render };
+export { customRenderHook as renderHook };

+ 2 - 1
packages/dashboard/tsconfig.json

@@ -15,7 +15,8 @@
     "jsx": "preserve",
     "incremental": true,
     "strictNullChecks": true,
-    "allowSyntheticDefaultImports": true
+    "allowSyntheticDefaultImports": true,
+    "types": ["jest", "@testing-library/jest-dom"]
   },
   "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
   "exclude": ["node_modules"]

Файловите разлики са ограничени, защото са твърде много
+ 483 - 20
pnpm-lock.yaml


Някои файлове не бяха показани, защото твърде много файлове са промени