feat(dashboard): add new settings form in settings page

This commit is contained in:
Nicolas Meienberger 2023-03-28 21:25:29 +02:00 committed by Nicolas Meienberger
parent 7915192aae
commit d18d50814e
14 changed files with 494 additions and 224 deletions

View File

@ -104,7 +104,7 @@ class FsMock {
getMockFiles = () => this.mockFiles;
promises = {
unlink: (p: string) => {
unlink: async (p: string) => {
if (this.mockFiles[p] instanceof Array) {
this.mockFiles[p].forEach((file: string) => {
delete this.mockFiles[path.join(p, file)];
@ -112,6 +112,9 @@ class FsMock {
}
delete this.mockFiles[p];
},
writeFile: async (p: string, data: string | string[]) => {
this.mockFiles[p] = data;
},
};
}

View File

@ -19,6 +19,7 @@ export type RpcErrorResponse = {
httpStatus: number;
stack: string;
path: string; // TQuery
zodError?: Record<string, string>;
};
};
};
@ -33,7 +34,7 @@ const jsonRpcSuccessResponse = (data: unknown): RpcSuccessResponse<any> => {
};
};
const jsonRpcErrorResponse = (path: string, status: number, message: string): RpcErrorResponse => ({
const jsonRpcErrorResponse = (path: string, status: number, message: string, zodError?: Record<string, string>): RpcErrorResponse => ({
error: {
json: {
message,
@ -43,6 +44,7 @@ const jsonRpcErrorResponse = (path: string, status: number, message: string): Rp
httpStatus: status,
stack: 'Error: Internal Server Error',
path,
zodError,
},
},
},
@ -73,12 +75,13 @@ export const getTRPCMockError = <
type?: 'query' | 'mutation';
status?: number;
message?: string;
zodError?: Record<string, 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, (_, res, ctx) =>
res(ctx.delay(), ctx.json(jsonRpcErrorResponse(`${endpoint.path[0]}.${endpoint.path[1] as string}`, endpoint.status ?? 500, endpoint.message ?? 'Internal Server Error'))),
res(ctx.delay(), ctx.json(jsonRpcErrorResponse(`${endpoint.path[0]}.${endpoint.path[1] as string}`, endpoint.status ?? 500, endpoint.message ?? 'Internal Server Error', endpoint.zodError))),
);
};

View File

@ -26,6 +26,17 @@ export const handlers = [
type: 'query',
response: { cpu: { load: 0.1 }, disk: { available: 1, total: 2, used: 1 }, memory: { available: 1, total: 2, used: 1 } },
}),
getTRPCMock({
path: ['system', 'getSettings'],
type: 'query',
response: { internalIp: 'localhost', dnsIp: '1.1.1.1', appsRepoUrl: 'https://test.com/test', domain: 'tipi.localhost' },
}),
getTRPCMock({
path: ['system', 'updateSettings'],
type: 'mutation',
response: undefined,
}),
// Auth
getTRPCMock({
path: ['auth', 'login'],
type: 'mutation',

View File

@ -89,7 +89,7 @@ export const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValu
<form data-testid={`${name}-form`} className="flex flex-col" onSubmit={handleSubmit(validate)}>
{formFields.filter(typeFilter).map(renderField)}
{exposable && renderExposeForm()}
<Button type="submit" className="btn-success">
<Button loading={loading} type="submit" className="btn-success">
{initalValues ? 'Update' : 'Install'}
</Button>
</form>

View File

@ -0,0 +1,93 @@
import React from 'react';
import { faker } from '@faker-js/faker';
import { SettingsForm } from './SettingsForm';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
describe('Test: SettingsForm', () => {
it('should render without error', () => {
render(<SettingsForm onSubmit={jest.fn()} />);
expect(screen.getByText('General settings')).toBeInTheDocument();
});
it('should put initial values in the fields', async () => {
// arrange
const initialValues = {
dnsIp: faker.internet.ipv4(),
domain: faker.internet.domainName(),
internalIp: faker.internet.ipv4(),
appsRepoUrl: faker.internet.url(),
storagePath: faker.system.directoryPath(),
};
render(<SettingsForm onSubmit={jest.fn()} initalValues={initialValues} />);
// assert
await waitFor(() => {
expect(screen.getByDisplayValue(initialValues.dnsIp)).toBeInTheDocument();
expect(screen.getByDisplayValue(initialValues.domain)).toBeInTheDocument();
expect(screen.getByDisplayValue(initialValues.internalIp)).toBeInTheDocument();
expect(screen.getByDisplayValue(initialValues.appsRepoUrl)).toBeInTheDocument();
expect(screen.getByDisplayValue(initialValues.storagePath)).toBeInTheDocument();
});
});
it('should put submit errors in the fields', async () => {
// arrange
const submitErrors = {
dnsIp: 'invalid ip',
domain: 'invalid domain',
internalIp: 'invalid internal ip',
appsRepoUrl: 'invalid url',
storagePath: 'invalid path',
};
render(<SettingsForm onSubmit={jest.fn()} submitErrors={submitErrors} />);
// assert
await waitFor(() => {
expect(screen.getByText(submitErrors.dnsIp)).toBeInTheDocument();
expect(screen.getByText(submitErrors.domain)).toBeInTheDocument();
expect(screen.getByText(submitErrors.internalIp)).toBeInTheDocument();
expect(screen.getByText(submitErrors.appsRepoUrl)).toBeInTheDocument();
expect(screen.getByText(submitErrors.storagePath)).toBeInTheDocument();
});
});
it('should correctly validate the form', async () => {
// arrange
render(<SettingsForm onSubmit={jest.fn()} />);
const submitButton = screen.getByRole('button', { name: 'Save' });
const dnsIpInput = screen.getByLabelText('DNS IP');
const domainInput = screen.getByLabelText('Domain name');
const internalIpInput = screen.getByLabelText('Internal IP');
const appsRepoUrlInput = screen.getByLabelText('Apps repo URL');
// act
fireEvent.change(dnsIpInput, { target: { value: 'invalid ip' } });
fireEvent.change(domainInput, { target: { value: 'invalid domain' } });
fireEvent.change(internalIpInput, { target: { value: 'invalid internal ip' } });
fireEvent.change(appsRepoUrlInput, { target: { value: 'invalid url' } });
fireEvent.click(submitButton);
// assert
await waitFor(() => {
expect(screen.getAllByText('Invalid IP address')).toHaveLength(2);
expect(screen.getByText('Invalid domain')).toBeInTheDocument();
expect(screen.getByText('Invalid URL')).toBeInTheDocument();
});
});
it('should call onSubmit when the form is submitted', async () => {
// arrange
const onSubmit = jest.fn();
render(<SettingsForm onSubmit={onSubmit} />);
const submitButton = screen.getByRole('button', { name: 'Save' });
// act
fireEvent.click(submitButton);
// assert
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -0,0 +1,115 @@
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import validator from 'validator';
export type SettingsFormValues = {
dnsIp?: string;
internalIp?: string;
appsRepoUrl?: string;
domain?: string;
storagePath?: string;
};
interface IProps {
onSubmit: (values: SettingsFormValues) => void;
initalValues?: Partial<SettingsFormValues>;
loading?: boolean;
submitErrors?: Record<string, string>;
}
const validateFields = (values: SettingsFormValues) => {
const errors: { [K in keyof SettingsFormValues]?: string } = {};
if (values.dnsIp && !validator.isIP(values.dnsIp)) {
errors.dnsIp = 'Invalid IP address';
}
if (values.internalIp && values.internalIp !== 'localhost' && !validator.isIP(values.internalIp)) {
errors.internalIp = 'Invalid IP address';
}
if (values.appsRepoUrl && !validator.isURL(values.appsRepoUrl)) {
errors.appsRepoUrl = 'Invalid URL';
}
if (values.domain && !validator.isFQDN(values.domain)) {
errors.domain = 'Invalid domain';
}
return errors;
};
export const SettingsForm = (props: IProps) => {
const { onSubmit, initalValues, loading, submitErrors } = props;
const {
register,
handleSubmit,
setValue,
setError,
formState: { errors, isDirty },
} = useForm<SettingsFormValues>();
useEffect(() => {
if (initalValues && !isDirty) {
Object.entries(initalValues).forEach(([key, value]) => {
setValue(key as keyof SettingsFormValues, value);
});
}
}, [initalValues, isDirty, setValue]);
useEffect(() => {
if (submitErrors) {
Object.entries(submitErrors).forEach(([key, value]) => {
setError(key as keyof SettingsFormValues, { message: value });
});
}
}, [submitErrors, setError]);
const validate = (values: SettingsFormValues) => {
const validationErrors = validateFields(values);
Object.entries(validationErrors).forEach(([key, value]) => {
if (value) {
setError(key as keyof SettingsFormValues, { message: value });
}
});
if (Object.keys(validationErrors).length === 0) {
onSubmit(values);
}
};
return (
<form data-testid="settings-form" className="flex flex-col" onSubmit={handleSubmit(validate)}>
<h2 className="text-2xl font-bold">General settings</h2>
<p className="mb-4">This will update your settings.json file. Make sure you know what you are doing before updating these values.</p>
<div className="mb-3">
<Input {...register('domain')} label="Domain name" error={errors.domain?.message} placeholder="tipi.localhost" />
<span className="text-muted">
Make sure this domain contains a <strong>A</strong> record pointing to your IP.
</span>
</div>
<div className="mb-3">
<Input {...register('dnsIp')} label="DNS IP" error={errors.dnsIp?.message} placeholder="9.9.9.9" />
</div>
<div className="mb-3">
<Input {...register('internalIp')} label="Internal IP" error={errors.internalIp?.message} placeholder="192.168.1.100" />
<span className="text-muted">IP address your server is listening on. Keep localhost for default</span>
</div>
<div className="mb-3">
<Input {...register('appsRepoUrl')} label="Apps repo URL" error={errors.appsRepoUrl?.message} placeholder="https://github.com/meienberger/runtipi-appstore" />
<span className="text-muted">URL to the apps repository.</span>
</div>
<div className="mb-3">
<Input {...register('storagePath')} label="Storage path" error={errors.storagePath?.message} placeholder="Storage path" />
<span className="text-muted">Path to the storage directory. Keep empty for default</span>
</div>
<Button loading={loading} type="submit" className="btn-success">
Save
</Button>
</form>
);
};

View File

@ -0,0 +1 @@
export { SettingsForm, type SettingsFormValues } from './SettingsForm';

View File

@ -0,0 +1,96 @@
import React from 'react';
import { useToastStore } from '@/client/state/toastStore';
import { getTRPCMock, getTRPCMockError } from '@/client/mocks/getTrpcMock';
import { server } from '@/client/mocks/server';
import { GeneralActions } from './GeneralActions';
import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../../tests/test-utils';
describe('Test: GeneralActions', () => {
it('should render without error', () => {
render(<GeneralActions />);
expect(screen.getByText('Update')).toBeInTheDocument();
});
it('should show toast if update mutation fails', async () => {
// arrange
const { result } = renderHook(() => useToastStore());
server.use(getTRPCMock({ path: ['system', 'getVersion'], response: { current: '1.0.0', latest: '2.0.0' } }));
server.use(getTRPCMockError({ path: ['system', 'update'], type: 'mutation', status: 500, message: 'Something went wrong' }));
render(<GeneralActions />);
await waitFor(() => {
expect(screen.getByText('Update to 2.0.0')).toBeInTheDocument();
});
const updateButton = screen.getByText('Update');
// act
fireEvent.click(updateButton);
// assert
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].status).toEqual('error');
expect(result.current.toasts[0].title).toEqual('Error');
expect(result.current.toasts[0].description).toEqual('Something went wrong');
});
});
it('should log user out if update is successful', async () => {
// arrange
localStorage.setItem('token', '123');
server.use(getTRPCMock({ path: ['system', 'getVersion'], response: { current: '1.0.0', latest: '2.0.0' } }));
server.use(getTRPCMock({ path: ['system', 'update'], response: true }));
render(<GeneralActions />);
await waitFor(() => {
expect(screen.getByText('Update to 2.0.0')).toBeInTheDocument();
});
const updateButton = screen.getByText('Update');
// act
fireEvent.click(updateButton);
// assert
await waitFor(() => {
expect(localStorage.getItem('token')).toBeNull();
});
});
it('should show toast if restart mutation fails', async () => {
// arrange
const { result } = renderHook(() => useToastStore());
server.use(getTRPCMockError({ path: ['system', 'restart'], type: 'mutation', status: 500, message: 'Something went wrong' }));
render(<GeneralActions />);
// Find button near the top of the page
const restartButton = screen.getByTestId('settings-modal-restart-button');
// act
fireEvent.click(restartButton);
// assert
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].status).toEqual('error');
expect(result.current.toasts[0].title).toEqual('Error');
expect(result.current.toasts[0].description).toEqual('Something went wrong');
});
});
it('should log user out if restart is successful', async () => {
// arrange
localStorage.setItem('token', '1234');
server.use(getTRPCMock({ path: ['system', 'restart'], response: true }));
render(<GeneralActions />);
// Find button near the top of the page
const restartButton = screen.getByTestId('settings-modal-restart-button');
// act
fireEvent.click(restartButton);
// assert
await waitFor(() => {
expect(localStorage.getItem('token')).toBeNull();
});
});
});

