commit
3ab108c919
237 changed files with 7803 additions and 4497 deletions
24
Dockerfile
24
Dockerfile
|
@ -1,5 +1,12 @@
|
|||
FROM node:18 AS builder
|
||||
FROM node:18-alpine3.16 AS builder
|
||||
|
||||
# Required for argon2
|
||||
RUN apk --no-cache add g++
|
||||
RUN apk --no-cache add make
|
||||
RUN apk --no-cache add python3
|
||||
|
||||
# Required for sharp
|
||||
RUN apk --no-cache add vips-dev=8.12.2-r5
|
||||
RUN npm install node-gyp -g
|
||||
|
||||
WORKDIR /api
|
||||
|
@ -18,23 +25,12 @@ WORKDIR /dashboard
|
|||
COPY ./packages/dashboard /dashboard
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM alpine:3.16.0 as app
|
||||
FROM node:18-alpine3.16 as app
|
||||
|
||||
WORKDIR /
|
||||
|
||||
# # Install dependencies
|
||||
RUN apk --no-cache add nodejs npm
|
||||
RUN apk --no-cache add g++
|
||||
RUN apk --no-cache add make
|
||||
RUN apk --no-cache add python3
|
||||
|
||||
RUN npm install node-gyp -g
|
||||
|
||||
WORKDIR /api
|
||||
COPY ./packages/system-api/package*.json /api/
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY ./packages/system-api/package.json /api/
|
||||
COPY --from=builder /api/dist /api/dist
|
||||
|
||||
WORKDIR /dashboard
|
||||
|
|
34
README.md
34
README.md
|
@ -23,13 +23,14 @@
|
|||
|
||||
> ⚠️ Tipi is still at an early stage of development and issues are to be expected. Feel free to open an issue or pull request if you find a bug.
|
||||
|
||||
Tipi is a personal homeserver orchestrator. It is running docker containers under the hood and provides a simple web interface to manage them. Every service comes with an opinionated configuration in order to remove the need for manual configuration and network setup.
|
||||
Tipi is a personal homeserver orchestrator that makes it easy to manage and run multiple services on a single server. It is based on Docker and comes with a simple web interface to manage your services. Tipi is designed to be easy to use, so you don't have to worry about manual configuration or networking. Simply install Tipi on your server and use the web interface to add and manage services. You can see a list of available services in the [App Store repo](https://github.com/meienberger/runtipi-appstore) and request new ones if you don't see what you need. To get started, follow the installation instructions below.
|
||||
|
||||
Check our demo instance : **[demo.runtipi.com](https://demo.runtipi.com)** / username: **user@runtipi.com** / password: **runtipi**
|
||||
## Demo
|
||||
|
||||
## Apps available
|
||||
You can try out a demo of Tipi at [demo.runtipi.com](demo.runtipi.com) using the following credentials:
|
||||
|
||||
See the list of apps available and submit your requests in the [App Store repo](https://github.com/meienberger/runtipi-appstore)
|
||||
username: user@runtipi.com
|
||||
password: runtipi
|
||||
|
||||
## 🛠 Installation
|
||||
|
||||
|
@ -37,24 +38,25 @@ See the list of apps available and submit your requests in the [App Store repo](
|
|||
|
||||
Ubuntu 18.04 LTS or higher is recommended. However other major Linux distribution are supported but may lead to installation issues. Please file an issue if you encounter one.
|
||||
|
||||
### Step 1. Download Tipi
|
||||
### Download and install Tipi
|
||||
|
||||
Run this in an empty directory where you want to install Tipi.
|
||||
Download the latest version of Tipi:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/meienberger/runtipi.git
|
||||
curl -L https://setup.runtipi.com | bash
|
||||
```
|
||||
|
||||
### Step 2. Run Tipi
|
||||
The script will prompt you the ip address of the dashboard once configured.
|
||||
|
||||
cd into the downloaded directory and run the start script.
|
||||
### Commands
|
||||
|
||||
If you already installed Tipi, you can start it manually by running the `start.sh` script in the `runtipi` folder.
|
||||
|
||||
```bash
|
||||
cd runtipi
|
||||
sudo ./scripts/start.sh
|
||||
```
|
||||
|
||||
The script will prompt you the ip address of the dashboard once configured.
|
||||
Tipi will run by default on port 80. To select another port you can run the start script with the `--port` argument
|
||||
|
||||
```bash
|
||||
|
@ -67,9 +69,17 @@ To stop Tipi, run the stop script.
|
|||
sudo ./scripts/stop.sh
|
||||
```
|
||||
|
||||
### Update Tipi
|
||||
|
||||
To update Tipi to the latest version, run the update script.
|
||||
|
||||
```bash
|
||||
sudo ./scripts/system.sh update
|
||||
```
|
||||
|
||||
### Custom settings
|
||||
|
||||
You can change the default settings by creating a `settings.json` file. The file should be located in the `state` directory. This file will make your changes persist across restarts. Example file:
|
||||
You can change the default settings by creating a `settings.json` file. The file should be located in the `runtipi/state` directory. This file will make your changes persist across restarts. Example file:
|
||||
|
||||
```json
|
||||
{
|
||||
|
@ -115,7 +125,7 @@ sudo rm -rf runtipi
|
|||
|
||||
## 📚 Documentation
|
||||
|
||||
For a detailed guide on how to install Tipi. This amazing article by @kycfree1 [Running a Home Server with Tipi](https://kyc3.life/running-a-home-server-with-tipi/)
|
||||
For a detailed guide on how to install Tipi. This amazing article by @kycfree [Running a Home Server with Tipi](https://kyc3.life/running-a-home-server-with-tipi/)
|
||||
|
||||
You can find more documentation and tutorials / FAQ in the [Wiki](https://github.com/meienberger/runtipi/wiki).
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ services:
|
|||
restart: unless-stopped
|
||||
stop_grace_period: 1m
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 5432:5432
|
||||
environment:
|
||||
|
@ -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:
|
||||
|
@ -135,3 +124,6 @@ networks:
|
|||
driver: default
|
||||
config:
|
||||
- subnet: 10.21.21.0/24
|
||||
|
||||
volumes:
|
||||
pgdata:
|
|
@ -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:
|
||||
|
|
151
docker-compose.test.yml
Normal file
151
docker-compose.test.yml
Normal file
|
@ -0,0 +1,151 @@
|
|||
version: "3.7"
|
||||
|
||||
services:
|
||||
reverse-proxy:
|
||||
container_name: reverse-proxy
|
||||
image: traefik:v2.8
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${NGINX_PORT-80}:80
|
||||
- ${NGINX_PORT_SSL-443}:443
|
||||
command: --providers.docker
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ${PWD}/traefik:/root/.config
|
||||
- ${PWD}/traefik/shared:/shared
|
||||
networks:
|
||||
- tipi_main_network
|
||||
|
||||
tipi-db:
|
||||
container_name: tipi-db
|
||||
image: postgres:14
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 1m
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_USER: tipi
|
||||
POSTGRES_DB: tipi
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -d tipi -U tipi"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 120
|
||||
networks:
|
||||
- tipi_main_network
|
||||
|
||||
tipi-redis:
|
||||
container_name: tipi-redis
|
||||
image: redis:alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
networks:
|
||||
- tipi_main_network
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
command: /bin/sh -c "cd /api && npm run start"
|
||||
restart: unless-stopped
|
||||
container_name: api
|
||||
depends_on:
|
||||
tipi-db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ${PWD}/repos:/runtipi/repos:ro
|
||||
- ${PWD}/apps:/runtipi/apps
|
||||
- ${PWD}/state:/runtipi/state
|
||||
- ${PWD}/logs:/app/logs
|
||||
- ${STORAGE_PATH}:/app/storage
|
||||
- ${PWD}/.env:/runtipi/.env:ro
|
||||
environment:
|
||||
INTERNAL_IP: ${INTERNAL_IP}
|
||||
TIPI_VERSION: ${TIPI_VERSION}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
NGINX_PORT: ${NGINX_PORT}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_USERNAME: tipi
|
||||
POSTGRES_DBNAME: tipi
|
||||
POSTGRES_HOST: tipi-db
|
||||
NODE_ENV: production
|
||||
APPS_REPO_ID: ${APPS_REPO_ID}
|
||||
APPS_REPO_URL: ${APPS_REPO_URL}
|
||||
DOMAIN: ${DOMAIN}
|
||||
ARCHITECTURE: ${ARCHITECTURE}
|
||||
networks:
|
||||
- tipi_main_network
|
||||
labels:
|
||||
traefik.enable: true
|
||||
# Web
|
||||
traefik.http.routers.api.rule: PathPrefix(`/api`)
|
||||
traefik.http.routers.api.service: api
|
||||
traefik.http.routers.api.entrypoints: web
|
||||
traefik.http.routers.api.middlewares: api-stripprefix
|
||||
traefik.http.services.api.loadbalancer.server.port: 3001
|
||||
# Websecure
|
||||
traefik.http.routers.api-secure.rule: (Host(`${DOMAIN}`) && PathPrefix(`/api`))
|
||||
traefik.http.routers.api-secure.entrypoints: websecure
|
||||
traefik.http.routers.api-secure.service: api-secure
|
||||
traefik.http.routers.api-secure.tls.certresolver: myresolver
|
||||
traefik.http.routers.api-secure.middlewares: api-stripprefix
|
||||
traefik.http.services.api-secure.loadbalancer.server.port: 3001
|
||||
# Middlewares
|
||||
traefik.http.middlewares.api-stripprefix.stripprefix.prefixes: /api
|
||||
|
||||
dashboard:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
command: /bin/sh -c "cd /dashboard && node server.js"
|
||||
restart: unless-stopped
|
||||
container_name: dashboard
|
||||
networks:
|
||||
- tipi_main_network
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
environment:
|
||||
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.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.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:
|
||||
driver: bridge
|
||||
ipam:
|
||||
driver: default
|
||||
config:
|
||||
- subnet: 10.21.21.0/24
|
||||
|
||||
volumes:
|
||||
pgdata:
|
|
@ -107,33 +107,17 @@ services:
|
|||
NODE_ENV: production
|
||||
labels:
|
||||
traefik.enable: true
|
||||
traefik.http.routers.dashboard-redirect.rule: PathPrefix("/")
|
||||
traefik.http.routers.dashboard-redirect.entrypoints: web
|
||||
traefik.http.routers.dashboard-redirect.middlewares: redirect-middleware
|
||||
traefik.http.routers.dashboard-redirect.service: dashboard
|
||||
traefik.http.services.dashboard-redirect.loadbalancer.server.port: 3000
|
||||
|
||||
traefik.http.routers.dashboard-redirect-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/`)
|
||||
traefik.http.routers.dashboard-redirect-secure.entrypoints: websecure
|
||||
traefik.http.routers.dashboard-redirect-secure.middlewares: redirect-middleware
|
||||
traefik.http.routers.dashboard-redirect-secure.service: dashboard
|
||||
traefik.http.routers.dashboard-redirect-secure.tls.certresolver: myresolver
|
||||
traefik.http.services.dashboard-redirect-secure.loadbalancer.server.port: 3000
|
||||
|
||||
# Web
|
||||
traefik.http.routers.dashboard.rule: PathPrefix("/dashboard")
|
||||
traefik.http.routers.dashboard.rule: PathPrefix("/")
|
||||
traefik.http.routers.dashboard.service: dashboard
|
||||
traefik.http.routers.dashboard.entrypoints: web
|
||||
traefik.http.services.dashboard.loadbalancer.server.port: 3000
|
||||
# Websecure
|
||||
traefik.http.routers.dashboard-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/dashboard`)
|
||||
traefik.http.routers.dashboard-secure.rule: Host(`${DOMAIN}`) && PathPrefix(`/`)
|
||||
traefik.http.routers.dashboard-secure.service: dashboard-secure
|
||||
traefik.http.routers.dashboard-secure.entrypoints: websecure
|
||||
traefik.http.routers.dashboard-secure.tls.certresolver: myresolver
|
||||
traefik.http.services.dashboard-secure.loadbalancer.server.port: 3000
|
||||
# Middlewares
|
||||
traefik.http.middlewares.redirect-middleware.redirectregex.regex: .*
|
||||
traefik.http.middlewares.redirect-middleware.redirectregex.replacement: /dashboard
|
||||
|
||||
networks:
|
||||
tipi_main_network:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "runtipi",
|
||||
"version": "0.7.4",
|
||||
"version": "0.8.0",
|
||||
"description": "A homeserver for everyone",
|
||||
"scripts": {
|
||||
"prepare": "husky install",
|
||||
|
@ -9,10 +9,11 @@
|
|||
"act:docker": "act --container-architecture linux/amd64 --secret-file github.secrets -j build-images",
|
||||
"start:dev": "./scripts/start-dev.sh",
|
||||
"start:rc": "docker-compose -f docker-compose.rc.yml --env-file .env up --build",
|
||||
"start:prod": "docker-compose --env-file .env up --build",
|
||||
"start:prod": "docker-compose -f docker-compose.test.yml --env-file .env up --build",
|
||||
"start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres",
|
||||
"version": "echo $npm_package_version",
|
||||
"release:rc": "./scripts/deploy/release-rc.sh"
|
||||
"release:rc": "./scripts/deploy/release-rc.sh",
|
||||
"test:build": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t meienberger/runtipi:test ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^17.0.3",
|
||||
|
|
|
@ -1,5 +1,16 @@
|
|||
module.exports = {
|
||||
extends: ['next/core-web-vitals', 'airbnb-typescript', 'eslint:recommended', 'plugin:import/typescript'],
|
||||
plugins: ['@typescript-eslint', 'import', 'react', 'jest'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'next/core-web-vitals',
|
||||
'next',
|
||||
'airbnb',
|
||||
'airbnb-typescript',
|
||||
'eslint:recommended',
|
||||
'plugin:import/typescript',
|
||||
'prettier',
|
||||
'plugin:react/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
|
@ -7,14 +18,22 @@ module.exports = {
|
|||
project: './tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'import'],
|
||||
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,
|
||||
'react/no-unused-prop-types': 0,
|
||||
'react/button-has-type': 0,
|
||||
'import/no-extraneous-dependencies': ['error', { devDependencies: ['**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', '**/mocks/**', 'tests/**'] }],
|
||||
},
|
||||
globals: {
|
||||
JSX: true,
|
||||
},
|
||||
env: {
|
||||
'jest/globals': true,
|
||||
},
|
||||
};
|
||||
|
|
2
packages/dashboard/.gitignore
vendored
2
packages/dashboard/.gitignore
vendored
|
@ -32,4 +32,4 @@ yarn-error.log*
|
|||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
*.tsbuildinfo
|
|
@ -1,11 +1,18 @@
|
|||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
verbose: true,
|
||||
// testEnvironment: 'node',
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
// setupFiles: ['<rootDir>/tests/dotenv-config.ts'],
|
||||
const nextJest = require('next/jest');
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
||||
dir: './',
|
||||
});
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const customJestConfig = {
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/jest.setup.tsx'],
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
collectCoverage: true,
|
||||
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
|
||||
// coverageProvider: 'v8',
|
||||
passWithNoTests: true,
|
||||
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/index.ts', '!**/src/pages/**/*.{ts,tsx}', '!**/src/mocks/**', '!**/src/core/apollo/**'],
|
||||
testMatch: ['<rootDir>/src/**/*.{spec,test}.{ts,tsx}'],
|
||||
};
|
||||
|
||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||
module.exports = createJestConfig(customJestConfig);
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
basePath: '/dashboard',
|
||||
swcMinify: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
{
|
||||
"name": "dashboard",
|
||||
"version": "0.7.4",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"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",
|
||||
|
@ -13,55 +14,72 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.6.8",
|
||||
"@chakra-ui/react": "^2.1.2",
|
||||
"@emotion/react": "^11",
|
||||
"@emotion/styled": "^11",
|
||||
"@fontsource/open-sans": "^4.5.8",
|
||||
"@hookform/resolvers": "^2.9.10",
|
||||
"@tabler/core": "1.0.0-beta16",
|
||||
"@tabler/icons": "^1.109.0",
|
||||
"clsx": "^1.1.1",
|
||||
"final-form": "^4.20.6",
|
||||
"framer-motion": "^6",
|
||||
"graphql": "^15.8.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"next": "12.3.1",
|
||||
"react": "18.1.0",
|
||||
"react-dom": "18.1.0",
|
||||
"react-final-form": "^6.5.9",
|
||||
"react-icons": "^4.3.1",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"next": "13.0.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.38.0",
|
||||
"react-markdown": "^8.0.3",
|
||||
"react-select": "^5.3.2",
|
||||
"react-select": "^5.6.1",
|
||||
"react-tooltip": "^4.4.3",
|
||||
"remark-breaks": "^3.0.2",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-mdx": "^2.1.1",
|
||||
"sass": "^1.55.0",
|
||||
"semver": "^7.3.7",
|
||||
"sharp": "0.30.7",
|
||||
"swr": "^1.3.0",
|
||||
"tslib": "^2.4.0",
|
||||
"validator": "^13.7.0",
|
||||
"zod": "^3.19.1",
|
||||
"zustand": "^3.7.2"
|
||||
},
|
||||
"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",
|
||||
"@types/js-cookie": "^3.0.2",
|
||||
"@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/react-slick": "^0.23.8",
|
||||
"@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",
|
||||
"autoprefixer": "^10.4.4",
|
||||
"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",
|
||||
"postcss": "^8.4.12",
|
||||
"tailwindcss": "^3.0.23",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
1
packages/dashboard/public/empty.svg
Normal file
1
packages/dashboard/public/empty.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns:xlink="http://www.w3.org/1999/xlink" class="ant-empty-img-simple" width="64" height="41" viewBox="0 0 64 41" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 1)" fill="none" fill-rule="evenodd"><ellipse class="ant-empty-img-simple-ellipse" cx="32" cy="33" rx="32" ry="7" fill="#F5F5F5"></ellipse><g class="ant-empty-img-simple-g" fill-rule="nonzero" stroke="#D9D9D9" fill="none"><path d="M55 12.76L44.854 1.258C44.367.474 43.656 0 42.907 0H21.093c-.749 0-1.46.474-1.947 1.257L9 12.761V22h46v-9.24z" stroke="#D9D9D9" fill="none"></path><path d="M41.613 15.931c0-1.605.994-2.93 2.227-2.931H55v18.137C55 33.26 53.68 35 52.05 35h-40.1C10.32 35 9 33.259 9 31.137V13h11.16c1.233 0 2.227 1.323 2.227 2.928v.022c0 1.605 1.005 2.901 2.237 2.901h14.752c1.232 0 2.237-1.308 2.237-2.913v-.007z" class="ant-empty-img-simple-path" stroke="#D9D9D9" fill="#FAFAFA"></path></g></g></svg>
|
After Width: | Height: | Size: 893 B |
BIN
packages/dashboard/public/error.png
Normal file
BIN
packages/dashboard/public/error.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 42 KiB |
303
packages/dashboard/public/mockServiceWorker.js
Normal file
303
packages/dashboard/public/mockServiceWorker.js
Normal file
|
@ -0,0 +1,303 @@
|
|||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker (0.49.1).
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
|
||||
const activeClientIds = new Set()
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
const clientId = event.source.id
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: INTEGRITY_CHECKSUM,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event
|
||||
const accept = request.headers.get('accept') || ''
|
||||
|
||||
// Bypass server-sent events.
|
||||
if (accept.includes('text/event-stream')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique request ID.
|
||||
const requestId = Math.random().toString(16).slice(2)
|
||||
|
||||
event.respondWith(
|
||||
handleRequest(event, requestId).catch((error) => {
|
||||
if (error.name === 'NetworkError') {
|
||||
console.warn(
|
||||
'[MSW] Successfully emulated a network error for the "%s %s" request.',
|
||||
request.method,
|
||||
request.url,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// At this point, any exception indicates an issue with the original request/response.
|
||||
console.error(
|
||||
`\
|
||||
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
|
||||
request.method,
|
||||
request.url,
|
||||
`${error.name}: ${error.message}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event)
|
||||
const response = await getResponse(event, client, requestId)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
;(async function () {
|
||||
const clonedResponse = response.clone()
|
||||
sendToClient(client, {
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
type: clonedResponse.type,
|
||||
ok: clonedResponse.ok,
|
||||
status: clonedResponse.status,
|
||||
statusText: clonedResponse.statusText,
|
||||
body:
|
||||
clonedResponse.body === null ? null : await clonedResponse.text(),
|
||||
headers: Object.fromEntries(clonedResponse.headers.entries()),
|
||||
redirected: clonedResponse.redirected,
|
||||
},
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Resolve the main client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event
|
||||
const clonedRequest = request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const headers = Object.fromEntries(clonedRequest.headers.entries())
|
||||
|
||||
// Remove MSW-specific request headers so the bypassed requests
|
||||
// comply with the server's CORS preflight check.
|
||||
// Operate with the headers as an object because request "Headers"
|
||||
// are immutable.
|
||||
delete headers['x-msw-bypass']
|
||||
|
||||
return fetch(clonedRequest, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass requests with the explicit bypass header.
|
||||
// Such requests can be issued by "ctx.fetch()".
|
||||
if (request.headers.get('x-msw-bypass') === 'true') {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const clientMessage = await sendToClient(client, {
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
mode: request.mode,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.text(),
|
||||
bodyUsed: request.bodyUsed,
|
||||
keepalive: request.keepalive,
|
||||
},
|
||||
})
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'MOCK_NOT_FOUND': {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
case 'NETWORK_ERROR': {
|
||||
const { name, message } = clientMessage.data
|
||||
const networkError = new Error(message)
|
||||
networkError.name = name
|
||||
|
||||
// Rejecting a "respondWith" promise emulates a network error.
|
||||
throw networkError
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
function sendToClient(client, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [channel.port2])
|
||||
})
|
||||
}
|
||||
|
||||
function sleep(timeMs) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, timeMs)
|
||||
})
|
||||
}
|
||||
|
||||
async function respondWithMock(response) {
|
||||
await sleep(response.delay)
|
||||
return new Response(response.body, response)
|
||||
}
|
BIN
packages/dashboard/public/placeholder.png
Normal file
BIN
packages/dashboard/public/placeholder.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 136 B |
|
@ -0,0 +1,3 @@
|
|||
.dropShadow {
|
||||
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow(0 1px 1px rgb(0 0 0 / 0.06));
|
||||
}
|
|
@ -1,10 +1,17 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { getUrl } from '../../core/helpers/url-helpers';
|
||||
import styles from './AppLogo.module.scss';
|
||||
|
||||
const AppLogo: React.FC<{ id: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
|
||||
const logoUrl = `/api/apps/${id}/metadata/logo.jpg`;
|
||||
export const AppLogo: React.FC<{ id?: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
|
||||
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={`drop-shadow ${className}`} style={{ width: size, height: size }}>
|
||||
<div aria-label={alt} className={clsx(styles.dropShadow, className)} style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0" maskUnits="userSpaceOnUse" x="0" y="0" width="200" height="200">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M0 100C0 0 0 0 100 0S200 0 200 100 200 200 100 200 0 200 0 100" fill="white" />
|
||||
|
@ -14,5 +21,3 @@ const AppLogo: React.FC<{ id: string; size?: number; className?: string; alt?: s
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLogo;
|
||||
|
|
1
packages/dashboard/src/components/AppLogo/index.ts
Normal file
1
packages/dashboard/src/components/AppLogo/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { AppLogo } from './AppLogo';
|
|
@ -1 +0,0 @@
|
|||
export * from './AppLogo';
|
|
@ -0,0 +1,3 @@
|
|||
.text {
|
||||
margin-bottom: 1px;
|
||||
}
|
20
packages/dashboard/src/components/AppStatus/AppStatus.tsx
Normal file
20
packages/dashboard/src/components/AppStatus/AppStatus.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import styles from './AppStatus.module.scss';
|
||||
import { AppStatusEnum } from '../../generated/graphql';
|
||||
|
||||
export const AppStatus: React.FC<{ status: AppStatusEnum; lite?: boolean }> = ({ status, lite }) => {
|
||||
const formattedStatus = `${status[0]}${status.substring(1, status.length).toLowerCase()}`;
|
||||
|
||||
const classes = clsx('status-dot status-gray', {
|
||||
'status-dot-animated status-green': status === AppStatusEnum.Running,
|
||||
'status-red': status === AppStatusEnum.Stopped,
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-place="top" data-tip={lite && formattedStatus} className="d-flex align-items-center">
|
||||
<span className={classes} />
|
||||
{!lite && <span className={clsx(styles.text, 'ms-2 text-muted')}>{formattedStatus}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
1
packages/dashboard/src/components/AppStatus/index.tsx
Normal file
1
packages/dashboard/src/components/AppStatus/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export { AppStatus } from './AppStatus';
|
|
@ -1,33 +0,0 @@
|
|||
import React from 'react';
|
||||
import { FiPauseCircle, FiPlayCircle } from 'react-icons/fi';
|
||||
import { RiLoader4Line } from 'react-icons/ri';
|
||||
import { AppStatusEnum } from '../../generated/graphql';
|
||||
|
||||
const AppStatus: React.FC<{ status: AppStatusEnum }> = ({ status }) => {
|
||||
if (status === AppStatusEnum.Running) {
|
||||
return (
|
||||
<>
|
||||
<FiPlayCircle className="text-green-500 mr-1" size={20} />
|
||||
<span className="text-gray-400 text-sm">Running</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === AppStatusEnum.Stopped) {
|
||||
return (
|
||||
<>
|
||||
<FiPauseCircle className="text-red-500 mr-1" size={20} />
|
||||
<span className="text-gray-400 text-sm">Stopped</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RiLoader4Line className="text-gray-500 mr-1" size={20} />
|
||||
<span className="text-gray-400 text-sm">{`${status[0]}${status.substring(1, status.length).toLowerCase()}...`}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppStatus;
|
|
@ -0,0 +1,3 @@
|
|||
.statusContainer {
|
||||
margin-bottom: 4px;
|
||||
}
|
40
packages/dashboard/src/components/AppTile/AppTile.tsx
Normal file
40
packages/dashboard/src/components/AppTile/AppTile.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { IconDownload } from '@tabler/icons';
|
||||
import { AppStatus } from '../AppStatus';
|
||||
import { AppLogo } from '../AppLogo/AppLogo';
|
||||
import { limitText } from '../../modules/AppStore/helpers/table.helpers';
|
||||
import { AppInfo, AppStatusEnum } from '../../generated/graphql';
|
||||
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 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">
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="me-3">
|
||||
<AppLogo alt={`${app.name} logo`} className="mr-3 group-hover:scale-105 transition-all" id={app.id} size={60} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="d-flex h-3 align-items-center">
|
||||
<span className="h4 me-2 mt-1 fw-bolder">{app.name}</span>
|
||||
<div className={styles.statusContainer}>
|
||||
<AppStatus lite status={status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted">{limitText(app.short_desc, 50)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{updateAvailable && (
|
||||
<div data-tip="Update available" className="ribbon bg-green ribbon-top">
|
||||
<IconDownload />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
|
@ -1,44 +1 @@
|
|||
import { Box, SlideFade, Tag, useColorModeValue, Tooltip } from '@chakra-ui/react';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { FiChevronRight } from 'react-icons/fi';
|
||||
import { MdSystemUpdateAlt } from 'react-icons/md';
|
||||
import AppStatus from './AppStatus';
|
||||
import AppLogo from '../AppLogo/AppLogo';
|
||||
import { limitText } from '../../modules/AppStore/helpers/table.helpers';
|
||||
import { AppInfo, AppStatusEnum } from '../../generated/graphql';
|
||||
|
||||
type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;
|
||||
|
||||
const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => {
|
||||
const bg = useColorModeValue('white', '#1a202c');
|
||||
|
||||
return (
|
||||
<Link href={`/apps/${app.id}`} passHref>
|
||||
<SlideFade in className="flex flex-1" offsetY="20px">
|
||||
<Box bg={bg} className="flex flex-1 border-2 drop-shadow-sm rounded-lg p-3 items-center cursor-pointer group hover:drop-shadow-md transition-all">
|
||||
<AppLogo alt={`${app.name} logo`} className="mr-3 group-hover:scale-105 transition-all" id={app.id} size={100} />
|
||||
<div className="mr-3 flex-1">
|
||||
<div className="flex">
|
||||
<h3 className="font-bold text-xl mr-2">{app.name}</h3>
|
||||
{updateAvailable && (
|
||||
<Tooltip label="Update available">
|
||||
<Tag colorScheme="gray">
|
||||
<MdSystemUpdateAlt size={15} />
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<span>{limitText(app.short_desc, 50)}</span>
|
||||
<div className="flex mt-1">
|
||||
<AppStatus status={status} />
|
||||
</div>
|
||||
</div>
|
||||
<FiChevronRight className="text-slate-300" size={30} />
|
||||
</Box>
|
||||
</SlideFade>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppTile;
|
||||
export { AppTile } from './AppTile';
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface IProps {
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
type?: Parameters<typeof Input>[0]['type'];
|
||||
label?: string;
|
||||
className?: string;
|
||||
isInvalid?: boolean;
|
||||
size?: Parameters<typeof Input>[0]['size'];
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, size, hint, ...rest }) => {
|
||||
return (
|
||||
<div className={clsx('transition-all', className)}>
|
||||
{label && <label className="mb-1">{label}</label>}
|
||||
{hint && <div className="text-sm text-gray-500 mb-1">{hint}</div>}
|
||||
<Input type={type} placeholder={placeholder} isInvalid={isInvalid} size={size} {...rest} />
|
||||
{isInvalid && <span className="text-red-500 text-sm">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormInput;
|
|
@ -1,23 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Input, Switch } from '@chakra-ui/react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface IProps {
|
||||
placeholder?: string;
|
||||
type?: Parameters<typeof Input>[0]['type'];
|
||||
label?: string;
|
||||
className?: string;
|
||||
size?: Parameters<typeof Input>[0]['size'];
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
const FormSwitch: React.FC<IProps> = ({ placeholder, type, label, className, size, ...rest }) => {
|
||||
return (
|
||||
<div className={clsx('transition-all', className)}>
|
||||
{label && <label className="mr-2">{label}</label>}
|
||||
<Switch isChecked={rest.checked} type={type} placeholder={placeholder} size={size} {...rest} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormSwitch;
|
|
@ -1,28 +0,0 @@
|
|||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import { FiMenu } from 'react-icons/fi';
|
||||
import { getUrl } from '../../core/helpers/url-helpers';
|
||||
|
||||
interface IProps {
|
||||
onClickMenu: () => void;
|
||||
}
|
||||
|
||||
const Header: React.FC<IProps> = ({ onClickMenu }) => {
|
||||
return (
|
||||
<header style={{ width: '100%' }} className="flex h-12 md:h-0">
|
||||
<Flex className="items-center border-b-2 bg-graycool px-5 flex-1 py-2">
|
||||
<div onClick={onClickMenu} className="visible md:invisible absolute cursor-pointer py-2">
|
||||
<FiMenu color="black" />
|
||||
</div>
|
||||
<Flex justifyContent="center" flex="1">
|
||||
<Link href="/" passHref>
|
||||
<img src={getUrl('tipi.png')} alt="Tipi Logo" width={30} height={30} />
|
||||
</Link>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
|
@ -0,0 +1,3 @@
|
|||
.topActions {
|
||||
min-height: 50px;
|
||||
}
|
|
@ -1,81 +1,74 @@
|
|||
import { Flex, useDisclosure, Spinner, Breadcrumb, BreadcrumbItem, useColorModeValue, Box } from '@chakra-ui/react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import React, { useEffect } from 'react';
|
||||
import { FiChevronRight } from 'react-icons/fi';
|
||||
import Header from './Header';
|
||||
import Menu from './SideMenu';
|
||||
import MenuDrawer from './MenuDrawer';
|
||||
import { useRefreshTokenQuery } from '../../generated/graphql';
|
||||
import clsx from 'clsx';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
import semver from 'semver';
|
||||
import { useRefreshTokenQuery, useVersionQuery } from '../../generated/graphql';
|
||||
import { Header } from '../ui/Header';
|
||||
import styles from './Layout.module.scss';
|
||||
|
||||
interface IProps {
|
||||
loading?: boolean;
|
||||
breadcrumbs?: { name: string; href: string; current?: boolean }[];
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout: React.FC<IProps> = ({ children, loading, breadcrumbs }) => {
|
||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||
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) {
|
||||
localStorage.setItem('token', data.refreshToken.token);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const menubg = useColorModeValue('#F1F3F4', '#202736');
|
||||
const bg = useColorModeValue('white', '#1a202c');
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<Flex className="justify-center flex-1">
|
||||
<Spinner />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
}, [data?.refreshToken?.token]);
|
||||
|
||||
const renderBreadcrumbs = () => {
|
||||
if (!breadcrumbs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb spacing="8px" separator={<FiChevronRight color="gray.500" />}>
|
||||
{breadcrumbs?.map((breadcrumb, index) => {
|
||||
return (
|
||||
<BreadcrumbItem className="hover:underline" isCurrentPage={breadcrumb.current} key={index}>
|
||||
<Link href={breadcrumb.href}>{breadcrumb.name}</Link>
|
||||
</BreadcrumbItem>
|
||||
);
|
||||
})}
|
||||
</Breadcrumb>
|
||||
<ol className="breadcrumb" aria-label="breadcrumbs">
|
||||
{breadcrumbs.map((breadcrumb) => (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-testid={`${title?.toLowerCase().split(' ').join('-')}-layout`} className="page">
|
||||
<Head>
|
||||
<title>Tipi</title>
|
||||
<title>{title} - Tipi</title>
|
||||
</Head>
|
||||
<Flex height="100vh" direction="column">
|
||||
<MenuDrawer isOpen={isOpen} onClose={onClose}>
|
||||
<Menu />
|
||||
</MenuDrawer>
|
||||
<Header onClickMenu={onOpen} />
|
||||
<Flex flex={1}>
|
||||
<Flex height="100vh" bg={menubg} className="sticky top-0 invisible md:visible w-0 md:w-64">
|
||||
<Menu />
|
||||
</Flex>
|
||||
<Box bg={bg} className="flex-1 px-4 py-4 md:px-10 md:py-8">
|
||||
{/* <UpdateBanner /> */}
|
||||
{renderBreadcrumbs()}
|
||||
{renderContent()}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
<ReactTooltip offset={{ right: 3 }} effect="solid" place="bottom" />
|
||||
<Header isUpdateAvailable={!isLatest} />
|
||||
<div className="page-wrapper">
|
||||
<div className="page-header d-print-none">
|
||||
<div className="container-xl">
|
||||
<div className={clsx('align-items-stretch align-items-md-center d-flex flex-column flex-md-row ', styles.topActions)}>
|
||||
<div className="me-3 text-white">
|
||||
<div className="page-pretitle">{renderBreadcrumbs()}</div>
|
||||
<h2 className="page-title">{title}</h2>
|
||||
</div>
|
||||
<div className="flex-fill">{actions}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div className="container-xl">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
import { Drawer, DrawerBody, DrawerCloseButton, DrawerContent, DrawerHeader, DrawerOverlay, useColorModeValue } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
interface IProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const MenuDrawer: React.FC<IProps> = ({ children, isOpen, onClose }) => {
|
||||
const menubg = useColorModeValue('#F1F3F4', '#202736');
|
||||
|
||||
return (
|
||||
<Drawer size="xs" isOpen={isOpen} placement="left" onClose={onClose}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent bg={menubg}>
|
||||
<DrawerCloseButton />
|
||||
<DrawerHeader>My Tipi</DrawerHeader>
|
||||
<DrawerBody display="flex">{children}</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuDrawer;
|
|
@ -1,96 +0,0 @@
|
|||
import { AiOutlineDashboard, AiOutlineSetting, AiOutlineAppstore } from 'react-icons/ai';
|
||||
import { FaAppStore, FaRegMoon } from 'react-icons/fa';
|
||||
import { FiLogOut } from 'react-icons/fi';
|
||||
import Package from '../../../package.json';
|
||||
import { Badge, Box, Divider, Flex, List, ListItem, Switch, useColorMode } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/router';
|
||||
import { IconType } from 'react-icons';
|
||||
import { useLogoutMutation, useVersionQuery } from '../../generated/graphql';
|
||||
import { getUrl } from '../../core/helpers/url-helpers';
|
||||
import { BsHeart } from 'react-icons/bs';
|
||||
import semver from 'semver';
|
||||
|
||||
const SideMenu: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { colorMode, setColorMode } = useColorMode();
|
||||
const [logout] = useLogoutMutation({ refetchQueries: ['Me'] });
|
||||
const versionQuery = useVersionQuery();
|
||||
const path = router.pathname.split('/')[1];
|
||||
|
||||
const defaultVersion = '0.0.0';
|
||||
const isLatest = semver.gte(versionQuery.data?.version.current || defaultVersion, versionQuery.data?.version.latest || defaultVersion);
|
||||
|
||||
const renderMenuItem = (title: string, name: string, Icon: IconType) => {
|
||||
const selected = path === name;
|
||||
|
||||
const itemClass = clsx('mx-3 border-transparent rounded-lg p-3 transition-colors border-1', {
|
||||
'drop-shadow-sm border-gray-200': selected && colorMode === 'light',
|
||||
'bg-white': selected && colorMode === 'light',
|
||||
});
|
||||
|
||||
return (
|
||||
<Link href={`/${name}`} passHref>
|
||||
<div className={itemClass}>
|
||||
<ListItem className={'flex items-center cursor-pointer hover:font-bold'}>
|
||||
<Icon size={20} className={clsx('mr-3', { 'text-red-600': selected && colorMode === 'light', 'text-red-200': selected && colorMode === 'dark' })} />
|
||||
<p className={clsx({ 'font-bold': selected, 'text-red-600': selected && colorMode === 'light', 'text-red-200': selected && colorMode === 'dark' })}>{title}</p>
|
||||
</ListItem>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangeColorMode = (checked: boolean) => {
|
||||
setColorMode(checked ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
localStorage.removeItem('token');
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex-1 flex flex-col p-0 md:p-4">
|
||||
<img className="self-center mb-5 logo mt-0 md:mt-5" src={getUrl('tipi.png')} width={512} height={512} />
|
||||
<List spacing={3} className="pt-5">
|
||||
{renderMenuItem('Dashboard', '', AiOutlineDashboard)}
|
||||
{renderMenuItem('My Apps', 'apps', AiOutlineAppstore)}
|
||||
{renderMenuItem('App Store', 'app-store', FaAppStore)}
|
||||
{renderMenuItem('Settings', 'settings', AiOutlineSetting)}
|
||||
</List>
|
||||
<Divider className="my-3" />
|
||||
<Flex flex="1" />
|
||||
<List>
|
||||
<div className="mx-3">
|
||||
<a href="https://github.com/meienberger/runtipi?sponsor=1" target="_blank" rel="noreferrer">
|
||||
<ListItem className="cursor-pointer hover:font-bold flex items-center mb-4">
|
||||
<BsHeart size={20} className="mr-3" />
|
||||
<p className="flex-1 mb-1 text-md">Donate</p>
|
||||
</ListItem>
|
||||
</a>
|
||||
<ListItem onClick={handleLogout} className="cursor-pointer hover:font-bold flex items-center mb-5">
|
||||
<FiLogOut size={20} className="mr-3" />
|
||||
<p className="flex-1">Log out</p>
|
||||
</ListItem>
|
||||
<ListItem className="flex items-center">
|
||||
<FaRegMoon size={20} className="mr-3" />
|
||||
<p className="flex-1">Dark mode</p>
|
||||
<Switch isChecked={colorMode === 'dark'} onChange={(event) => handleChangeColorMode(event.target.checked)} />
|
||||
</ListItem>
|
||||
</div>
|
||||
</List>
|
||||
|
||||
<div className="pb-1 text-center text-sm text-gray-400 mt-5">Tipi version {Package.version}</div>
|
||||
{!isLatest && (
|
||||
<Badge className="self-center mt-1" colorScheme="green">
|
||||
New version available
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideMenu;
|
|
@ -1,36 +0,0 @@
|
|||
import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, CloseButton } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import { useVersionQuery } from '../../generated/graphql';
|
||||
|
||||
const UpdateBanner = () => {
|
||||
const { data, loading } = useVersionQuery();
|
||||
|
||||
const isLatest = data?.version.latest === data?.version.current;
|
||||
|
||||
if (isLatest || (loading && !data?.version)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClose = () => {};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert status="info" className="flex mb-3">
|
||||
<AlertIcon />
|
||||
<Box className="flex-1">
|
||||
<AlertTitle>New version available!</AlertTitle>
|
||||
<AlertDescription>
|
||||
There is a new version of Tipi available ({data?.version.latest}). Visit{' '}
|
||||
<a className="text-blue-600" target="_blank" rel="noreferrer" href={'https://github.com/meienberger/runtipi/releases/latest'}>
|
||||
GitHub
|
||||
</a>{' '}
|
||||
for update instructions.
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
<CloseButton alignSelf="flex-start" position="relative" right={-1} top={-1} onClick={onClose} />
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateBanner;
|
|
@ -1 +1 @@
|
|||
export { default } from './Layout';
|
||||
export { Layout } from './Layout';
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
import { Flex, Spinner } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
const LoadingScreen = () => {
|
||||
return (
|
||||
<Flex height="100vh" alignItems="center" justifyContent="center">
|
||||
<Spinner size="lg" />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingScreen;
|
|
@ -4,29 +4,30 @@ import remarkBreaks from 'remark-breaks';
|
|||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMdx from 'remark-mdx';
|
||||
|
||||
const Markdown: React.FC<{ children: string; className: string }> = ({ children, className }) => {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className={className}
|
||||
components={{
|
||||
h1: (props) => <h1 {...props} className="text-2xl font-bold mb-4 text-center md:text-left" />,
|
||||
h2: (props) => <h2 {...props} className="text-xl font-bold mb-4 text-center md:text-left" />,
|
||||
h3: (props) => <h3 {...props} className="text-lg font-bold mb-4 text-center md:text-left" />,
|
||||
ul: (props) => <ul {...props} className="list-disc pl-4 mb-4" />,
|
||||
img: (props) => (
|
||||
<div className="flex justify-center py-2">
|
||||
<img {...props} className="w-full lg:w-2/3" />
|
||||
</div>
|
||||
),
|
||||
p: (props) => <p {...props} className="mb-4 text-center md:text-left" />,
|
||||
a: (props) => <a target="_blank" rel="noreferrer" {...props} className="text-blue-500" href={props.href} />,
|
||||
div: (props) => <div {...props} className="mb-4" />,
|
||||
}}
|
||||
remarkPlugins={[remarkBreaks, remarkGfm, remarkMdx]}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
};
|
||||
const MarkdownImg = (props: Pick<React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>, 'key' | keyof React.ImgHTMLAttributes<HTMLImageElement>>) => (
|
||||
<div className="d-flex justify-content-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img alt="app-demonstration" {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const Markdown: React.FC<{ children: string; className: string }> = ({ children, className }) => (
|
||||
<ReactMarkdown
|
||||
className={className}
|
||||
components={{
|
||||
// h1: (props) => <h1 {...props} className="text-2xl font-bold mb-4 text-center md:text-left" />,
|
||||
// h2: (props) => <h2 {...props} className="text-xl font-bold mb-4 text-center md:text-left" />,
|
||||
// h3: (props) => <h3 {...props} className="text-lg font-bold mb-4 text-center md:text-left" />,
|
||||
// ul: (props) => <ul {...props} className="list-disc pl-4 mb-4" />,
|
||||
img: MarkdownImg,
|
||||
// p: (props) => <p {...props} className="mb-4 text-left md:text-left" />,
|
||||
// a: (props) => <a target="_blank" rel="noreferrer" {...props} className="text-blue-500" href={props.href} />,
|
||||
// div: (props) => <div {...props} className="mb-4" />,
|
||||
}}
|
||||
remarkPlugins={[remarkBreaks, remarkGfm, remarkMdx]}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
|
||||
export default Markdown;
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import Image from 'next/image';
|
||||
import React from 'react';
|
||||
import { getUrl } from '../../core/helpers/url-helpers';
|
||||
import { Button } from '../ui/Button';
|
||||
|
||||
interface IProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onAction?: () => void;
|
||||
actionTitle?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const StatusScreen: React.FC<IProps> = ({ title, subtitle, onAction, actionTitle, loading = true }) => (
|
||||
<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"
|
||||
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>
|
||||
{loading && <div className="spinner-border spinner-border-sm text-muted" />}
|
||||
{onAction && (
|
||||
<div className="empty-action">
|
||||
<Button onClick={onAction} className="btn">
|
||||
{actionTitle}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
1
packages/dashboard/src/components/StatusScreen/index.ts
Normal file
1
packages/dashboard/src/components/StatusScreen/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { StatusScreen } from './StatusScreen';
|
|
@ -1,14 +0,0 @@
|
|||
import { Flex, Spinner, Text } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
const RestartingScreen = () => {
|
||||
return (
|
||||
<Flex height="100vh" direction="column" alignItems="center" justifyContent="center">
|
||||
<Text fontSize="2xl">Your system is restarting...</Text>
|
||||
<Text color="gray.500">Please do not refresh this page</Text>
|
||||
<Spinner size="lg" className="mt-5" />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default RestartingScreen;
|
|
@ -1,54 +0,0 @@
|
|||
import { SlideFade } from '@chakra-ui/react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { SystemStatus } from '../../state/systemStore';
|
||||
import RestartingScreen from './RestartingScreen';
|
||||
import UpdatingScreen from './UpdatingScreen';
|
||||
|
||||
interface IProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
const StatusWrapper: React.FC<IProps> = ({ children }) => {
|
||||
const [s, setS] = useState<SystemStatus>(SystemStatus.RUNNING);
|
||||
const { data } = useSWR('/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();
|
||||
}
|
||||
|
||||
if (data?.status === SystemStatus.RUNNING) {
|
||||
setS(SystemStatus.RUNNING);
|
||||
}
|
||||
if (data?.status === SystemStatus.RESTARTING) {
|
||||
setS(SystemStatus.RESTARTING);
|
||||
}
|
||||
if (data?.status === SystemStatus.UPDATING) {
|
||||
setS(SystemStatus.UPDATING);
|
||||
}
|
||||
}, [data?.status, s]);
|
||||
|
||||
if (s === SystemStatus.RESTARTING) {
|
||||
return (
|
||||
<SlideFade in>
|
||||
<RestartingScreen />
|
||||
</SlideFade>
|
||||
);
|
||||
}
|
||||
|
||||
if (s === SystemStatus.UPDATING) {
|
||||
return (
|
||||
<SlideFade in>
|
||||
<UpdatingScreen />
|
||||
</SlideFade>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default StatusWrapper;
|
|
@ -1,14 +0,0 @@
|
|||
import { Text, Flex, Spinner } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
const UpdatingScreen = () => {
|
||||
return (
|
||||
<Flex height="100vh" direction="column" alignItems="center" justifyContent="center">
|
||||
<Text fontSize="2xl">Your system is updating...</Text>
|
||||
<Text color="gray.500">Please do not refresh this page</Text>
|
||||
<Spinner size="lg" className="mt-5" />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdatingScreen;
|
|
@ -0,0 +1,42 @@
|
|||
import { graphql } from 'msw';
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '../../../../tests/test-utils';
|
||||
import { server } from '../../../mocks/server';
|
||||
import { AuthProvider } from './AuthProvider';
|
||||
|
||||
describe('Test: AuthProvider', () => {
|
||||
it('should render login form if user is not logged in', async () => {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<div>Should not render</div>
|
||||
</AuthProvider>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument());
|
||||
expect(screen.queryByText('Should not render')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render children if user is logged in', async () => {
|
||||
server.use(graphql.query('Me', (req, res, ctx) => res(ctx.data({ me: { id: '1' } }))));
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<div>Should render</div>
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Should render')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should render register form if app is not configured', async () => {
|
||||
server.use(graphql.query('Configured', (req, res, ctx) => res(ctx.data({ isConfigured: false }))));
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<div>Should not render</div>
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Register')).toBeInTheDocument());
|
||||
expect(screen.queryByText('Should not render')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import { useConfiguredQuery, useMeQuery } from '../../../generated/graphql';
|
||||
import { LoginContainer } from '../../../modules/Auth/containers/LoginContainer';
|
||||
import { RegisterContainer } from '../../../modules/Auth/containers/RegisterContainer';
|
||||
import { StatusScreen } from '../../StatusScreen';
|
||||
|
||||
interface IProps {
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<IProps> = ({ children }) => {
|
||||
const user = useMeQuery();
|
||||
const isConfigured = useConfiguredQuery();
|
||||
const loading = user.loading || isConfigured.loading;
|
||||
|
||||
if (loading && !user.data?.me) {
|
||||
return <StatusScreen title="" subtitle="" />;
|
||||
}
|
||||
|
||||
if (user.data?.me) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!isConfigured?.data?.isConfigured) {
|
||||
return <RegisterContainer />;
|
||||
}
|
||||
|
||||
return <LoginContainer />;
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { AuthProvider } from './AuthProvider';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,47 @@
|
|||
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';
|
||||
|
||||
interface IProps {
|
||||
children: ReactElement;
|
||||
}
|
||||
|
||||
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, 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) {
|
||||
router.reload();
|
||||
}
|
||||
|
||||
if (data?.status === SystemStatus.RUNNING) {
|
||||
setS(SystemStatus.RUNNING);
|
||||
}
|
||||
if (data?.status === SystemStatus.RESTARTING) {
|
||||
setS(SystemStatus.RESTARTING);
|
||||
}
|
||||
if (data?.status === SystemStatus.UPDATING) {
|
||||
setS(SystemStatus.UPDATING);
|
||||
}
|
||||
}, [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" />;
|
||||
}
|
||||
|
||||
if (s === SystemStatus.UPDATING) {
|
||||
return <StatusScreen title="Your system is updating..." subtitle="Please do not refresh this page" />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { StatusProvider } from './StatusProvider';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import { IToast, useToastStore } from '../../../state/toastStore';
|
||||
import { Toast } from '../../ui/Toast';
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { toasts, removeToast } = useToastStore();
|
||||
|
||||
const renderToast = (toast: IToast) => {
|
||||
const { status, title, description, id } = toast;
|
||||
|
||||
return <Toast status={status} title={title} message={description} id={id} onClose={() => removeToast(id)} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className="position-fixed bottom-0 end-0 p-3" style={{ zIndex: 11 }}>
|
||||
{renderToast(toast)}
|
||||
</div>
|
||||
))}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { ToastProvider } from './ToastProvider';
|
50
packages/dashboard/src/components/ui/Button/Button.test.tsx
Normal file
50
packages/dashboard/src/components/ui/Button/Button.test.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import React from 'react';
|
||||
import { render, fireEvent, cleanup } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { Button } from './Button';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('Button component', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<Button>Click me</Button>);
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render children correctly', () => {
|
||||
const { getByText } = render(<Button>Click me</Button>);
|
||||
expect(getByText('Click me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply className prop correctly', () => {
|
||||
const { container } = render(<Button className="test-class">Click me</Button>);
|
||||
expect(container.querySelector('button')).toHaveClass('test-class');
|
||||
});
|
||||
|
||||
it('should render spinner when loading prop is true', () => {
|
||||
const { container } = render(<Button loading>Click me</Button>);
|
||||
expect(container.querySelector('.spinner-border')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable button when disabled prop is true', () => {
|
||||
const { container } = render(<Button disabled>Click me</Button>);
|
||||
expect(container.querySelector('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should set type correctly', () => {
|
||||
const { container } = render(<Button type="submit">Click me</Button>);
|
||||
expect(container.querySelector('button')).toHaveAttribute('type', 'submit');
|
||||
});
|
||||
|
||||
it('should applies width correctly', () => {
|
||||
const { container } = render(<Button width={100}>Click me</Button>);
|
||||
expect(container.querySelector('button')).toHaveStyle('width: 100px');
|
||||
});
|
||||
|
||||
it('should call onClick callback when clicked', () => {
|
||||
const onClick = jest.fn();
|
||||
const { container } = render(<Button onClick={onClick}>Click me</Button>);
|
||||
fireEvent.click(container.querySelector('button') as HTMLButtonElement);
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
21
packages/dashboard/src/components/ui/Button/Button.tsx
Normal file
21
packages/dashboard/src/components/ui/Button/Button.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface IProps {
|
||||
className?: string;
|
||||
type?: 'submit' | 'reset' | 'button';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
width?: number | null;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, IProps>(({ type, className, children, loading, disabled, onClick, width, ...rest }, ref) => {
|
||||
const styles = { width: width ? `${width}px` : 'auto' };
|
||||
return (
|
||||
<button style={styles} onClick={onClick} disabled={disabled || loading} ref={ref} className={clsx('btn', className, { disabled: disabled || loading })} type={type} {...rest}>
|
||||
{loading ? <span className="spinner-border spinner-border-sm mb-1 mx-2" role="status" aria-hidden="true" /> : children}
|
||||
</button>
|
||||
);
|
||||
});
|
1
packages/dashboard/src/components/ui/Button/index.ts
Normal file
1
packages/dashboard/src/components/ui/Button/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Button } from './Button';
|
|
@ -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();
|
||||
});
|
||||
});
|
13
packages/dashboard/src/components/ui/DataGrid/DataGrid.tsx
Normal file
13
packages/dashboard/src/components/ui/DataGrid/DataGrid.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
|
||||
interface IProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DataGrid: React.FC<IProps> = ({ children }) => (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="datagrid">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
|
||||
interface IProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DataGridItem: React.FC<IProps> = ({ children, title }) => (
|
||||
<div className="datagrid-item">
|
||||
<div className="datagrid-title">{title}</div>
|
||||
<div className="datagrid-content">{children}</div>
|
||||
</div>
|
||||
);
|
2
packages/dashboard/src/components/ui/DataGrid/index.ts
Normal file
2
packages/dashboard/src/components/ui/DataGrid/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { DataGrid } from './DataGrid';
|
||||
export { DataGridItem } from './DataGridItem';
|
|
@ -0,0 +1,4 @@
|
|||
.emptyImage {
|
||||
height: 80px;
|
||||
width: 80px;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
38
packages/dashboard/src/components/ui/EmptyPage/EmptyPage.tsx
Normal file
38
packages/dashboard/src/components/ui/EmptyPage/EmptyPage.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
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 './EmptyPage.module.scss';
|
||||
|
||||
interface IProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onAction?: () => void;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
export const EmptyPage: React.FC<IProps> = ({ title, subtitle, onAction, actionLabel }) => (
|
||||
<div data-testid="empty-page" className="card empty">
|
||||
<Image
|
||||
src={getUrl('empty.svg')}
|
||||
alt="Empty box"
|
||||
height="80"
|
||||
width="80"
|
||||
className={clsx(styles.emptyImage, 'mb-3')}
|
||||
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 data-testid="empty-page-action" onClick={onAction} className="btn-primary">
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
1
packages/dashboard/src/components/ui/EmptyPage/index.ts
Normal file
1
packages/dashboard/src/components/ui/EmptyPage/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { EmptyPage } from './EmptyPage';
|
|
@ -0,0 +1,4 @@
|
|||
.emptyImage {
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '../../../../tests/test-utils';
|
||||
import { ErrorPage } from './ErrorPage';
|
||||
|
||||
describe('ErrorPage', () => {
|
||||
it('should render the error message', () => {
|
||||
const errorMessage = 'There was an error';
|
||||
render(<ErrorPage error={errorMessage} />);
|
||||
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the retry button when onRetry is provided', () => {
|
||||
const onRetry = jest.fn();
|
||||
render(<ErrorPage onRetry={onRetry} />);
|
||||
|
||||
expect(screen.getByTestId('error-page-action')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the retry button when onRetry is not provided', () => {
|
||||
render(<ErrorPage />);
|
||||
|
||||
expect(screen.queryByTestId('error-page-action')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call the onRetry callback when the retry button is clicked', () => {
|
||||
const onRetry = jest.fn();
|
||||
render(<ErrorPage onRetry={onRetry} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('error-page-action'));
|
||||
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
39
packages/dashboard/src/components/ui/ErrorPage/ErrorPage.tsx
Normal file
39
packages/dashboard/src/components/ui/ErrorPage/ErrorPage.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { IconRotateClockwise } from '@tabler/icons';
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/image';
|
||||
import React from 'react';
|
||||
import { getUrl } from '../../../core/helpers/url-helpers';
|
||||
import { Button } from '../Button';
|
||||
import styles from './ErrorPage.module.scss';
|
||||
|
||||
interface IProps {
|
||||
error?: string;
|
||||
onRetry?: () => void;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
export const ErrorPage: React.FC<IProps> = ({ error, onRetry }) => (
|
||||
<div data-testid="error-page" className="card empty">
|
||||
<Image
|
||||
src={getUrl('error.png')}
|
||||
alt="Empty box"
|
||||
height="100"
|
||||
width="100"
|
||||
className={clsx(styles.emptyImage, 'mb-3 mt-2')}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
}}
|
||||
/>
|
||||
<p className="empty-title">An error occured</p>
|
||||
<p className="empty-subtitle text-muted">{error}</p>
|
||||
<div className="empty-action">
|
||||
{onRetry && (
|
||||
<Button data-testid="error-page-action" onClick={onRetry} className="btn-danger">
|
||||
<IconRotateClockwise />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
1
packages/dashboard/src/components/ui/ErrorPage/index.ts
Normal file
1
packages/dashboard/src/components/ui/ErrorPage/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ErrorPage } from './ErrorPage';
|
81
packages/dashboard/src/components/ui/Header/Header.test.tsx
Normal file
81
packages/dashboard/src/components/ui/Header/Header.test.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import React from 'react';
|
||||
import { fireEvent, render, renderHook, screen } from '../../../../tests/test-utils';
|
||||
import { useUIStore } from '../../../state/uiStore';
|
||||
import { Header } from './Header';
|
||||
|
||||
const logoutFn = jest.fn();
|
||||
const reloadFn = jest.fn();
|
||||
|
||||
jest.mock('../../../generated/graphql', () => ({
|
||||
useLogoutMutation: () => [logoutFn],
|
||||
}));
|
||||
|
||||
jest.mock('next/router', () => {
|
||||
const actualRouter = jest.requireActual('next-router-mock');
|
||||
|
||||
return {
|
||||
...actualRouter,
|
||||
reload: () => reloadFn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Header', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<Header />);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the brand logo', () => {
|
||||
const { container } = render(<Header />);
|
||||
expect(container).toHaveTextContent('Tipi');
|
||||
expect(container).toContainElement(screen.getByAltText('Tipi logo'));
|
||||
});
|
||||
|
||||
it('renders the dark mode toggle', () => {
|
||||
const { container } = render(<Header />);
|
||||
const darkModeToggle = container.querySelector('[data-tip="Dark mode"]');
|
||||
expect(darkModeToggle).toContainElement(screen.getByTestId('icon-moon'));
|
||||
});
|
||||
|
||||
it('renders the light mode toggle', () => {
|
||||
const { container } = render(<Header />);
|
||||
const lightModeToggle = container.querySelector('[data-tip="Light mode"]');
|
||||
expect(lightModeToggle).toContainElement(screen.getByTestId('icon-sun'));
|
||||
});
|
||||
|
||||
it('Should toggle the dark mode on click of the dark mode toggle', () => {
|
||||
const { result } = renderHook(() => useUIStore());
|
||||
|
||||
const { container } = render(<Header />);
|
||||
const darkModeToggle = container.querySelector('[data-tip="Dark mode"]');
|
||||
fireEvent.click(darkModeToggle as Element);
|
||||
|
||||
expect(result.current.darkMode).toBe(true);
|
||||
});
|
||||
|
||||
it('Should toggle the dark mode on click of the light mode toggle', () => {
|
||||
const { result } = renderHook(() => useUIStore());
|
||||
|
||||
const { container } = render(<Header />);
|
||||
const lightModeToggle = container.querySelector('[data-tip="Light mode"]');
|
||||
fireEvent.click(lightModeToggle as Element);
|
||||
|
||||
expect(result.current.darkMode).toBe(false);
|
||||
});
|
||||
|
||||
it('Should call the logout mutation on logout', () => {
|
||||
const { container } = render(<Header />);
|
||||
const logoutButton = container.querySelector('[data-tip="Log out"]');
|
||||
fireEvent.click(logoutButton as Element);
|
||||
|
||||
expect(logoutFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Should reload the page with next/router on logout', () => {
|
||||
const { container } = render(<Header />);
|
||||
const logoutButton = container.querySelector('[data-tip="Log out"]');
|
||||
fireEvent.click(logoutButton as Element);
|
||||
|
||||
expect(reloadFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
79
packages/dashboard/src/components/ui/Header/Header.tsx
Normal file
79
packages/dashboard/src/components/ui/Header/Header.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
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 Link from 'next/link';
|
||||
import { getUrl } from '../../../core/helpers/url-helpers';
|
||||
import { useUIStore } from '../../../state/uiStore';
|
||||
import { NavBar } from '../NavBar';
|
||||
import { useLogoutMutation } from '../../../generated/graphql';
|
||||
|
||||
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');
|
||||
router.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="navbar navbar-expand-md navbar-dark navbar-overlap d-print-none">
|
||||
<div className="container-xl">
|
||||
<button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu">
|
||||
<span className="navbar-toggler-icon" />
|
||||
</button>
|
||||
<Link href="/" passHref>
|
||||
<h1 className="navbar-brand d-none-navbar-horizontal pe-0 pe-md-3">
|
||||
<Image
|
||||
priority
|
||||
alt="Tipi logo"
|
||||
className={clsx('navbar-brand-image me-3')}
|
||||
width={100}
|
||||
height={100}
|
||||
src={getUrl('tipi.png')}
|
||||
style={{
|
||||
width: '30px',
|
||||
maxWidth: '30px',
|
||||
height: 'auto',
|
||||
}}
|
||||
/>
|
||||
Tipi
|
||||
</h1>
|
||||
</Link>
|
||||
<div className="navbar-nav flex-row order-md-last">
|
||||
<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 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">
|
||||
<IconHeart className="me-1 icon text-pink" size={24} />
|
||||
Sponsor
|
||||
</a>
|
||||
</div>
|
||||
</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 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 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 isUpdateAvailable={isUpdateAvailable} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
1
packages/dashboard/src/components/ui/Header/index.ts
Normal file
1
packages/dashboard/src/components/ui/Header/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Header } from './Header';
|
105
packages/dashboard/src/components/ui/Input/Input.test.tsx
Normal file
105
packages/dashboard/src/components/ui/Input/Input.test.tsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
import React from 'react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { Input } from './Input';
|
||||
import { fireEvent, render, waitFor } from '../../../../tests/test-utils';
|
||||
|
||||
describe('Input', () => {
|
||||
it('should render without errors', () => {
|
||||
const { container } = render(<Input name="test-input" />);
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render the label if provided', () => {
|
||||
const { getByLabelText } = render(<Input name="test-input" label="Test Label" />);
|
||||
const input = getByLabelText('Test Label');
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render the placeholder if provided', () => {
|
||||
const { getByPlaceholderText } = render(<Input name="test-input" placeholder="Test Placeholder" />);
|
||||
const input = getByPlaceholderText('Test Placeholder');
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render the error message if provided', () => {
|
||||
const { getByText } = render(<Input name="test-input" error="Test Error" />);
|
||||
const error = getByText('Test Error');
|
||||
expect(error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call onChange when the input value is changed', async () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByLabelText } = render(<Input name="test-input" label="Test Label" onChange={onChange} />);
|
||||
const input = getByLabelText('Test Label');
|
||||
fireEvent.change(input, { target: { value: 'changed' } });
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('should call onBlur when the input is blurred', async () => {
|
||||
const onBlur = jest.fn();
|
||||
const { getByLabelText } = render(<Input name="test-input" label="Test Label" onBlur={onBlur} />);
|
||||
const input = getByLabelText('Test Label');
|
||||
fireEvent.blur(input);
|
||||
await waitFor(() => expect(onBlur).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('should set the input type if provided', () => {
|
||||
const { getByLabelText } = render(<Input name="test-input" label="Test Label" type="password" />);
|
||||
const input = getByLabelText('Test Label') as HTMLInputElement;
|
||||
expect(input.type).toBe('password');
|
||||
});
|
||||
|
||||
it('should set the input value if provided', () => {
|
||||
const { getByLabelText } = render(<Input name="test-input" label="Test Label" value="Test Value" onChange={jest.fn} />);
|
||||
const input = getByLabelText('Test Label') as HTMLInputElement;
|
||||
expect(input.value).toBe('Test Value');
|
||||
});
|
||||
|
||||
it('should apply the className prop to the container div', () => {
|
||||
const { container } = render(<Input name="test-input" className="test-class" />);
|
||||
expect(container.firstChild).toHaveClass('test-class');
|
||||
});
|
||||
|
||||
it('should apply the isInvalid prop to the input element', () => {
|
||||
const { getByLabelText } = render(<Input name="test-input" label="Test Label" isInvalid />);
|
||||
const input = getByLabelText('Test Label');
|
||||
expect(input).toHaveClass('is-invalid', 'is-invalid-lite');
|
||||
});
|
||||
|
||||
it('should apply the disabled prop to the input element', () => {
|
||||
const { getByLabelText } = render(<Input name="test-input" label="Test Label" disabled />);
|
||||
const input = getByLabelText('Test Label');
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should set the input name attribute if provided', () => {
|
||||
const { getByLabelText } = render(<Input name="test-input" label="Test Label" />);
|
||||
const input = getByLabelText('Test Label');
|
||||
expect(input).toHaveAttribute('name', 'test-input');
|
||||
});
|
||||
|
||||
it('should set the input id attribute if provided', () => {
|
||||
const { getByLabelText } = render(<Input name="test-input" label="Test Label" />);
|
||||
const input = getByLabelText('Test Label');
|
||||
expect(input).toHaveAttribute('id', 'test-input');
|
||||
});
|
||||
|
||||
it('should set the input ref if provided', () => {
|
||||
const ref = React.createRef<HTMLInputElement>();
|
||||
const { getByLabelText } = render(<Input name="test-input" label="Test Label" ref={ref} />);
|
||||
const input = getByLabelText('Test Label');
|
||||
expect(input).toEqual(ref.current);
|
||||
});
|
||||
|
||||
it('should set the input type attribute to "text" if not provided or if an invalid value is provided', () => {
|
||||
const { getByLabelText } = render(<Input name="test-input" label="Test Label" />);
|
||||
const input1 = getByLabelText('Test Label') as HTMLInputElement;
|
||||
expect(input1.type).toBe('text');
|
||||
});
|
||||
|
||||
it('should set the input placeholder attribute if provided', () => {
|
||||
const { getByLabelText } = render(<Input name="test-input" label="Test Label" placeholder="Test Placeholder" />);
|
||||
const input = getByLabelText('Test Label');
|
||||
expect(input).toHaveAttribute('placeholder', 'Test Placeholder');
|
||||
});
|
||||
});
|
39
packages/dashboard/src/components/ui/Input/Input.tsx
Normal file
39
packages/dashboard/src/components/ui/Input/Input.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface IProps {
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
isInvalid?: boolean;
|
||||
type?: HTMLInputElement['type'];
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
name?: string;
|
||||
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
disabled?: boolean;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
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">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
id={name}
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
type={type}
|
||||
ref={ref}
|
||||
className={clsx('form-control', { 'is-invalid is-invalid-lite': error || isInvalid })}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{error && <div className="invalid-feedback">{error}</div>}
|
||||
</div>
|
||||
));
|
1
packages/dashboard/src/components/ui/Input/index.ts
Normal file
1
packages/dashboard/src/components/ui/Input/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Input } from './Input';
|
37
packages/dashboard/src/components/ui/Modal/Modal.module.scss
Normal file
37
packages/dashboard/src/components/ui/Modal/Modal.module.scss
Normal file
|
@ -0,0 +1,37 @@
|
|||
@keyframes zoomIn {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dimmedBackground {
|
||||
from {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
to {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.dimmedBackground {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
animation-name: dimmedBackground;
|
||||
animation-duration: 0.2s;
|
||||
animation-iteration-count: 1;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.zoomIn {
|
||||
animation-name: zoomIn;
|
||||
animation-duration: 0.25s;
|
||||
animation-iteration-count: 1;
|
||||
animation-timing-function: spring;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
141
packages/dashboard/src/components/ui/Modal/Modal.test.tsx
Normal file
141
packages/dashboard/src/components/ui/Modal/Modal.test.tsx
Normal file
|
@ -0,0 +1,141 @@
|
|||
import React from 'react';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { fireEvent, render } from '../../../../tests/test-utils';
|
||||
import { Modal } from './Modal';
|
||||
import { ModalBody } from './ModalBody';
|
||||
import { ModalFooter } from './ModalFooter';
|
||||
import { ModalHeader } from './ModalHeader';
|
||||
|
||||
describe('Modal component', () => {
|
||||
it('should render without errors', () => {
|
||||
const { container } = render(
|
||||
<Modal onClose={() => {}}>
|
||||
<p>Test modal content</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not be visible by default', () => {
|
||||
const { queryByTestId } = render(
|
||||
<Modal onClose={() => {}}>
|
||||
<p>Test modal content</p>
|
||||
</Modal>,
|
||||
);
|
||||
// display should be none
|
||||
expect(queryByTestId('modal')).toHaveStyle('display: none');
|
||||
});
|
||||
|
||||
it('should be visible when `isOpen` prop is true', () => {
|
||||
const { getByTestId } = render(
|
||||
<Modal onClose={() => {}} isOpen>
|
||||
<p>Test modal content</p>
|
||||
</Modal>,
|
||||
);
|
||||
// display should be block
|
||||
expect(getByTestId('modal')).toHaveStyle('display: block');
|
||||
});
|
||||
|
||||
it('should not be visible when `isOpen` prop is false', () => {
|
||||
const { queryByTestId } = render(
|
||||
<Modal onClose={() => {}}>
|
||||
<p>Test modal content</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(queryByTestId('modal')).toHaveStyle('display: none');
|
||||
});
|
||||
|
||||
it('should call the `onClose` prop when the close button is clicked', () => {
|
||||
const onClose = jest.fn();
|
||||
const { getByLabelText } = render(
|
||||
<Modal onClose={onClose} isOpen>
|
||||
<p>Test modal content</p>
|
||||
</Modal>,
|
||||
);
|
||||
fireEvent.click(getByLabelText('Close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call the `onClose` callback when user clicks outside of the modal', () => {
|
||||
const onClose = jest.fn();
|
||||
const { container } = render(
|
||||
<Modal onClose={onClose} isOpen>
|
||||
<p>Test modal content</p>
|
||||
</Modal>,
|
||||
);
|
||||
fireEvent.click(container);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have the correct `size` class when the `size` prop is passed', () => {
|
||||
const { getByTestId } = render(
|
||||
<Modal onClose={() => {}} isOpen size="sm">
|
||||
<p>Test modal content</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(getByTestId('modal')).toHaveClass('modal-sm');
|
||||
});
|
||||
|
||||
it('should have the correct `type` class when the `type` prop is passed', () => {
|
||||
const { getByTestId } = render(
|
||||
<Modal onClose={() => {}} isOpen type="primary">
|
||||
<p>Test modal content</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(getByTestId('modal-status')).toHaveClass('bg-primary');
|
||||
expect(getByTestId('modal-status')).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
it('should render the modal content as a child of the modal', () => {
|
||||
const { getByTestId, getByText } = render(
|
||||
<Modal onClose={() => {}} isOpen>
|
||||
<p>Test modal content</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(getByTestId('modal')).toContainElement(getByText('Test modal content'));
|
||||
});
|
||||
|
||||
it('should call the `onClose` callback when the escape key is pressed', () => {
|
||||
const onClose = jest.fn();
|
||||
render(
|
||||
<Modal onClose={onClose} isOpen>
|
||||
<p>Test modal content</p>
|
||||
</Modal>,
|
||||
);
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should correctly render with ModalBody', () => {
|
||||
const { getByTestId } = render(
|
||||
<Modal onClose={() => {}} isOpen>
|
||||
<ModalBody>
|
||||
<p>Test modal content</p>
|
||||
</ModalBody>
|
||||
</Modal>,
|
||||
);
|
||||
expect(getByTestId('modal-body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should correctly render with ModalFooter', () => {
|
||||
const { getByTestId } = render(
|
||||
<Modal onClose={() => {}} isOpen>
|
||||
<ModalFooter>
|
||||
<p>Test modal content</p>
|
||||
</ModalFooter>
|
||||
</Modal>,
|
||||
);
|
||||
expect(getByTestId('modal-footer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should correctly render with ModalHeader', () => {
|
||||
const { getByTestId } = render(
|
||||
<Modal onClose={() => {}} isOpen>
|
||||
<ModalHeader>
|
||||
<p>Test modal content</p>
|
||||
</ModalHeader>
|
||||
</Modal>,
|
||||
);
|
||||
expect(getByTestId('modal-header')).toBeInTheDocument();
|
||||
});
|
||||
});
|
65
packages/dashboard/src/components/ui/Modal/Modal.tsx
Normal file
65
packages/dashboard/src/components/ui/Modal/Modal.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import styles from './Modal.module.scss';
|
||||
|
||||
interface IProps {
|
||||
children: React.ReactNode;
|
||||
isOpen?: boolean;
|
||||
onClose: () => void;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
type?: 'default' | 'primary' | 'success' | 'info' | 'warning' | 'danger';
|
||||
}
|
||||
|
||||
export const Modal: React.FC<IProps> = ({ children, isOpen, onClose, size = 'lg', type }) => {
|
||||
const style = { display: 'none' };
|
||||
|
||||
if (isOpen) {
|
||||
style.display = 'block';
|
||||
}
|
||||
|
||||
const [modal, setModal] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
// On click outside
|
||||
const handleClickOutside = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (modal && !modal.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[modal, onClose],
|
||||
);
|
||||
|
||||
// On click outside
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
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 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 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>
|
||||
</div>
|
||||
);
|
||||
};
|
13
packages/dashboard/src/components/ui/Modal/ModalBody.tsx
Normal file
13
packages/dashboard/src/components/ui/Modal/ModalBody.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
interface IProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ModalBody: React.FC<IProps> = ({ children, className }) => (
|
||||
<div data-testid="modal-body" className={clsx('modal-body', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
11
packages/dashboard/src/components/ui/Modal/ModalFooter.tsx
Normal file
11
packages/dashboard/src/components/ui/Modal/ModalFooter.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import React from 'react';
|
||||
|
||||
interface IProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ModalFooter: React.FC<IProps> = ({ children }) => (
|
||||
<div data-testid="modal-footer" className="modal-footer">
|
||||
{children}
|
||||
</div>
|
||||
);
|
11
packages/dashboard/src/components/ui/Modal/ModalHeader.tsx
Normal file
11
packages/dashboard/src/components/ui/Modal/ModalHeader.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import React from 'react';
|
||||
|
||||
interface IProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ModalHeader: React.FC<IProps> = ({ children }) => (
|
||||
<div data-testid="modal-header" className="modal-header">
|
||||
{children}
|
||||
</div>
|
||||
);
|
4
packages/dashboard/src/components/ui/Modal/index.ts
Normal file
4
packages/dashboard/src/components/ui/Modal/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export { Modal } from './Modal';
|
||||
export { ModalBody } from './ModalBody';
|
||||
export { ModalFooter } from './ModalFooter';
|
||||
export { ModalHeader } from './ModalHeader';
|
50
packages/dashboard/src/components/ui/NavBar/NavBar.test.tsx
Normal file
50
packages/dashboard/src/components/ui/NavBar/NavBar.test.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
import { render } from '../../../../tests/test-utils';
|
||||
import { NavBar } from './NavBar';
|
||||
|
||||
jest.mock('next/router', () => ({
|
||||
useRouter: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('<NavBar />', () => {
|
||||
beforeEach(() => {
|
||||
(useRouter as jest.Mock).mockImplementation(() => ({
|
||||
pathname: '/',
|
||||
}));
|
||||
});
|
||||
|
||||
it('should render the navbar items', () => {
|
||||
const { getByText } = render(<NavBar isUpdateAvailable />);
|
||||
|
||||
expect(getByText('Dashboard')).toBeInTheDocument();
|
||||
expect(getByText('My Apps')).toBeInTheDocument();
|
||||
expect(getByText('App Store')).toBeInTheDocument();
|
||||
expect(getByText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should highlight the active navbar item', () => {
|
||||
(useRouter as jest.Mock).mockImplementation(() => ({
|
||||
pathname: '/app-store',
|
||||
}));
|
||||
|
||||
const { getByTestId } = render(<NavBar isUpdateAvailable />);
|
||||
const activeItem = getByTestId('nav-item-app-store');
|
||||
const inactiveItem = getByTestId('nav-item-settings');
|
||||
|
||||
expect(activeItem.classList.contains('active')).toBe(true);
|
||||
expect(inactiveItem.classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('should render the update available badge', () => {
|
||||
const { getByText } = render(<NavBar isUpdateAvailable />);
|
||||
|
||||
expect(getByText('Update available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the update available badge', () => {
|
||||
const { queryByText } = render(<NavBar isUpdateAvailable={false} />);
|
||||
|
||||
expect(queryByText('Update available')).toBeNull();
|
||||
});
|
||||
});
|
44
packages/dashboard/src/components/ui/NavBar/NavBar.tsx
Normal file
44
packages/dashboard/src/components/ui/NavBar/NavBar.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { IconApps, IconBrandAppstore, IconHome, IconSettings, TablerIcon } from '@tabler/icons';
|
||||
import clsx from 'clsx';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
|
||||
interface IProps {
|
||||
isUpdateAvailable?: boolean;
|
||||
}
|
||||
|
||||
export const NavBar: React.FC<IProps> = ({ isUpdateAvailable }) => {
|
||||
const router = useRouter();
|
||||
const path = router.pathname.split('/')[1];
|
||||
|
||||
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 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} />
|
||||
</span>
|
||||
<span className="nav-link-title">{title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="navbar-menu" className="collapse navbar-collapse" style={{}}>
|
||||
<div className="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
|
||||
<ul className="navbar-nav">
|
||||
{renderItem('Dashboard', '', IconHome)}
|
||||
{renderItem('My Apps', 'apps', IconApps)}
|
||||
{renderItem('App Store', 'app-store', IconBrandAppstore)}
|
||||
{renderItem('Settings', 'settings', IconSettings)}
|
||||
</ul>
|
||||
{Boolean(isUpdateAvailable) && <span className="ms-2 badge bg-green d-none d-lg-block">Update available</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
1
packages/dashboard/src/components/ui/NavBar/index.ts
Normal file
1
packages/dashboard/src/components/ui/NavBar/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { NavBar } from './NavBar';
|
59
packages/dashboard/src/components/ui/Switch/Switch.test.tsx
Normal file
59
packages/dashboard/src/components/ui/Switch/Switch.test.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
||||
import { Switch } from './Switch';
|
||||
import { fireEvent, render } from '../../../../tests/test-utils';
|
||||
|
||||
describe('Switch', () => {
|
||||
it('renders the label', () => {
|
||||
const label = 'Test Label';
|
||||
const { getByText } = render(<Switch label={label} />);
|
||||
|
||||
expect(getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the className', () => {
|
||||
const className = 'test-class';
|
||||
const { container } = render(<Switch className={className} />);
|
||||
const switchContainer = container.querySelector('.test-class');
|
||||
|
||||
expect(switchContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the checked state', () => {
|
||||
const { container } = render(<Switch checked onChange={jest.fn} />);
|
||||
const checkbox = container.querySelector('input[type="checkbox"]');
|
||||
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it('triggers onChange event when clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
const { container } = render(<Switch onChange={onChange} />);
|
||||
const checkbox = container.querySelector('input[type="checkbox"]') as Element;
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers onBlur event when blurred', () => {
|
||||
const onBlur = jest.fn();
|
||||
const { container } = render(<Switch onBlur={onBlur} />);
|
||||
const checkbox = container.querySelector('input[type="checkbox"]') as Element;
|
||||
|
||||
fireEvent.blur(checkbox);
|
||||
|
||||
expect(onBlur).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should change the checked state when clicked', () => {
|
||||
const { container } = render(<Switch onChange={jest.fn} />);
|
||||
const checkbox = container.querySelector('input[type="checkbox"]') as Element;
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
});
|
19
packages/dashboard/src/components/ui/Switch/Switch.tsx
Normal file
19
packages/dashboard/src/components/ui/Switch/Switch.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
|
||||
interface IProps {
|
||||
label?: string;
|
||||
className?: string;
|
||||
checked?: boolean;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
name?: string;
|
||||
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const Switch = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, checked, className }, ref) => (
|
||||
<div className={className}>
|
||||
<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>
|
||||
));
|
1
packages/dashboard/src/components/ui/Switch/index.ts
Normal file
1
packages/dashboard/src/components/ui/Switch/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Switch } from './Switch';
|
18
packages/dashboard/src/components/ui/Toast/Toast.module.scss
Normal file
18
packages/dashboard/src/components/ui/Toast/Toast.module.scss
Normal file
|
@ -0,0 +1,18 @@
|
|||
@keyframes slideInAndOut {
|
||||
0% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
5% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
95% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.slideIn {
|
||||
animation: slideInAndOut 5s ease-in-out;
|
||||
}
|
34
packages/dashboard/src/components/ui/Toast/Toast.test.tsx
Normal file
34
packages/dashboard/src/components/ui/Toast/Toast.test.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { fireEvent, render } from '../../../../tests/test-utils';
|
||||
import { Toast } from './Toast';
|
||||
|
||||
describe('Toast', () => {
|
||||
it('renders the correct title', () => {
|
||||
const { getByText } = render(<Toast id="toast-1" title="Test Title" onClose={jest.fn} status="info" />);
|
||||
|
||||
expect(getByText('Test Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the correct message', () => {
|
||||
const { getByText } = render(<Toast id="toast-1" title="Test Title" message="Test message" onClose={jest.fn} status="info" />);
|
||||
|
||||
expect(getByText('Test message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the correct status', () => {
|
||||
const { container } = render(<Toast id="toast-1" title="Test Title" status="success" onClose={jest.fn} />);
|
||||
const toastElement = container.querySelector('.tipi-toast');
|
||||
|
||||
expect(toastElement).toHaveClass('alert-success');
|
||||
});
|
||||
|
||||
it('calls the correct function when the close button is clicked', () => {
|
||||
const onCloseMock = jest.fn();
|
||||
const { getByLabelText } = render(<Toast id="toast-1" title="Test Title" onClose={onCloseMock} status="info" />);
|
||||
const closeButton = getByLabelText('close');
|
||||
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
51
packages/dashboard/src/components/ui/Toast/Toast.tsx
Normal file
51
packages/dashboard/src/components/ui/Toast/Toast.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import styles from './Toast.module.scss';
|
||||
|
||||
interface IProps {
|
||||
onClose: () => void;
|
||||
status: 'success' | 'error' | 'warning' | 'info';
|
||||
title: string;
|
||||
message?: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const Toast: React.FC<IProps> = ({ status, onClose, title, message, id }) => (
|
||||
<div
|
||||
id={id}
|
||||
className={clsx(styles.slideIn, 'show fade alert alert-important alert-dismissible tipi-toast', {
|
||||
'alert-danger': status === 'error',
|
||||
'alert-success': status === 'success',
|
||||
'alert-info': status === 'info',
|
||||
warning: status === 'warning',
|
||||
})}
|
||||
role="alert"
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon alert-icon"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-fill">
|
||||
<h4 className="alert-title text-white font-weight-bold">{title}</h4>
|
||||
{message && <div className="text-white">{message}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} data-testid="toast-close-button" className="btn-close btn-close-white" data-bs-dismiss="alert" aria-label="close" />
|
||||
</div>
|
||||
);
|
1
packages/dashboard/src/components/ui/Toast/index.ts
Normal file
1
packages/dashboard/src/components/ui/Toast/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Toast } from './Toast';
|
|
@ -1,7 +1,7 @@
|
|||
import { ApolloClient, from, InMemoryCache } from '@apollo/client';
|
||||
import links from './links';
|
||||
|
||||
export const createApolloClient = async (): Promise<ApolloClient<any>> => {
|
||||
export const createApolloClient = (): ApolloClient<unknown> => {
|
||||
const additiveLink = from([links.errorLink, links.authLink, links.httpLink]);
|
||||
|
||||
return new ApolloClient({
|
||||
|
|
|
@ -14,4 +14,5 @@ export const APP_CATEGORIES = [
|
|||
{ name: 'Data', id: AppCategoriesEnum.Data, icon: 'FaDatabase' },
|
||||
{ name: 'Music', id: AppCategoriesEnum.Music, icon: 'FaMusic' },
|
||||
{ name: 'Finance', id: AppCategoriesEnum.Finance, icon: 'FaMoneyBillAlt' },
|
||||
{ name: 'Gaming', id: AppCategoriesEnum.Gaming, icon: 'FaGamepad' },
|
||||
];
|
||||
|
|
|
@ -1,5 +1 @@
|
|||
export const getUrl = (url: string) => {
|
||||
let prefix = 'dashboard';
|
||||
|
||||
return `/${prefix}/${url}`;
|
||||
};
|
||||
export const getUrl = (url: string) => `/${url}`;
|
||||
|
|
|
@ -1,9 +1,3 @@
|
|||
export enum RequestStatus {
|
||||
SUCCESS = 'SUCCESS',
|
||||
ERROR = 'ERROR',
|
||||
LOADING = 'LOADING',
|
||||
}
|
||||
|
||||
export interface IUser {
|
||||
name: string;
|
||||
email: string;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { gql } from '@apollo/client';
|
||||
import * as Apollo from '@apollo/client';
|
||||
|
||||
export type Maybe<T> = T | null;
|
||||
export type InputMaybe<T> = Maybe<T>;
|
||||
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
|
||||
|
@ -154,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;
|
||||
};
|
||||
|
@ -206,7 +199,6 @@ export type Query = {
|
|||
version: VersionResponse;
|
||||
};
|
||||
|
||||
|
||||
export type QueryGetAppArgs = {
|
||||
id: Scalars['String'];
|
||||
};
|
||||
|
@ -253,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>;
|
||||
|
||||
/**
|
||||
|
@ -399,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>;
|
||||
|
||||
/**
|
||||
|
@ -432,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>;
|
||||
|
||||
/**
|
||||
|
@ -462,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>;
|
||||
|
||||
/**
|
||||
|
@ -495,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>;
|
||||
|
||||
/**
|
||||
|
@ -525,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>;
|
||||
|
||||
/**
|
||||
|
@ -560,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>;
|
||||
|
||||
/**
|
||||
|
@ -595,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>;
|
||||
|
||||
/**
|
||||
|
@ -630,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>;
|
||||
|
||||
/**
|
||||
|
@ -660,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>;
|
||||
|
||||
/**
|
||||
|
@ -695,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>;
|
||||
|
||||
/**
|
||||
|
@ -730,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__
|
||||
|
@ -802,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__
|
||||
|
@ -852,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__
|
||||
|
@ -884,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__
|
||||
|
@ -930,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__
|
||||
|
@ -964,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__
|
||||
|
@ -998,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__
|
||||
|
@ -1044,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__
|
||||
|
@ -1077,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>;
|
||||
|
|
|
@ -13,7 +13,7 @@ export default function useCachedResources(): IReturnProps {
|
|||
|
||||
async function loadResourcesAndDataAsync() {
|
||||
try {
|
||||
const restoredClient = await createApolloClient();
|
||||
const restoredClient = createApolloClient();
|
||||
|
||||
setClient(restoredClient);
|
||||
} catch (error) {
|
||||
|
|
17
packages/dashboard/src/hooks/useDisclosure.ts
Normal file
17
packages/dashboard/src/hooks/useDisclosure.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
|
||||
export const useDisclosure = (isOpenDefault = false) => {
|
||||
const [isOpen, setIsOpen] = useState(isOpenDefault);
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), []);
|
||||
const close = useCallback(() => setIsOpen(false), []);
|
||||
const toggle = useCallback((toSet: boolean) => {
|
||||
if (typeof toSet === 'undefined') {
|
||||
setIsOpen((state) => !state);
|
||||
} else {
|
||||
setIsOpen(toSet);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isOpen, open, close, toggle };
|
||||
};
|
4
packages/dashboard/src/mocks/browser.ts
Normal file
4
packages/dashboard/src/mocks/browser.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { setupWorker } from 'msw';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
export const worker = setupWorker(...handlers);
|
57
packages/dashboard/src/mocks/fixtures/app.fixtures.ts
Normal file
57
packages/dashboard/src/mocks/fixtures/app.fixtures.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { faker } from '@faker-js/faker';
|
||||
import { App, AppCategoriesEnum, AppInfo, AppStatusEnum } from '../../generated/graphql';
|
||||
|
||||
const randomCategory = (): AppCategoriesEnum[] => {
|
||||
const categories = Object.values(AppCategoriesEnum);
|
||||
const randomIndex = faker.datatype.number({ min: 0, max: categories.length - 1 });
|
||||
return [categories[randomIndex]];
|
||||
};
|
||||
|
||||
export const createApp = (overrides?: Partial<AppInfo>): AppInfo => {
|
||||
const name = faker.random.word();
|
||||
return {
|
||||
id: name.toLowerCase(),
|
||||
name,
|
||||
description: faker.random.words(),
|
||||
author: faker.random.word(),
|
||||
available: true,
|
||||
categories: randomCategory(),
|
||||
form_fields: [],
|
||||
port: faker.datatype.number({ min: 1000, max: 9999 }),
|
||||
short_desc: faker.random.words(),
|
||||
tipi_version: 1,
|
||||
version: faker.system.semver(),
|
||||
source: faker.internet.url(),
|
||||
https: false,
|
||||
no_gui: false,
|
||||
exposable: true,
|
||||
url_suffix: '',
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
type CreateAppEntityParams = {
|
||||
overrides?: Omit<Partial<App>, 'info'>;
|
||||
overridesInfo?: Partial<AppInfo>;
|
||||
status?: AppStatusEnum;
|
||||
};
|
||||
|
||||
export const createAppEntity = (params: CreateAppEntityParams) => {
|
||||
const { overrides, overridesInfo, status = AppStatusEnum.Running } = params;
|
||||
|
||||
const id = faker.random.word().toLowerCase();
|
||||
const app = createApp({ id, ...overridesInfo });
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
info: app,
|
||||
config: {},
|
||||
exposed: false,
|
||||
updateInfo: null,
|
||||
domain: null,
|
||||
version: 1,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
export const createAppsRandomly = (count: number): AppInfo[] => Array.from({ length: count }).map(() => createApp());
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue