refactor: replace grapqhl queries with trpc in the frontend

This commit is contained in:
Nicolas Meienberger 2022-12-26 06:12:23 +01:00 committed by Nicolas Meienberger
parent ce6662bef5
commit 3cc3c9011e
31 changed files with 619 additions and 341 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
export type ContainerProps<T> = {
loading?: boolean;
error?: string;
} & T;

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

View File

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

View File

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

View File

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

View File

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