View File

@ -0,0 +1,87 @@
import React from 'react';
import semver from 'semver';
import { Button } from '../../../../components/ui/Button';
import { useDisclosure } from '../../../../hooks/useDisclosure';
import { useToastStore } from '../../../../state/toastStore';
import { RestartModal } from '../../components/RestartModal';
import { UpdateModal } from '../../components/UpdateModal/UpdateModal';
import { trpc } from '../../../../utils/trpc';
import { useSystemStore } from '../../../../state/systemStore';
export const GeneralActions = () => {
const versionQuery = trpc.system.getVersion.useQuery(undefined, { staleTime: 0 });
const [loading, setLoading] = React.useState(false);
const { addToast } = useToastStore();
const { setPollStatus } = useSystemStore();
const restartDisclosure = useDisclosure();
const updateDisclosure = useDisclosure();
const defaultVersion = '0.0.0';
const isLatest = semver.gte(versionQuery.data?.current || defaultVersion, versionQuery.data?.latest || defaultVersion);
const update = trpc.system.update.useMutation({
onMutate: () => {
setLoading(true);
},
onSuccess: async () => {
setPollStatus(true);
localStorage.removeItem('token');
},
onError: (error) => {
updateDisclosure.close();
addToast({ title: 'Error', description: error.message, status: 'error' });
},
onSettled: () => {
setLoading(false);
},
});
const restart = trpc.system.restart.useMutation({
onMutate: () => {
setLoading(true);
},
onSuccess: async () => {
setPollStatus(true);
localStorage.removeItem('token');
},
onError: (error) => {
restartDisclosure.close();
addToast({ title: 'Error', description: error.message, status: 'error' });
},
onSettled: () => {
setLoading(false);
},
});
const renderUpdate = () => {
if (isLatest) {
return <Button disabled>Already up to date</Button>;
}
return (
<div>
<Button onClick={updateDisclosure.open} className="mr-2 btn-success">
Update to {versionQuery.data?.latest}
</Button>
</div>
);
};
return (
<div className="col d-flex flex-column">
<div className="card-body">
<h2 className="mb-4">Actions</h2>
<h3 className="card-title mt-4">Version {versionQuery.data?.current}</h3>
<p className="card-subtitle">Stay up to date with the latest version of Tipi</p>
{renderUpdate()}
<h3 className="card-title mt-4">Maintenance</h3>
<p className="card-subtitle">Common actions to perform on your instance</p>
<div>
<Button onClick={restartDisclosure.open}>Restart</Button>
</div>
</div>
<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>
);
};

