feat(dashboard): add new settings form in settings page
This commit is contained in:
parent
7915192aae
commit
d18d50814e
14 changed files with 494 additions and 224 deletions
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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))),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { SettingsForm, type SettingsFormValues } from './SettingsForm';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { GeneralActions } from './GeneralActions';
|
|
@ -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 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();
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
render(<SettingsContainer data={{ current, latest: current }} />);
|
||||
expect(result.current.pollStatus).toBe(false);
|
||||
|
||||
const restartButton = screen.getByTestId('settings-modal-restart-button');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(restartButton);
|
||||
});
|
||||
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(removeItem).toBeCalledWith('token');
|
||||
expect(result.current.pollStatus).toBe(true);
|
||||
expect(screen.getByDisplayValue('1.1.1.1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
removeItem.mockRestore();
|
||||
unmount();
|
||||
// act
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
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 }} />);
|
||||
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' });
|
||||
|
||||
const restartButton = screen.getByTestId('settings-modal-restart-button');
|
||||
act(() => {
|
||||
fireEvent.click(restartButton);
|
||||
});
|
||||
// act
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.toasts[0].description).toBe(error);
|
||||
});
|
||||
|
||||
unmount();
|
||||
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 }} />);
|
||||
|
||||
const updateButton = screen.getByText('Update');
|
||||
act(() => {
|
||||
fireEvent.click(updateButton);
|
||||
});
|
||||
// act
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
// assert
|
||||
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();
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
expect(result.current.toasts[0].status).toEqual('success');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 defaultVersion = '0.0.0';
|
||||
const isLatest = semver.gte(current, latest || defaultVersion);
|
||||
|
||||
const update = trpc.system.update.useMutation({
|
||||
onMutate: () => {
|
||||
setLoading(true);
|
||||
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' });
|
||||
},
|
||||
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>;
|
||||
onError: (e) => {
|
||||
if (e.shape?.data.zodError) {
|
||||
setErrors(e.shape.data.zodError);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button onClick={updateDisclosure.open} className="mr-2 btn-success">
|
||||
Update to {latest}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
addToast({ title: 'Error saving settings', description: e.message, status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
<SettingsForm submitErrors={errors} initalValues={getSettings.data} loading={updateSettings.isLoading} onSubmit={onSubmit} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue