refactor: replace grapqhl queries with trpc in the frontend
This commit is contained in:
parent
ce6662bef5
commit
3cc3c9011e
31 changed files with 619 additions and 341 deletions
|
@ -78,13 +78,13 @@ services:
|
|||
labels:
|
||||
traefik.enable: true
|
||||
# Web
|
||||
traefik.http.routers.api.rule: PathPrefix(`/api`)
|
||||
traefik.http.routers.api.rule: PathPrefix(`/api-legacy`)
|
||||
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.rule: (Host(`${DOMAIN}`) && PathPrefix(`/api-legacy`))
|
||||
traefik.http.routers.api-secure.entrypoints: websecure
|
||||
traefik.http.routers.api-secure.service: api-secure
|
||||
traefik.http.routers.api-secure.tls.certresolver: myresolver
|
||||
|
@ -104,6 +104,18 @@ services:
|
|||
condition: service_started
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
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
|
||||
APPS_REPO_ID: ${APPS_REPO_ID}
|
||||
APPS_REPO_URL: ${APPS_REPO_URL}
|
||||
DOMAIN: ${DOMAIN}
|
||||
ARCHITECTURE: ${ARCHITECTURE}
|
||||
labels:
|
||||
traefik.enable: true
|
||||
# Web
|
||||
|
|
|
@ -80,13 +80,13 @@ services:
|
|||
labels:
|
||||
traefik.enable: true
|
||||
# Web
|
||||
traefik.http.routers.api.rule: PathPrefix(`/api`)
|
||||
traefik.http.routers.api.rule: PathPrefix(`/api-legacy`)
|
||||
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.rule: (Host(`${DOMAIN}`) && PathPrefix(`/api-legacy`))
|
||||
traefik.http.routers.api-secure.entrypoints: websecure
|
||||
traefik.http.routers.api-secure.service: api-secure
|
||||
traefik.http.routers.api-secure.tls.certresolver: myresolver
|
||||
|
@ -109,35 +109,32 @@ services:
|
|||
condition: service_started
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
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
|
||||
APPS_REPO_ID: ${APPS_REPO_ID}
|
||||
APPS_REPO_URL: ${APPS_REPO_URL}
|
||||
DOMAIN: ${DOMAIN}
|
||||
ARCHITECTURE: ${ARCHITECTURE}
|
||||
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:
|
||||
|
|
|
@ -78,13 +78,13 @@ services:
|
|||
labels:
|
||||
traefik.enable: true
|
||||
# Web
|
||||
traefik.http.routers.api.rule: PathPrefix(`/api`)
|
||||
traefik.http.routers.api.rule: PathPrefix(`/api-legacy`)
|
||||
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.rule: (Host(`${DOMAIN}`) && PathPrefix(`/api-legacy`))
|
||||
traefik.http.routers.api-secure.entrypoints: websecure
|
||||
traefik.http.routers.api-secure.service: api-secure
|
||||
traefik.http.routers.api-secure.tls.certresolver: myresolver
|
||||
|
@ -105,6 +105,18 @@ services:
|
|||
condition: service_started
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
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
|
||||
APPS_REPO_ID: ${APPS_REPO_ID}
|
||||
APPS_REPO_URL: ${APPS_REPO_URL}
|
||||
DOMAIN: ${DOMAIN}
|
||||
ARCHITECTURE: ${ARCHITECTURE}
|
||||
labels:
|
||||
traefik.enable: true
|
||||
# Web
|
||||
|
|
|
@ -4,8 +4,9 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"test": "jest --colors",
|
||||
"test:client": "jest --colors --config=jest.config.client.js",
|
||||
"test:server": "jest --colors --config=jest.config.server.js",
|
||||
"dev": "next dev",
|
||||
"dev:msw": "NEXT_PUBLIC_API_MOCKING=enabled next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
|
|
|
@ -4,9 +4,10 @@ import React, { useEffect } from 'react';
|
|||
import clsx from 'clsx';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
import semver from 'semver';
|
||||
import { useRefreshTokenQuery, useVersionQuery } from '../../generated/graphql';
|
||||
import { useRefreshTokenQuery } from '../../generated/graphql';
|
||||
import { Header } from '../ui/Header';
|
||||
import styles from './Layout.module.scss';
|
||||
import { useSystemStore } from '../../state/systemStore';
|
||||
|
||||
interface IProps {
|
||||
loading?: boolean;
|
||||
|
@ -18,9 +19,9 @@ interface IProps {
|
|||
|
||||
export const Layout: React.FC<IProps> = ({ children, breadcrumbs, title, actions }) => {
|
||||
const { data } = useRefreshTokenQuery({ fetchPolicy: 'network-only' });
|
||||
const { data: dataVersion } = useVersionQuery({ nextFetchPolicy: 'network-only' });
|
||||
const { version } = useSystemStore();
|
||||
const defaultVersion = '0.0.0';
|
||||
const isLatest = semver.gte(dataVersion?.version.current || defaultVersion, dataVersion?.version.latest || defaultVersion);
|
||||
const isLatest = semver.gte(version?.current || defaultVersion, version?.latest || defaultVersion);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.refreshToken?.token) {
|
||||
|
|
96
packages/dashboard/src/client/components/Layout/LayoutV2.tsx
Normal file
96
packages/dashboard/src/client/components/Layout/LayoutV2.tsx
Normal file
|
@ -0,0 +1,96 @@
|
|||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import React, { useEffect } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
import semver from 'semver';
|
||||
import { useRefreshTokenQuery } from '../../generated/graphql';
|
||||
import { Header } from '../ui/Header';
|
||||
import styles from './Layout.module.scss';
|
||||
import { ErrorPage } from '../ui/ErrorPage';
|
||||
import { trpc } from '../../utils/trpc';
|
||||
|
||||
interface IProps {
|
||||
loading?: boolean;
|
||||
loadingComponent?: React.ReactNode;
|
||||
error?: string;
|
||||
breadcrumbs?: { name: string; href: string; current?: boolean }[];
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
actions?: React.ReactNode;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export const Layout: React.FC<IProps> = ({ children, breadcrumbs, title, actions, loading, error, loadingComponent, data }) => {
|
||||
const { data: dataRefreshToken } = useRefreshTokenQuery({ fetchPolicy: 'network-only' });
|
||||
const { data: dataVersion } = trpc.system.getVersion.useQuery(undefined, { networkMode: 'online' });
|
||||
|
||||
const defaultVersion = '0.0.0';
|
||||
const isLatest = semver.gte(dataVersion?.current || defaultVersion, dataVersion?.latest || defaultVersion);
|
||||
|
||||
useEffect(() => {
|
||||
if (dataRefreshToken?.refreshToken?.token) {
|
||||
localStorage.setItem('token', dataRefreshToken.refreshToken.token);
|
||||
}
|
||||
}, [dataRefreshToken?.refreshToken?.token]);
|
||||
|
||||
const renderBreadcrumbs = () => {
|
||||
if (!breadcrumbs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return loadingComponent;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorPage error={error} />;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid={`${title?.toLowerCase().split(' ').join('-')}-layout`} className="page">
|
||||
<Head>
|
||||
<title>{title} - Tipi</title>
|
||||
</Head>
|
||||
<ReactTooltip offset={{ right: 1 }} 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">{renderContent()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -12,7 +12,7 @@ interface IProps {
|
|||
}
|
||||
|
||||
export const StatusScreen: React.FC<IProps> = ({ title, subtitle, onAction, actionTitle, loading = true }) => (
|
||||
<div className="page page-center">
|
||||
<div data-testid="status-screen" className="page page-center">
|
||||
<div className="container container-tight py-4 d-flex align-items-center flex-column">
|
||||
<Image
|
||||
alt="Tipi log"
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { rest } from 'msw';
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '../../../../../tests/test-utils';
|
||||
import { server } from '../../../mocks/server';
|
||||
import { act, render, renderHook, screen, waitFor } from '../../../../../tests/test-utils';
|
||||
import { useSystemStore } from '../../../state/systemStore';
|
||||
import { StatusProvider } from './StatusProvider';
|
||||
|
||||
const reloadFn = jest.fn();
|
||||
|
@ -29,7 +28,10 @@ describe('Test: StatusProvider', () => {
|
|||
});
|
||||
|
||||
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' }))));
|
||||
const { result } = renderHook(() => useSystemStore());
|
||||
act(() => {
|
||||
result.current.setStatus('RESTARTING');
|
||||
});
|
||||
render(
|
||||
<StatusProvider>
|
||||
<div>system running</div>
|
||||
|
@ -42,7 +44,11 @@ describe('Test: StatusProvider', () => {
|
|||
});
|
||||
|
||||
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' }))));
|
||||
const { result } = renderHook(() => useSystemStore());
|
||||
act(() => {
|
||||
result.current.setStatus('UPDATING');
|
||||
});
|
||||
|
||||
render(
|
||||
<StatusProvider>
|
||||
<div>system running</div>
|
||||
|
@ -55,7 +61,11 @@ describe('Test: StatusProvider', () => {
|
|||
});
|
||||
|
||||
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' }))));
|
||||
const { result } = renderHook(() => useSystemStore());
|
||||
act(() => {
|
||||
result.current.setStatus('UPDATING');
|
||||
});
|
||||
|
||||
render(
|
||||
<StatusProvider>
|
||||
<div>system running</div>
|
||||
|
@ -66,7 +76,9 @@ describe('Test: StatusProvider', () => {
|
|||
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' }))));
|
||||
act(() => {
|
||||
result.current.setStatus('RUNNING');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(reloadFn).toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
@ -1,45 +1,41 @@
|
|||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import React, { ReactElement, useEffect, useRef } from 'react';
|
||||
import router from 'next/router';
|
||||
import { SystemStatus } from '../../../state/systemStore';
|
||||
import { SystemStatus, useSystemStore } 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 });
|
||||
const { status } = useSystemStore();
|
||||
const s = useRef(status);
|
||||
|
||||
useEffect(() => {
|
||||
// If previous was not running and current is running, we need to refresh the page
|
||||
if (data?.status === SystemStatus.RUNNING && s !== SystemStatus.RUNNING) {
|
||||
if (status === SystemStatus.RUNNING && s.current !== SystemStatus.RUNNING) {
|
||||
router.reload();
|
||||
}
|
||||
if (status === SystemStatus.RUNNING) {
|
||||
s.current = SystemStatus.RUNNING;
|
||||
}
|
||||
if (status === SystemStatus.RESTARTING) {
|
||||
s.current = SystemStatus.RESTARTING;
|
||||
}
|
||||
if (status === SystemStatus.UPDATING) {
|
||||
s.current = SystemStatus.UPDATING;
|
||||
}
|
||||
}, [status, s]);
|
||||
|
||||
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) {
|
||||
if (s.current === SystemStatus.LOADING) {
|
||||
return <StatusScreen title="" subtitle="" />;
|
||||
}
|
||||
|
||||
if (s === SystemStatus.RESTARTING) {
|
||||
if (s.current === SystemStatus.RESTARTING) {
|
||||
return <StatusScreen title="Your system is restarting..." subtitle="Please do not refresh this page" />;
|
||||
}
|
||||
|
||||
if (s === SystemStatus.UPDATING) {
|
||||
if (s.current === SystemStatus.UPDATING) {
|
||||
return <StatusScreen title="Your system is updating..." subtitle="Please do not refresh this page" />;
|
||||
}
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { App, AppCategoriesEnum, AppInfo, AppStatusEnum } from '../../generated/
|
|||
const randomCategory = (): AppCategoriesEnum[] => {
|
||||
const categories = Object.values(AppCategoriesEnum);
|
||||
const randomIndex = faker.datatype.number({ min: 0, max: categories.length - 1 });
|
||||
return [categories[randomIndex]];
|
||||
return [categories[randomIndex] as AppCategoriesEnum];
|
||||
};
|
||||
|
||||
export const createApp = (overrides?: Partial<AppInfo>): AppInfo => {
|
||||
|
|
110
packages/dashboard/src/client/mocks/getTrpcMock.ts
Normal file
110
packages/dashboard/src/client/mocks/getTrpcMock.ts
Normal file
|
@ -0,0 +1,110 @@
|
|||
import { rest } from 'msw';
|
||||
import SuperJSON from 'superjson';
|
||||
import type { RouterInput, RouterOutput } from '../../server/routers/_app';
|
||||
|
||||
export type RpcResponse<Data> = RpcSuccessResponse<Data> | RpcErrorResponse;
|
||||
|
||||
export type RpcSuccessResponse<Data> = {
|
||||
id: null;
|
||||
result: { type: 'data'; data: Data };
|
||||
};
|
||||
|
||||
export type RpcErrorResponse = {
|
||||
id: null;
|
||||
error: {
|
||||
json: {
|
||||
message: string;
|
||||
code: number;
|
||||
data: {
|
||||
code: string;
|
||||
httpStatus: number;
|
||||
stack: string;
|
||||
path: string; // TQuery
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const jsonRpcSuccessResponse = (data: unknown): RpcSuccessResponse<any> => {
|
||||
const response = SuperJSON.serialize(data);
|
||||
|
||||
return {
|
||||
id: null,
|
||||
result: { type: 'data', data: response },
|
||||
};
|
||||
};
|
||||
|
||||
const jsonRpcErrorResponse = (path: string, status: number, message: string): RpcErrorResponse => ({
|
||||
id: null,
|
||||
error: {
|
||||
json: {
|
||||
message,
|
||||
code: -32600,
|
||||
data: {
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
httpStatus: status,
|
||||
stack: 'Error: Internal Server Error',
|
||||
path,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Mocks a TRPC endpoint and returns a msw handler for Storybook.
|
||||
* Only supports routes with two levels.
|
||||
* The path and response is fully typed and infers the type from your routes file.
|
||||
* @todo make it accept multiple endpoints
|
||||
* @param endpoint.path - path to the endpoint ex. ["post", "create"]
|
||||
* @param endpoint.response - response to return ex. {id: 1}
|
||||
* @param endpoint.type - specific type of the endpoint ex. "query" or "mutation" (defaults to "query")
|
||||
* @returns - msw endpoint
|
||||
* @example
|
||||
* Page.parameters = {
|
||||
msw: {
|
||||
handlers: [
|
||||
getTRPCMock({
|
||||
path: ["post", "getMany"],
|
||||
type: "query",
|
||||
response: [
|
||||
{ id: 0, title: "test" },
|
||||
{ id: 1, title: "test" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
*/
|
||||
export const getTRPCMock = <
|
||||
K1 extends keyof RouterInput,
|
||||
K2 extends keyof RouterInput[K1], // object itself
|
||||
O extends RouterOutput[K1][K2], // all its keys
|
||||
>(endpoint: {
|
||||
path: [K1, K2];
|
||||
response: O;
|
||||
type?: 'query' | 'mutation';
|
||||
delay?: number;
|
||||
}) => {
|
||||
const fn = endpoint.type === 'mutation' ? rest.post : rest.get;
|
||||
|
||||
const route = `http://localhost:3000/api/trpc/${endpoint.path[0]}.${endpoint.path[1] as string}`;
|
||||
|
||||
return fn(route, (req, res, ctx) => res(ctx.delay(endpoint.delay), ctx.json(jsonRpcSuccessResponse(endpoint.response))));
|
||||
};
|
||||
|
||||
export const getTRPCMockError = <
|
||||
K1 extends keyof RouterInput,
|
||||
K2 extends keyof RouterInput[K1], // object itself
|
||||
>(endpoint: {
|
||||
path: [K1, K2];
|
||||
type?: 'query' | 'mutation';
|
||||
status?: number;
|
||||
message?: string;
|
||||
}) => {
|
||||
const fn = endpoint.type === 'mutation' ? rest.post : rest.get;
|
||||
|
||||
const route = `http://localhost:3000/api/trpc/${endpoint.path[0]}.${endpoint.path[1] as string}`;
|
||||
|
||||
return fn(route, (req, res, ctx) =>
|
||||
res(ctx.delay(), ctx.json(jsonRpcErrorResponse(`${endpoint.path[0]}.${endpoint.path[1] as string}`, endpoint.status ?? 500, endpoint.message ?? 'Internal Server Error'))),
|
||||
);
|
||||
};
|
|
@ -1,29 +1,8 @@
|
|||
import { graphql, rest } from 'msw';
|
||||
import {
|
||||
ConfiguredQuery,
|
||||
LoginMutation,
|
||||
LogoutMutationResult,
|
||||
MeQuery,
|
||||
RefreshTokenQuery,
|
||||
RegisterMutation,
|
||||
RegisterMutationVariables,
|
||||
UsernamePasswordInput,
|
||||
VersionQuery,
|
||||
SystemInfoQuery,
|
||||
} from '../generated/graphql';
|
||||
import { graphql } from 'msw';
|
||||
import { ConfiguredQuery, LoginMutation, LogoutMutationResult, MeQuery, RefreshTokenQuery, RegisterMutation, RegisterMutationVariables, UsernamePasswordInput } from '../generated/graphql';
|
||||
import { getTRPCMock } from './getTrpcMock';
|
||||
import appHandlers from './handlers/appHandlers';
|
||||
|
||||
const restHandlers = [
|
||||
rest.get('/api/status', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.delay(200),
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'RUNNING',
|
||||
}),
|
||||
),
|
||||
),
|
||||
];
|
||||
const graphqlHandlers = [
|
||||
// Handles a "Login" mutation
|
||||
graphql.mutation('Login', (req, res, ctx) => {
|
||||
|
@ -36,9 +15,8 @@ const graphqlHandlers = [
|
|||
|
||||
return res(ctx.delay(), ctx.data(result));
|
||||
}),
|
||||
|
||||
// Handles a "Logout" mutation
|
||||
graphql.mutation('Logout', (req, res, ctx) => {
|
||||
graphql.mutation('Logout', (_req, res, ctx) => {
|
||||
sessionStorage.removeItem('is-authenticated');
|
||||
|
||||
const result: LogoutMutationResult['data'] = {
|
||||
|
@ -47,9 +25,8 @@ const graphqlHandlers = [
|
|||
|
||||
return res(ctx.delay(), ctx.data(result));
|
||||
}),
|
||||
|
||||
// Handles me query
|
||||
graphql.query('Me', (req, res, ctx) => {
|
||||
graphql.query('Me', (_req, res, ctx) => {
|
||||
const isAuthenticated = sessionStorage.getItem('is-authenticated');
|
||||
if (!isAuthenticated) {
|
||||
return res(ctx.errors([{ message: 'Not authenticated' }]));
|
||||
|
@ -57,18 +34,15 @@ const graphqlHandlers = [
|
|||
const result: MeQuery = {
|
||||
me: { id: '1' },
|
||||
};
|
||||
|
||||
return res(ctx.delay(), ctx.data(result));
|
||||
}),
|
||||
|
||||
graphql.query('RefreshToken', (req, res, ctx) => {
|
||||
graphql.query('RefreshToken', (_req, res, ctx) => {
|
||||
const result: RefreshTokenQuery = {
|
||||
refreshToken: { token: 'token' },
|
||||
};
|
||||
|
||||
return res(ctx.delay(), ctx.data(result));
|
||||
}),
|
||||
|
||||
graphql.mutation('Register', (req, res, ctx) => {
|
||||
const {
|
||||
input: { username },
|
||||
|
@ -88,46 +62,37 @@ const graphqlHandlers = [
|
|||
appHandlers.getApp,
|
||||
appHandlers.installedApps,
|
||||
appHandlers.installApp,
|
||||
graphql.query('Version', (req, res, ctx) => {
|
||||
const result: VersionQuery = {
|
||||
version: {
|
||||
current: '1.0.0',
|
||||
latest: '1.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
return res(ctx.data(result));
|
||||
}),
|
||||
|
||||
graphql.query('Configured', (req, res, ctx) => {
|
||||
graphql.query('Configured', (_req, res, ctx) => {
|
||||
const result: ConfiguredQuery = {
|
||||
isConfigured: true,
|
||||
};
|
||||
|
||||
return res(ctx.data(result));
|
||||
}),
|
||||
|
||||
graphql.query('SystemInfo', (req, res, ctx) => {
|
||||
const result: SystemInfoQuery = {
|
||||
systemInfo: {
|
||||
cpu: {
|
||||
load: 50,
|
||||
},
|
||||
disk: {
|
||||
available: 1000000000,
|
||||
total: 2000000000,
|
||||
used: 1000000000,
|
||||
},
|
||||
memory: {
|
||||
available: 1000000000,
|
||||
total: 2000000000,
|
||||
used: 1000000000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return res(ctx.data(result));
|
||||
}),
|
||||
];
|
||||
|
||||
export const handlers = [...graphqlHandlers, ...restHandlers];
|
||||
export const handlers = [
|
||||
getTRPCMock({
|
||||
path: ['system', 'getVersion'],
|
||||
type: 'query',
|
||||
response: { current: '1.0.0', latest: '1.0.0' },
|
||||
}),
|
||||
getTRPCMock({
|
||||
path: ['system', 'update'],
|
||||
type: 'mutation',
|
||||
response: true,
|
||||
delay: 100,
|
||||
}),
|
||||
getTRPCMock({
|
||||
path: ['system', 'restart'],
|
||||
type: 'mutation',
|
||||
response: true,
|
||||
delay: 100,
|
||||
}),
|
||||
getTRPCMock({
|
||||
path: ['system', 'systemInfo'],
|
||||
type: 'query',
|
||||
response: { cpu: { load: 0.1 }, disk: { available: 1, total: 2, used: 1 }, memory: { available: 1, total: 2, used: 1 } },
|
||||
}),
|
||||
...graphqlHandlers,
|
||||
];
|
||||
|
|
|
@ -22,8 +22,8 @@ describe('InstallModal', () => {
|
|||
it('renders the InstallForm with the correct props', () => {
|
||||
render(<InstallModal app={app} isOpen onClose={jest.fn()} onSubmit={jest.fn()} />);
|
||||
|
||||
expect(screen.getByLabelText(app.form_fields[0].label)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(app.form_fields[1].label)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(app.form_fields[0]?.label || '')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(app.form_fields[1]?.label || '')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when the close button is clicked', () => {
|
||||
|
@ -38,8 +38,8 @@ describe('InstallModal', () => {
|
|||
const onSubmit = jest.fn();
|
||||
render(<InstallModal app={app} isOpen onClose={jest.fn()} onSubmit={onSubmit} />);
|
||||
|
||||
const hostnameInput = screen.getByLabelText(app.form_fields[0].label);
|
||||
const passwordInput = screen.getByLabelText(app.form_fields[1].label);
|
||||
const hostnameInput = screen.getByLabelText(app.form_fields[0]?.label || '');
|
||||
const passwordInput = screen.getByLabelText(app.form_fields[1]?.label || '');
|
||||
|
||||
fireEvent.change(hostnameInput, { target: { value: 'test-hostname' } });
|
||||
expect(hostnameInput).toHaveValue('test-hostname');
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import { render, screen, waitFor } from '../../../../../../tests/test-utils';
|
||||
import { AppInfo } from '../../../../generated/graphql';
|
||||
import appHandlers, { mockedApps, mockInstalledAppIds } from '../../../../mocks/handlers/appHandlers';
|
||||
import { server } from '../../../../mocks/server';
|
||||
import { AppDetailsPage } from './AppDetailsPage';
|
||||
|
@ -7,7 +8,7 @@ import { AppDetailsPage } from './AppDetailsPage';
|
|||
describe('AppDetailsPage', () => {
|
||||
it('should render', async () => {
|
||||
// Arrange
|
||||
render(<AppDetailsPage appId={mockInstalledAppIds[0]} />);
|
||||
render(<AppDetailsPage appId={mockInstalledAppIds[0] as string} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('app-details')).toBeInTheDocument();
|
||||
});
|
||||
|
@ -32,7 +33,7 @@ describe('AppDetailsPage', () => {
|
|||
it('should render the error page when an error occurs', async () => {
|
||||
// Arrange
|
||||
server.use(appHandlers.getAppError);
|
||||
render(<AppDetailsPage appId={mockInstalledAppIds[0]} />);
|
||||
render(<AppDetailsPage appId={mockInstalledAppIds[0] as string} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-page')).toBeInTheDocument();
|
||||
});
|
||||
|
@ -43,7 +44,7 @@ describe('AppDetailsPage', () => {
|
|||
|
||||
it('should set the breadcrumb prop of the Layout component to an array containing two elements with the correct name and href properties', async () => {
|
||||
// Arrange
|
||||
const app = mockedApps[0];
|
||||
const app = mockedApps[0] as AppInfo;
|
||||
render(<AppDetailsPage appId={app.id} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('app-details')).toBeInTheDocument();
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { faker } from '@faker-js/faker';
|
||||
import React from 'react';
|
||||
import { render } from '../../../../../tests/test-utils';
|
||||
import { SystemInfoResponse } from '../../../generated/graphql';
|
||||
import Dashboard from './Dashboard';
|
||||
import { SystemRouterOutput } from '../../../../server/routers/system/system.router';
|
||||
import { DashboardContainer } from './DashboardContainer';
|
||||
|
||||
describe('Test: Dashboard', () => {
|
||||
it('should render', () => {
|
||||
const data: SystemInfoResponse = {
|
||||
const data: SystemRouterOutput['systemInfo'] = {
|
||||
disk: {
|
||||
available: faker.datatype.number(),
|
||||
total: faker.datatype.number(),
|
||||
|
@ -22,6 +22,6 @@ describe('Test: Dashboard', () => {
|
|||
},
|
||||
};
|
||||
|
||||
render(<Dashboard data={data} />);
|
||||
render(<DashboardContainer data={data} loading={false} />);
|
||||
});
|
||||
});
|
|
@ -1,23 +1,22 @@
|
|||
import { IconCircuitResistor, IconCpu, IconDatabase } from '@tabler/icons';
|
||||
import React from 'react';
|
||||
import { SystemInfoResponse } from '../../../generated/graphql';
|
||||
import { Layout } from '../../../components/Layout/LayoutV2';
|
||||
import { SystemRouterOutput } from '../../../../server/routers/system/system.router';
|
||||
import SystemStat from '../components/SystemStat';
|
||||
import { ContainerProps } from '../../../types/layout-helpers';
|
||||
|
||||
interface IProps {
|
||||
data: SystemInfoResponse;
|
||||
}
|
||||
type IProps = { data?: SystemRouterOutput['systemInfo'] };
|
||||
|
||||
const Dashboard: React.FC<IProps> = ({ data }) => {
|
||||
const DashboardWithData: React.FC<Required<IProps>> = ({ data }) => {
|
||||
const { disk, memory, cpu } = data;
|
||||
|
||||
// Convert bytes to GB
|
||||
const diskFree = Math.round(disk.available / 1024 / 1024 / 1024);
|
||||
const diskSize = Math.round(disk.total / 1024 / 1024 / 1024);
|
||||
const diskUsed = diskSize - diskFree;
|
||||
const percentUsed = Math.round((diskUsed / diskSize) * 100);
|
||||
|
||||
const memoryTotal = Math.round(Number(memory?.total) / 1024 / 1024 / 1024);
|
||||
const memoryFree = Math.round(Number(memory?.available) / 1024 / 1024 / 1024);
|
||||
const memoryTotal = Math.round(Number(memory.total) / 1024 / 1024 / 1024);
|
||||
const memoryFree = Math.round(Number(memory.available) / 1024 / 1024 / 1024);
|
||||
const percentUsedMemory = Math.round(((memoryTotal - memoryFree) / memoryTotal) * 100);
|
||||
|
||||
return (
|
||||
|
@ -29,4 +28,8 @@ const Dashboard: React.FC<IProps> = ({ data }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
export const DashboardContainer: React.FC<ContainerProps<IProps>> = ({ data, loading, error }) => (
|
||||
<Layout data={data} loading={loading} error={error} title="Dashboard">
|
||||
<DashboardWithData data={data!} />
|
||||
</Layout>
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
export { DashboardContainer } from './DashboardContainer'
|
|
@ -1,14 +1,10 @@
|
|||
import React from 'react';
|
||||
import type { NextPage } from 'next';
|
||||
import { Layout } from '../../../../components/Layout';
|
||||
import Dashboard from '../../containers/Dashboard';
|
||||
import { useSystemInfoQuery } from '../../../../generated/graphql';
|
||||
import { DashboardContainer } from '../../containers/DashboardContainer';
|
||||
import { trpc } from '../../../../utils/trpc';
|
||||
|
||||
export const DashboardPage: NextPage = () => {
|
||||
const { data, loading } = useSystemInfoQuery({ pollInterval: 10000 });
|
||||
return (
|
||||
<Layout title="Dashboard" loading={loading && !data}>
|
||||
{data?.systemInfo && <Dashboard data={data.systemInfo} />}
|
||||
</Layout>
|
||||
);
|
||||
const { data, isLoading, error } = trpc.system.systemInfo.useQuery();
|
||||
|
||||
return <DashboardContainer data={data} loading={isLoading} error={error?.message} />;
|
||||
};
|
||||
|
|
|
@ -1,122 +1,135 @@
|
|||
import { faker } from '@faker-js/faker';
|
||||
import { graphql } from 'msw';
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor } from '../../../../../../tests/test-utils';
|
||||
import { render, screen, waitFor, act, fireEvent, renderHook } from '../../../../../../tests/test-utils';
|
||||
import { getTRPCMockError } from '../../../../mocks/getTrpcMock';
|
||||
import { server } from '../../../../mocks/server';
|
||||
import { useToastStore } from '../../../../state/toastStore';
|
||||
import { SettingsContainer } from './SettingsContainer';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem('token');
|
||||
});
|
||||
|
||||
describe('Test: SettingsContainer', () => {
|
||||
it('renders without crashing', () => {
|
||||
const currentVersion = faker.system.semver();
|
||||
render(<SettingsContainer currentVersion={currentVersion} latestVersion={currentVersion} />);
|
||||
describe('UI', () => {
|
||||
it('renders without crashing', () => {
|
||||
const current = faker.system.semver();
|
||||
render(<SettingsContainer data={{ current }} />);
|
||||
|
||||
expect(screen.getByText('Tipi settings')).toBeInTheDocument();
|
||||
expect(screen.getByText('Already up to date')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tipi settings')).toBeInTheDocument();
|
||||
expect(screen.getByText('Already up to date')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should make update button disable if current version is equal to latest version', () => {
|
||||
const current = faker.system.semver();
|
||||
render(<SettingsContainer data={{ current, latest: current }} />);
|
||||
|
||||
expect(screen.getByText('Already up to date')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should make update button disabled if current version is greater than latest version', () => {
|
||||
const current = '1.0.0';
|
||||
const latest = '0.0.1';
|
||||
render(<SettingsContainer data={{ current, latest }} />);
|
||||
|
||||
expect(screen.getByText('Already up to date')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should display update button if current version is less than latest version', () => {
|
||||
const current = '0.0.1';
|
||||
const latest = '1.0.0';
|
||||
|
||||
render(<SettingsContainer data={{ current, latest }} />);
|
||||
expect(screen.getByText(`Update to ${latest}`)).toBeInTheDocument();
|
||||
expect(screen.getByText(`Update to ${latest}`)).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should display error page if error is present', () => {
|
||||
const current = faker.system.semver();
|
||||
const error = faker.lorem.sentence();
|
||||
|
||||
render(<SettingsContainer data={{ current }} error={error} />);
|
||||
|
||||
expect(screen.getByText(error)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should make update button disable if current version is equal to latest version', () => {
|
||||
const currentVersion = faker.system.semver();
|
||||
render(<SettingsContainer currentVersion={currentVersion} latestVersion={currentVersion} />);
|
||||
describe('Update', () => {
|
||||
it('should remove token from local storage on success', async () => {
|
||||
const current = '0.0.1';
|
||||
const latest = faker.system.semver();
|
||||
localStorage.setItem('token', 'token');
|
||||
|
||||
expect(screen.getByText('Already up to date')).toBeDisabled();
|
||||
render(<SettingsContainer data={{ current, latest }} />);
|
||||
|
||||
const updateButton = screen.getByText('Update');
|
||||
act(() => {
|
||||
fireEvent.click(updateButton);
|
||||
});
|
||||
|
||||
// wait 500 ms because localStore cannot be awaited in tests
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
expect(localStorage.getItem('token')).toBeNull();
|
||||
});
|
||||
|
||||
it('should display error toast on error', async () => {
|
||||
const { result, unmount } = renderHook(() => useToastStore());
|
||||
const current = '0.0.1';
|
||||
const latest = faker.system.semver();
|
||||
const error = faker.lorem.sentence();
|
||||
server.use(getTRPCMockError({ path: ['system', 'update'], type: 'mutation', message: error }));
|
||||
render(<SettingsContainer data={{ current, latest }} />);
|
||||
|
||||
const updateButton = screen.getByText('Update');
|
||||
act(() => {
|
||||
fireEvent.click(updateButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.toasts[0].description).toBe(error);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('should make update button disabled if current version is greater than latest version', () => {
|
||||
const currentVersion = '1.0.0';
|
||||
const latestVersion = '0.0.1';
|
||||
render(<SettingsContainer currentVersion={currentVersion} latestVersion={latestVersion} />);
|
||||
describe('Restart', () => {
|
||||
it('should remove token from local storage on success', async () => {
|
||||
const current = faker.system.semver();
|
||||
localStorage.setItem('token', 'token');
|
||||
|
||||
expect(screen.getByText('Already up to date')).toBeDisabled();
|
||||
});
|
||||
render(<SettingsContainer data={{ current }} />);
|
||||
const restartButton = screen.getByTestId('settings-modal-restart-button');
|
||||
act(() => {
|
||||
fireEvent.click(restartButton);
|
||||
});
|
||||
|
||||
it('should display update button if current version is less than latest version', () => {
|
||||
const currentVersion = '0.0.1';
|
||||
const latestVersion = '1.0.0';
|
||||
// wait 500 ms because localStore cannot be awaited in tests
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
render(<SettingsContainer currentVersion={currentVersion} latestVersion={latestVersion} />);
|
||||
expect(screen.getByText(`Update to ${latestVersion}`)).toBeInTheDocument();
|
||||
expect(screen.getByText(`Update to ${latestVersion}`)).not.toBeDisabled();
|
||||
});
|
||||
expect(localStorage.getItem('token')).toBeNull();
|
||||
});
|
||||
|
||||
it('should call update mutation when update button is clicked', async () => {
|
||||
// Arrange
|
||||
it('should display error toast on error', async () => {
|
||||
const { result, unmount } = renderHook(() => useToastStore());
|
||||
const current = faker.system.semver();
|
||||
const error = faker.lorem.sentence();
|
||||
server.use(getTRPCMockError({ path: ['system', 'restart'], type: 'mutation', message: error }));
|
||||
render(<SettingsContainer data={{ current }} />);
|
||||
|
||||
localStorage.setItem('token', 'token');
|
||||
const currentVersion = '0.0.1';
|
||||
const latestVersion = '1.0.0';
|
||||
const updateFn = jest.fn();
|
||||
server.use(
|
||||
graphql.mutation('Update', async (req, res, ctx) => {
|
||||
updateFn();
|
||||
return res(ctx.data({ update: true }));
|
||||
}),
|
||||
);
|
||||
render(<SettingsContainer currentVersion={currentVersion} latestVersion={latestVersion} />);
|
||||
const restartButton = screen.getByTestId('settings-modal-restart-button');
|
||||
act(() => {
|
||||
fireEvent.click(restartButton);
|
||||
});
|
||||
|
||||
// Act
|
||||
act(() => screen.getByText(`Update to ${latestVersion}`).click());
|
||||
await waitFor(() => {
|
||||
expect(result.current.toasts[0].description).toBe(error);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Update'));
|
||||
waitFor(() => expect(updateFn).toHaveBeenCalled());
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 1500)));
|
||||
|
||||
// Assert
|
||||
const token = localStorage.getItem('token');
|
||||
expect(token).toBe(null);
|
||||
});
|
||||
|
||||
it('should display error toast if update mutation fails', async () => {
|
||||
// Arrange
|
||||
const { result, unmount } = renderHook(() => useToastStore());
|
||||
const currentVersion = '0.0.1';
|
||||
const latestVersion = '1.0.0';
|
||||
const errorMessage = 'My error';
|
||||
server.use(graphql.mutation('Update', async (req, res, ctx) => res(ctx.errors([{ message: errorMessage }]))));
|
||||
render(<SettingsContainer currentVersion={currentVersion} latestVersion={latestVersion} />);
|
||||
|
||||
// Act
|
||||
act(() => screen.getByText(`Update to ${latestVersion}`).click());
|
||||
fireEvent.click(screen.getByText('Update'));
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(result.current.toasts[0].description).toBe(errorMessage));
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call restart mutation when restart button is clicked', async () => {
|
||||
// Arrange
|
||||
const restartFn = jest.fn();
|
||||
server.use(
|
||||
graphql.mutation('Restart', async (req, res, ctx) => {
|
||||
restartFn();
|
||||
return res(ctx.data({ restart: true }));
|
||||
}),
|
||||
);
|
||||
render(<SettingsContainer currentVersion="1.0.0" latestVersion="1.0.0" />);
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('settings-modal-restart-button'));
|
||||
waitFor(() => expect(restartFn).toHaveBeenCalled());
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
// Assert
|
||||
const token = localStorage.getItem('token');
|
||||
expect(token).toBe(null);
|
||||
});
|
||||
|
||||
it('should display error toast if restart mutation fails', async () => {
|
||||
// Arrange
|
||||
const { result } = renderHook(() => useToastStore());
|
||||
const errorMessage = 'Update error';
|
||||
server.use(graphql.mutation('Restart', async (req, res, ctx) => res(ctx.errors([{ message: errorMessage }]))));
|
||||
render(<SettingsContainer currentVersion="1.0.0" latestVersion="1.0.0" />);
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('settings-modal-restart-button'));
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(result.current.toasts[0].description).toBe(errorMessage));
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,45 +1,57 @@
|
|||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import semver from 'semver';
|
||||
import { Button } from '../../../../components/ui/Button';
|
||||
import { useRestartMutation, useUpdateMutation } from '../../../../generated/graphql';
|
||||
import { useDisclosure } from '../../../../hooks/useDisclosure';
|
||||
import { SystemRouterOutput } from '../../../../../server/routers/system/system.router';
|
||||
import { useToastStore } from '../../../../state/toastStore';
|
||||
import { RestartModal } from '../../components/RestartModal';
|
||||
import { UpdateModal } from '../../components/UpdateModal/UpdateModal';
|
||||
import { Layout } from '../../../../components/Layout/LayoutV2';
|
||||
import { ContainerProps } from '../../../../types/layout-helpers';
|
||||
import { trpc } from '../../../../utils/trpc';
|
||||
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
const wait = (time: number) => new Promise((resolve) => setTimeout(resolve, time));
|
||||
type IProps = { data?: SystemRouterOutput['getVersion'] };
|
||||
|
||||
interface IProps {
|
||||
currentVersion: string;
|
||||
latestVersion?: string | null;
|
||||
}
|
||||
|
||||
export const SettingsContainer: React.FC<IProps> = ({ currentVersion, latestVersion }) => {
|
||||
const SettingsContainerWithData: React.FC<Required<IProps>> = ({ data }) => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const { current, latest } = data;
|
||||
const { addToast } = useToastStore();
|
||||
const restartDisclosure = useDisclosure();
|
||||
const updateDisclosure = useDisclosure();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [restart] = useRestartMutation();
|
||||
const [update] = useUpdateMutation();
|
||||
|
||||
const defaultVersion = '0.0.0';
|
||||
const isLatest = semver.gte(currentVersion, latestVersion || defaultVersion);
|
||||
const isLatest = semver.gte(current, latest || defaultVersion);
|
||||
|
||||
const handleError = (error: unknown) => {
|
||||
restartDisclosure.close();
|
||||
updateDisclosure.close();
|
||||
if (error instanceof Error) {
|
||||
addToast({
|
||||
title: 'Error',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
position: 'top',
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
const update = trpc.system.update.useMutation({
|
||||
onMutate: () => {
|
||||
setLoading(true);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setLoading(false);
|
||||
localStorage.removeItem('token');
|
||||
},
|
||||
onError: (error) => {
|
||||
setLoading(false);
|
||||
updateDisclosure.close();
|
||||
addToast({ title: 'Error', description: error.message, status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
const restart = trpc.system.restart.useMutation({
|
||||
onMutate: () => {
|
||||
setLoading(true);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setLoading(false);
|
||||
|
||||
localStorage.removeItem('token');
|
||||
},
|
||||
onError: (error) => {
|
||||
setLoading(false);
|
||||
restartDisclosure.close();
|
||||
addToast({ title: 'Error', description: error.message, status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
const renderUpdate = () => {
|
||||
if (isLatest) {
|
||||
|
@ -49,38 +61,12 @@ export const SettingsContainer: React.FC<IProps> = ({ currentVersion, latestVers
|
|||
return (
|
||||
<div>
|
||||
<Button onClick={updateDisclosure.open} className="mr-2 btn-success">
|
||||
Update to {latestVersion}
|
||||
Update to {latest}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await restart();
|
||||
await wait(1000);
|
||||
localStorage.removeItem('token');
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await update();
|
||||
await wait(1000);
|
||||
localStorage.removeItem('token');
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="row g-0">
|
||||
|
@ -105,9 +91,15 @@ export const SettingsContainer: React.FC<IProps> = ({ currentVersion, latestVers
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RestartModal isOpen={restartDisclosure.isOpen} onClose={restartDisclosure.close} onConfirm={handleRestart} loading={loading} />
|
||||
<UpdateModal isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} onConfirm={handleUpdate} loading={loading} />
|
||||
<RestartModal isOpen={restartDisclosure.isOpen} onClose={restartDisclosure.close} onConfirm={() => restart.mutate()} loading={loading} />
|
||||
<UpdateModal isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} onConfirm={() => update.mutate()} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SettingsContainer: React.FC<ContainerProps<IProps>> = ({ data, loading, error }) => (
|
||||
<Layout title="Settings" data={data} loading={loading} error={error}>
|
||||
<SettingsContainerWithData data={data!} />
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
@ -1,21 +1,11 @@
|
|||
import { graphql } from 'msw';
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '../../../../../../tests/test-utils';
|
||||
import { server } from '../../../../mocks/server';
|
||||
import { SettingsPage } from './SettingsPage';
|
||||
|
||||
describe('Test: SettingsPage', () => {
|
||||
it('should render', async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Tipi settings')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should render error page if version query fails', async () => {
|
||||
server.use(graphql.query('Version', (req, res, ctx) => res(ctx.errors([{ message: 'My error' }]))));
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('My error')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByTestId('settings-layout')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,17 +1,10 @@
|
|||
import React from 'react';
|
||||
import type { NextPage } from 'next';
|
||||
import { useVersionQuery } from '../../../../generated/graphql';
|
||||
import { Layout } from '../../../../components/Layout';
|
||||
import { SettingsContainer } from '../../containers/SettingsContainer/SettingsContainer';
|
||||
import { ErrorPage } from '../../../../components/ui/ErrorPage';
|
||||
import { trpc } from '../../../../utils/trpc';
|
||||
|
||||
export const SettingsPage: NextPage = () => {
|
||||
const { data, loading, error } = useVersionQuery();
|
||||
const { data, isLoading, error } = trpc.system.getVersion.useQuery(undefined, { staleTime: 0 });
|
||||
|
||||
return (
|
||||
<Layout title="Settings" loading={!data?.version && loading}>
|
||||
{data?.version && <SettingsContainer currentVersion={data.version.current} latestVersion={data.version.latest} />}
|
||||
{error && <ErrorPage error={error.message} />}
|
||||
</Layout>
|
||||
);
|
||||
return <SettingsContainer data={data} loading={isLoading} error={error?.message} />;
|
||||
};
|
||||
|
|
|
@ -4,14 +4,19 @@ export enum SystemStatus {
|
|||
RUNNING = 'RUNNING',
|
||||
RESTARTING = 'RESTARTING',
|
||||
UPDATING = 'UPDATING',
|
||||
LOADING = 'LOADING',
|
||||
}
|
||||
|
||||
type Store = {
|
||||
status: SystemStatus;
|
||||
version: { current: string; latest?: string };
|
||||
setStatus: (status: SystemStatus) => void;
|
||||
setVersion: (version: { current: string; latest?: string }) => void;
|
||||
};
|
||||
|
||||
export const useSystemStore = create<Store>((set) => ({
|
||||
status: SystemStatus.RUNNING,
|
||||
version: { current: '0.0.0', latest: '0.0.0' },
|
||||
setStatus: (status: SystemStatus) => set((state) => ({ ...state, status })),
|
||||
setVersion: (version: { current: string; latest?: string }) => set((state) => ({ ...state, version })),
|
||||
}));
|
||||
|
|
|
@ -5,8 +5,8 @@ export type IToast = {
|
|||
title: string;
|
||||
description?: string;
|
||||
status: 'error' | 'success' | 'warning' | 'info';
|
||||
position: 'top';
|
||||
isClosable: true;
|
||||
position?: 'top';
|
||||
isClosable?: true;
|
||||
};
|
||||
|
||||
type Store = {
|
||||
|
@ -19,10 +19,20 @@ type Store = {
|
|||
export const useToastStore = create<Store>((set) => ({
|
||||
toasts: [],
|
||||
addToast: (toast: Omit<IToast, 'id'>) => {
|
||||
const { title, description, status, position = 'top', isClosable = true } = toast;
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
|
||||
const toastToAdd = {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
position,
|
||||
isClosable,
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, { ...toast, id }],
|
||||
toasts: [...state.toasts, { ...toastToAdd, id }],
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
|
|
4
packages/dashboard/src/client/types/layout-helpers.d.ts
vendored
Normal file
4
packages/dashboard/src/client/types/layout-helpers.d.ts
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
export type ContainerProps<T> = {
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
} & T;
|
47
packages/dashboard/src/client/utils/trpc.ts
Normal file
47
packages/dashboard/src/client/utils/trpc.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { httpBatchLink } from '@trpc/client';
|
||||
import { createTRPCNext } from '@trpc/next';
|
||||
import superjson from 'superjson';
|
||||
import type { AppRouter } from '../../server/routers/_app';
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== 'undefined') {
|
||||
// browser should use relative path
|
||||
return '';
|
||||
}
|
||||
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
}
|
||||
|
||||
let token: string | null = '';
|
||||
|
||||
export const trpc = createTRPCNext<AppRouter>({
|
||||
config() {
|
||||
return {
|
||||
/**
|
||||
* @link https://trpc.io/docs/data-transformers
|
||||
*/
|
||||
transformer: superjson,
|
||||
links: [
|
||||
// loggerLink({
|
||||
// enabled: (opts) => process.env.NODE_ENV === 'development' || (opts.direction === 'down' && opts.result instanceof Error),
|
||||
// }),
|
||||
httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
headers() {
|
||||
if (typeof window !== 'undefined') {
|
||||
token = localStorage.getItem('token');
|
||||
}
|
||||
|
||||
return {
|
||||
authorization: token ? `Bearer ${token}` : '',
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @link https://trpc.io/docs/ssr
|
||||
* */
|
||||
ssr: false,
|
||||
});
|
|
@ -10,9 +10,25 @@ import { ToastProvider } from '../client/components/hoc/ToastProvider';
|
|||
import { StatusProvider } from '../client/components/hoc/StatusProvider';
|
||||
import { AuthProvider } from '../client/components/hoc/AuthProvider';
|
||||
import { StatusScreen } from '../client/components/StatusScreen';
|
||||
import { trpc } from '../client/utils/trpc';
|
||||
import { SystemStatus, useSystemStore } from '../client/state/systemStore';
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
const { setDarkMode } = useUIStore();
|
||||
const status = trpc.system.status.useQuery(undefined, { refetchInterval: 5000 });
|
||||
const version = trpc.system.getVersion.useQuery(undefined, { networkMode: 'online' });
|
||||
|
||||
const { setStatus, setVersion } = useSystemStore();
|
||||
|
||||
useEffect(() => {
|
||||
setStatus(status.data?.status || SystemStatus.RUNNING);
|
||||
}, [status.data?.status, setStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (version.data) {
|
||||
setVersion(version.data);
|
||||
}
|
||||
}, [setVersion, version.data]);
|
||||
|
||||
// check theme on component mount
|
||||
useEffect(() => {
|
||||
|
@ -28,8 +44,8 @@ function MyApp({ Component, pageProps }: AppProps) {
|
|||
themeCheck();
|
||||
}, [setDarkMode]);
|
||||
|
||||
const { client } = useCachedResources();
|
||||
if (!client) {
|
||||
const { client, isLoadingComplete } = useCachedResources();
|
||||
if (!client || !isLoadingComplete) {
|
||||
return <StatusScreen title="" subtitle="" />;
|
||||
}
|
||||
|
||||
|
@ -51,4 +67,4 @@ function MyApp({ Component, pageProps }: AppProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export default MyApp;
|
||||
export default trpc.withTRPC(MyApp);
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
|
||||
import { router } from '../trpc';
|
||||
import { systemRouter } from './system/system.router';
|
||||
|
||||
|
@ -6,3 +7,6 @@ export const appRouter = router({
|
|||
});
|
||||
// export type definition of API
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
export type RouterInput = inferRouterInputs<AppRouter>;
|
||||
export type RouterOutput = inferRouterOutputs<AppRouter>;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createTRPCReact, httpBatchLink, loggerLink } from '@trpc/react-query';
|
||||
import { createTRPCReact, httpLink, loggerLink } from '@trpc/react-query';
|
||||
import SuperJSON from 'superjson';
|
||||
import React from 'react';
|
||||
import fetch from 'isomorphic-fetch';
|
||||
|
@ -23,7 +23,7 @@ const trpcClient = trpc.createClient({
|
|||
loggerLink({
|
||||
enabled: () => false,
|
||||
}),
|
||||
httpBatchLink({
|
||||
httpLink({
|
||||
url: 'http://localhost:3000/api/trpc',
|
||||
headers() {
|
||||
return {};
|
||||
|
@ -37,7 +37,6 @@ const trpcClient = trpc.createClient({
|
|||
transformer: SuperJSON,
|
||||
});
|
||||
|
||||
|
||||
export function TRPCTestClientProvider(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
|
||||
|
|
|
@ -16,6 +16,8 @@ jest.mock('remark-breaks', () => () => ({}));
|
|||
jest.mock('remark-gfm', () => () => ({}));
|
||||
jest.mock('remark-mdx', () => () => ({}));
|
||||
|
||||
console.error = jest.fn();
|
||||
|
||||
beforeAll(() => {
|
||||
// Enable the mocking in tests.
|
||||
server.listen();
|
||||
|
|
Loading…
Add table
Reference in a new issue