mirror of
https://github.com/meienberger/runtipi.git
synced 2024-09-11 20:17:23 +03:00
test: front-end setup jest, testing-library, msw & test components
This commit is contained in:
parent
59386e744a
commit
c4bda4eb07
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
2
packages/dashboard/.gitignore
vendored
2
packages/dashboard/.gitignore
vendored
@ -32,4 +32,4 @@ yarn-error.log*
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
*.tsbuildinfo
|
@ -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);
|
||||
|
@ -2,7 +2,7 @@
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
basePath: '/dashboard',
|
||||
swcMinify: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
@ -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
Normal file
BIN
packages/dashboard/public/error.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 42 KiB |
303
packages/dashboard/public/mockServiceWorker.js
Normal file
303
packages/dashboard/public/mockServiceWorker.js
Normal file
@ -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)
|
||||
}
|
@ -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 }}>
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
@ -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" />
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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" />;
|
||||
}
|
||||
|
@ -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
packages/dashboard/src/components/ui/Button/Button.test.tsx
Normal file
50
packages/dashboard/src/components/ui/Button/Button.test.tsx
Normal file
@ -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();
|
||||
});
|
||||
});
|
@ -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 +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();
|
||||
});
|
||||
});
|
@ -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>
|
||||
)}
|
||||
|
@ -0,0 +1,4 @@
|
||||
.emptyImage {
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
}
|
@ -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
packages/dashboard/src/components/ui/ErrorPage/ErrorPage.tsx
Normal file
39
packages/dashboard/src/components/ui/ErrorPage/ErrorPage.tsx
Normal file
@ -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
packages/dashboard/src/components/ui/ErrorPage/index.ts
Normal file
1
packages/dashboard/src/components/ui/ErrorPage/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ErrorPage } from './ErrorPage';
|
81
packages/dashboard/src/components/ui/Header/Header.test.tsx
Normal file
81
packages/dashboard/src/components/ui/Header/Header.test.tsx
Normal file
@ -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);
|
||||
});
|
||||
});
|
@ -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
packages/dashboard/src/components/ui/Input/Input.test.tsx
Normal file
105
packages/dashboard/src/components/ui/Input/Input.test.tsx
Normal file
@ -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');
|
||||
});
|
||||
});
|
@ -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
packages/dashboard/src/components/ui/Modal/Modal.test.tsx
Normal file
141
packages/dashboard/src/components/ui/Modal/Modal.test.tsx
Normal file
@ -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();
|
||||
});
|
||||
});
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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
packages/dashboard/src/components/ui/NavBar/NavBar.test.tsx
Normal file
50
packages/dashboard/src/components/ui/NavBar/NavBar.test.tsx
Normal file
@ -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();
|
||||
});
|
||||
});
|
@ -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
packages/dashboard/src/components/ui/Switch/Switch.test.tsx
Normal file
59
packages/dashboard/src/components/ui/Switch/Switch.test.tsx
Normal file
@ -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();
|
||||
});
|
||||
});
|
@ -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
packages/dashboard/src/components/ui/Toast/Toast.test.tsx
Normal file
34
packages/dashboard/src/components/ui/Toast/Toast.test.tsx
Normal file
@ -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();
|
||||
});
|
||||
});
|
@ -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 +1 @@
|
||||
export const getUrl = (url: string) => {
|
||||
const prefix = 'dashboard';
|
||||
|
||||
return `/${prefix}/${url}`;
|
||||
};
|
||||
export const getUrl = (url: string) => `/${url}`;
|
||||
|
@ -1,9 +1,3 @@
|
||||
export enum RequestStatus {
|
||||
SUCCESS = 'SUCCESS',
|
||||
ERROR = 'ERROR',
|
||||
LOADING = 'LOADING',
|
||||
}
|
||||
|
||||
export interface IUser {
|
||||
name: string;
|
||||
email: string;
|
||||
|
@ -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 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 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 ConfiguredQuery = { __typename?: 'Query'; isConfigured: boolean };
|
||||
|
||||
export type ConfiguredQueryVariables = 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 ConfiguredQuery = { __typename?: 'Query', isConfigured: boolean };
|
||||
export type MeQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type ListAppsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
export type MeQuery = { __typename?: 'Query'; me?: { __typename?: 'User'; id: string } | null };
|
||||
|
||||
export type RefreshTokenQueryVariables = 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 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 MeQuery = { __typename?: 'Query', me?: { __typename?: 'User', id: string } | null };
|
||||
|
||||
export type RefreshTokenQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type RefreshTokenQuery = { __typename?: 'Query', refreshToken?: { __typename?: 'TokenResponse', token: string } | null };
|
||||
|
||||
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 VersionQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
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>;
|
||||
|
@ -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
packages/dashboard/src/mocks/browser.ts
Normal file
4
packages/dashboard/src/mocks/browser.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { setupWorker } from 'msw';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
export const worker = setupWorker(...handlers);
|
57
packages/dashboard/src/mocks/fixtures/app.fixtures.ts
Normal file
57
packages/dashboard/src/mocks/fixtures/app.fixtures.ts
Normal file
@ -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
packages/dashboard/src/mocks/handlers.ts
Normal file
133
packages/dashboard/src/mocks/handlers.ts
Normal file
@ -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
packages/dashboard/src/mocks/handlers/appHandlers.ts
Normal file
173
packages/dashboard/src/mocks/handlers/appHandlers.ts
Normal file
@ -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
packages/dashboard/src/mocks/index.ts
Normal file
13
packages/dashboard/src/mocks/index.ts
Normal file
@ -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
packages/dashboard/src/mocks/server.ts
Normal file
4
packages/dashboard/src/mocks/server.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { setupServer } from 'msw/node';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
export const server = setupServer(...handlers);
|
@ -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} />
|
||||
))}
|
||||
|
@ -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} />
|
||||
))}
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
@ -0,0 +1 @@
|
||||
export { AppActions } from './AppActions';
|
@ -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();
|
||||
});
|
||||
});
|
@ -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">
|
@ -0,0 +1 @@
|
||||
export { InstallForm } from './InstallForm';
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
@ -0,0 +1 @@
|
||||
export { InstallModal } from './InstallModal';
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
@ -0,0 +1 @@
|
||||
export { UpdateModal } from './UpdateModal';
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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} />
|
||||
|
@ -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}`);
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
|
@ -0,0 +1 @@
|
||||
export { validateAppConfig } from './validators';
|
@ -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({});
|
||||
});
|
||||
});
|
@ -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,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>
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
|
@ -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} />);
|
||||
});
|
||||
});
|
@ -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();
|
||||
});
|
||||
});
|
@ -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>
|
||||
|
@ -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));
|
||||
});
|
||||
});
|
@ -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,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>
|
||||
);
|
||||
};
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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
packages/dashboard/tests/jest.setup.tsx
Normal file
40
packages/dashboard/tests/jest.setup.tsx
Normal file
@ -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
packages/dashboard/tests/test-utils.tsx
Normal file
31
packages/dashboard/tests/test-utils.tsx
Normal file
@ -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 };
|
@ -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"]
|
||||
|
980
pnpm-lock.yaml
980
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user