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

This commit is contained in:
Nicolas Meienberger 2022-12-05 08:45:51 +01:00 committed by Nicolas Meienberger
parent 59386e744a
commit c4bda4eb07
95 changed files with 4330 additions and 469 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

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

View File

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

View File

@ -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">

View File

@ -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">

View File

@ -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" />

View File

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

View File

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

View File

@ -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" />;
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

@ -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>

View File

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

View File

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

View File

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

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

View File

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

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

View File

@ -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>

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View File

@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View File

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

View File

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

View File

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

View File

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

View File

@ -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: [] }),
}));

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

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

View File

@ -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"]

File diff suppressed because it is too large Load Diff