View File

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

View File

@ -1,131 +1,66 @@
import { faker } from '@faker-js/faker';
import React from 'react';
import { render, screen, waitFor, act, fireEvent, renderHook } from '../../../../../../tests/test-utils';
import { getTRPCMockError } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
import { useSystemStore } from '../../../../state/systemStore';
import { server } from '@/client/mocks/server';
import { getTRPCMockError } from '@/client/mocks/getTrpcMock';
import { useToastStore } from '../../../../state/toastStore';
import { SettingsContainer } from './SettingsContainer';
import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../../tests/test-utils';
describe('Test: SettingsContainer', () => {
describe('UI', () => {
it('renders without crashing', () => {
const current = faker.system.semver();
render(<SettingsContainer data={{ current }} />);
it('should render without error', () => {
render(<SettingsContainer />);
expect(screen.getByText('Tipi settings')).toBeInTheDocument();
expect(screen.getByText('Already up to date')).toBeInTheDocument();
expect(screen.getByText('General settings')).toBeInTheDocument();
});
it('should show toast if updateSettings mutation fails', async () => {
// arrange
const { result } = renderHook(() => useToastStore());
server.use(getTRPCMockError({ path: ['system', 'updateSettings'], type: 'mutation', status: 500, message: 'Something went wrong' }));
render(<SettingsContainer />);
const submitButton = screen.getByRole('button', { name: 'Save' });
await waitFor(() => {
expect(screen.getByDisplayValue('1.1.1.1')).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 }} />);
// act
fireEvent.click(submitButton);
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();
// assert
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].status).toEqual('error');
expect(result.current.toasts[0].title).toEqual('Error saving settings');
});
});
describe('Restart', () => {
it('should remove token from local storage on success', async () => {
const { result, unmount } = renderHook(() => useSystemStore());
const current = faker.system.semver();
const removeItem = jest.spyOn(localStorage, 'removeItem');
it('should put zod errors in the fields', async () => {
// arrange
server.use(getTRPCMockError({ path: ['system', 'updateSettings'], zodError: { dnsIp: 'invalid ip' }, type: 'mutation', status: 500, message: 'Something went wrong' }));
render(<SettingsContainer />);
const submitButton = screen.getByRole('button', { name: 'Save' });
render(<SettingsContainer data={{ current, latest: current }} />);
expect(result.current.pollStatus).toBe(false);
// act
fireEvent.click(submitButton);
const restartButton = screen.getByTestId('settings-modal-restart-button');
act(() => {
fireEvent.click(restartButton);
});
await waitFor(() => {
expect(removeItem).toBeCalledWith('token');
expect(result.current.pollStatus).toBe(true);
});
removeItem.mockRestore();
unmount();
});
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 }} />);
const restartButton = screen.getByTestId('settings-modal-restart-button');
act(() => {
fireEvent.click(restartButton);
});
await waitFor(() => {
expect(result.current.toasts[0].description).toBe(error);
});
unmount();
await waitFor(() => {
expect(screen.getByText('invalid ip')).toBeInTheDocument();
});
});
describe('Update', () => {
it('should remove token from local storage on success', async () => {
const { result, unmount } = renderHook(() => useSystemStore());
const current = '0.0.1';
const latest = faker.system.semver();
const removeItem = jest.spyOn(localStorage, 'removeItem');
it('should show toast if updateSettings mutation succeeds', async () => {
// arrange
const { result } = renderHook(() => useToastStore());
render(<SettingsContainer />);
const submitButton = screen.getByRole('button', { name: 'Save' });
render(<SettingsContainer data={{ current, latest }} />);
// act
fireEvent.click(submitButton);
const updateButton = screen.getByText('Update');
act(() => {
fireEvent.click(updateButton);
});
await waitFor(() => {
expect(removeItem).toBeCalledWith('token');
expect(result.current.pollStatus).toBe(true);
});
unmount();
});
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();
// assert
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].status).toEqual('success');
});
});
});

View File

@ -1,102 +1,32 @@
import React from 'react';
import semver from 'semver';
import { Button } from '../../../../components/ui/Button';
import { useDisclosure } from '../../../../hooks/useDisclosure';
import { SystemRouterOutput } from '../../../../../server/routers/system/system.router';
import React, { useState } from 'react';
import { trpc } from '@/utils/trpc';
import { useToastStore } from '../../../../state/toastStore';
import { RestartModal } from '../../components/RestartModal';
import { UpdateModal } from '../../components/UpdateModal/UpdateModal';
import { trpc } from '../../../../utils/trpc';
import { useSystemStore } from '../../../../state/systemStore';
import { SettingsForm, SettingsFormValues } from '../../components/SettingsForm';
type IProps = { data: SystemRouterOutput['getVersion'] };
export const SettingsContainer: React.FC<IProps> = ({ data }) => {
const [loading, setLoading] = React.useState(false);
const { current, latest } = data;
export const SettingsContainer = () => {
const [errors, setErrors] = useState<Record<string, string>>({});
const { addToast } = useToastStore();
const { setPollStatus } = useSystemStore();
const restartDisclosure = useDisclosure();
const updateDisclosure = useDisclosure();
const getSettings = trpc.system.getSettings.useQuery();
const updateSettings = trpc.system.updateSettings.useMutation({
onSuccess: () => {
addToast({ title: 'Settings updated', description: 'Restart your instance for settings to take effect', status: 'success' });
},
onError: (e) => {
if (e.shape?.data.zodError) {
setErrors(e.shape.data.zodError);
}
const defaultVersion = '0.0.0';
const isLatest = semver.gte(current, latest || defaultVersion);
const update = trpc.system.update.useMutation({
onMutate: () => {
setLoading(true);
},
onSuccess: async () => {
setPollStatus(true);
localStorage.removeItem('token');
},
onError: (error) => {
updateDisclosure.close();
addToast({ title: 'Error', description: error.message, status: 'error' });
},
onSettled: () => {
setLoading(false);
addToast({ title: 'Error saving settings', description: e.message, status: 'error' });
},
});
const restart = trpc.system.restart.useMutation({
onMutate: () => {
setLoading(true);
},
onSuccess: async () => {
setPollStatus(true);
localStorage.removeItem('token');
},
onError: (error) => {
restartDisclosure.close();
addToast({ title: 'Error', description: error.message, status: 'error' });
},
onSettled: () => {
setLoading(false);
},
});
const renderUpdate = () => {
if (isLatest) {
return <Button disabled>Already up to date</Button>;
}
return (
<div>
<Button onClick={updateDisclosure.open} className="mr-2 btn-success">
Update to {latest}
</Button>
</div>
);
const onSubmit = (values: SettingsFormValues) => {
updateSettings.mutate(values);
};
return (
<div className="card">
<div className="row g-0">
<div className="col-3 d-none d-md-block border-end">
<div className="card-body">
<h4 className="subheader">Tipi settings</h4>
<div className="list-group list-group-transparent">
<span className="cursor-pointer list-group-item list-group-item-action active">Actions</span>
</div>
</div>
</div>
<div className="col d-flex flex-column">
<div className="card-body">
<h2 className="mb-4">Actions</h2>
<h3 className="card-title mt-4">Version {current}</h3>
<p className="card-subtitle">Stay up to date with the latest version of Tipi</p>
{renderUpdate()}
<h3 className="card-title mt-4">Maintenance</h3>
<p className="card-subtitle">Common actions to perform on your instance</p>
<div>
<Button onClick={restartDisclosure.open}>Restart</Button>
</div>
</div>
</div>
<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 className="card-body">
<SettingsForm submitErrors={errors} initalValues={getSettings.data} loading={updateSettings.isLoading} onSubmit={onSubmit} />
</div>
);
};

View File

@ -1,8 +1,5 @@
import { faker } from '@faker-js/faker';
import React from 'react';
import { render, screen, waitFor } from '../../../../../../tests/test-utils';
import { getTRPCMockError } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
import { SettingsPage } from './SettingsPage';
describe('Test: SettingsPage', () => {
@ -11,15 +8,4 @@ describe('Test: SettingsPage', () => {
await waitFor(() => expect(screen.getByTestId('settings-layout')).toBeInTheDocument());
});
it('should display error page if error is present', async () => {
const error = faker.lorem.sentence();
server.use(getTRPCMockError({ path: ['system', 'getVersion'], message: error }));
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByText(error)).toBeInTheDocument();
});
});
});

View File

@ -1,18 +1,27 @@
import React from 'react';
import type { NextPage } from 'next';
import { SettingsContainer } from '../../containers/SettingsContainer/SettingsContainer';
import { trpc } from '../../../../utils/trpc';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Layout } from '../../../../components/Layout';
import { ErrorPage } from '../../../../components/ui/ErrorPage';
import { GeneralActions } from '../../containers/GeneralActions';
import { SettingsContainer } from '../../containers/SettingsContainer';
export const SettingsPage: NextPage = () => {
const { data, error } = trpc.system.getVersion.useQuery(undefined, { staleTime: 0 });
// TODO: add loading state
return (
<Layout title="Settings">
{data && <SettingsContainer data={data} />}
{error && <ErrorPage error={error.message} />}
<div className="card d-flex">
<Tabs defaultValue="actions">
<TabsList>
<TabsTrigger value="actions">Actions</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="actions">
<GeneralActions />
</TabsContent>
<TabsContent value="settings">
<SettingsContainer />
</TabsContent>
</Tabs>
</div>
</Layout>
);
};