diff --git a/__mocks__/fs-extra.ts b/__mocks__/fs-extra.ts index 4b1d6654..e7fd6edc 100644 --- a/__mocks__/fs-extra.ts +++ b/__mocks__/fs-extra.ts @@ -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; + }, }; } diff --git a/src/client/mocks/getTrpcMock.ts b/src/client/mocks/getTrpcMock.ts index 3a6ff376..b4f703a8 100644 --- a/src/client/mocks/getTrpcMock.ts +++ b/src/client/mocks/getTrpcMock.ts @@ -19,6 +19,7 @@ export type RpcErrorResponse = { httpStatus: number; stack: string; path: string; // TQuery + zodError?: Record; }; }; }; @@ -33,7 +34,7 @@ const jsonRpcSuccessResponse = (data: unknown): RpcSuccessResponse => { }; }; -const jsonRpcErrorResponse = (path: string, status: number, message: string): RpcErrorResponse => ({ +const jsonRpcErrorResponse = (path: string, status: number, message: string, zodError?: Record): 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; }) => { 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))), ); }; diff --git a/src/client/mocks/handlers.ts b/src/client/mocks/handlers.ts index a68cb979..15aa9837 100644 --- a/src/client/mocks/handlers.ts +++ b/src/client/mocks/handlers.ts @@ -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', diff --git a/src/client/modules/Apps/components/InstallForm/InstallForm.tsx b/src/client/modules/Apps/components/InstallForm/InstallForm.tsx index 295dfe46..f28b6d0c 100644 --- a/src/client/modules/Apps/components/InstallForm/InstallForm.tsx +++ b/src/client/modules/Apps/components/InstallForm/InstallForm.tsx @@ -89,7 +89,7 @@ export const InstallForm: React.FC = ({ formFields, onSubmit, initalValu
{formFields.filter(typeFilter).map(renderField)} {exposable && renderExposeForm()} -
diff --git a/src/client/modules/Settings/components/SettingsForm/SettingsForm.test.tsx b/src/client/modules/Settings/components/SettingsForm/SettingsForm.test.tsx new file mode 100644 index 00000000..0838397e --- /dev/null +++ b/src/client/modules/Settings/components/SettingsForm/SettingsForm.test.tsx @@ -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(); + + 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(); + + // 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(); + + // 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(); + 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(); + const submitButton = screen.getByRole('button', { name: 'Save' }); + + // act + fireEvent.click(submitButton); + + // assert + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/client/modules/Settings/components/SettingsForm/SettingsForm.tsx b/src/client/modules/Settings/components/SettingsForm/SettingsForm.tsx new file mode 100644 index 00000000..75ab943e --- /dev/null +++ b/src/client/modules/Settings/components/SettingsForm/SettingsForm.tsx @@ -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; + loading?: boolean; + submitErrors?: Record; +} + +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(); + + 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 ( +
+

General settings

+

This will update your settings.json file. Make sure you know what you are doing before updating these values.

+
+ + + Make sure this domain contains a A record pointing to your IP. + +
+
+ +
+
+ + IP address your server is listening on. Keep localhost for default +
+
+ + URL to the apps repository. +
+
+ + Path to the storage directory. Keep empty for default +
+ +
+ ); +}; diff --git a/src/client/modules/Settings/components/SettingsForm/index.ts b/src/client/modules/Settings/components/SettingsForm/index.ts new file mode 100644 index 00000000..0ecfb2e3 --- /dev/null +++ b/src/client/modules/Settings/components/SettingsForm/index.ts @@ -0,0 +1 @@ +export { SettingsForm, type SettingsFormValues } from './SettingsForm'; diff --git a/src/client/modules/Settings/containers/GeneralActions/GeneralActions.test.tsx b/src/client/modules/Settings/containers/GeneralActions/GeneralActions.test.tsx new file mode 100644 index 00000000..b25afaa3 --- /dev/null +++ b/src/client/modules/Settings/containers/GeneralActions/GeneralActions.test.tsx @@ -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(); + + 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(); + 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(); + 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(); + + // 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(); + + // 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(); + }); + }); +}); diff --git a/src/client/modules/Settings/containers/GeneralActions/GeneralActions.tsx b/src/client/modules/Settings/containers/GeneralActions/GeneralActions.tsx new file mode 100644 index 00000000..fd245044 --- /dev/null +++ b/src/client/modules/Settings/containers/GeneralActions/GeneralActions.tsx @@ -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 ; + } + + return ( +
+ +
+ ); + }; + return ( +
+
+

Actions

+

Version {versionQuery.data?.current}

+

Stay up to date with the latest version of Tipi

+ {renderUpdate()} +

Maintenance

+

Common actions to perform on your instance

+
+ +
+
+ restart.mutate()} loading={loading} /> + update.mutate()} loading={loading} /> +
+ ); +}; diff --git a/src/client/modules/Settings/containers/GeneralActions/index.ts b/src/client/modules/Settings/containers/GeneralActions/index.ts new file mode 100644 index 00000000..2136e2bd --- /dev/null +++ b/src/client/modules/Settings/containers/GeneralActions/index.ts @@ -0,0 +1 @@ +export { GeneralActions } from './GeneralActions'; diff --git a/src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.test.tsx b/src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.test.tsx index 07aa091b..40a9524b 100644 --- a/src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.test.tsx +++ b/src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.test.tsx @@ -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(); + it('should render without error', () => { + render(); - 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(); + 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(); + // 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(); - - 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(); - 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(); + const submitButton = screen.getByRole('button', { name: 'Save' }); - render(); - 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(); - - 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(); + const submitButton = screen.getByRole('button', { name: 'Save' }); - render(); + // 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(); - - 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'); }); }); }); diff --git a/src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.tsx b/src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.tsx index 3945c3e1..2e057002 100644 --- a/src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.tsx +++ b/src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.tsx @@ -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 = ({ data }) => { - const [loading, setLoading] = React.useState(false); - const { current, latest } = data; +export const SettingsContainer = () => { + const [errors, setErrors] = useState>({}); 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 ; - } - - return ( -
- -
- ); + const onSubmit = (values: SettingsFormValues) => { + updateSettings.mutate(values); }; return ( -
-
-
-
-

Tipi settings

-
- Actions -
-
-
-
-
-

Actions

-

Version {current}

-

Stay up to date with the latest version of Tipi

- {renderUpdate()} -

Maintenance

-

Common actions to perform on your instance

-
- -
-
-
- restart.mutate()} loading={loading} /> - update.mutate()} loading={loading} /> -
+
+
); }; diff --git a/src/client/modules/Settings/pages/SettingsPage/SettingsPage.test.tsx b/src/client/modules/Settings/pages/SettingsPage/SettingsPage.test.tsx index 199db3d8..250dd6cd 100644 --- a/src/client/modules/Settings/pages/SettingsPage/SettingsPage.test.tsx +++ b/src/client/modules/Settings/pages/SettingsPage/SettingsPage.test.tsx @@ -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(); - - await waitFor(() => { - expect(screen.getByText(error)).toBeInTheDocument(); - }); - }); }); diff --git a/src/client/modules/Settings/pages/SettingsPage/SettingsPage.tsx b/src/client/modules/Settings/pages/SettingsPage/SettingsPage.tsx index 038e11ef..f5ef4a01 100644 --- a/src/client/modules/Settings/pages/SettingsPage/SettingsPage.tsx +++ b/src/client/modules/Settings/pages/SettingsPage/SettingsPage.tsx @@ -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 ( - {data && } - {error && } +
+ + + Actions + Settings + + + + + + + + +
); };