Просмотр исходного кода

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

Nicolas Meienberger 2 лет назад
Родитель
Сommit
d18d50814e

+ 4 - 1
__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;
+    },
   };
 }
 

+ 5 - 2
src/client/mocks/getTrpcMock.ts

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

+ 11 - 0
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',

+ 1 - 1
src/client/modules/Apps/components/InstallForm/InstallForm.tsx

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

+ 93 - 0
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(<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);
+    });
+  });
+});

+ 115 - 0
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<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>
+  );
+};

+ 1 - 0
src/client/modules/Settings/components/SettingsForm/index.ts

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

+ 96 - 0
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(<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();
+    });
+  });
+});

+ 87 - 0
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 <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>
+  );
+};

+ 1 - 0
src/client/modules/Settings/containers/GeneralActions/index.ts

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

+ 42 - 107
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(<SettingsContainer data={{ current }} />);
+  it('should render without error', () => {
+    render(<SettingsContainer />);
 
-      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();
-    });
+    expect(screen.getByText('General settings')).toBeInTheDocument();
   });
 
-  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 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' });
 
-      render(<SettingsContainer data={{ current, latest: current }} />);
-      expect(result.current.pollStatus).toBe(false);
-
-      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();
+    await waitFor(() => {
+      expect(screen.getByDisplayValue('1.1.1.1')).toBeInTheDocument();
     });
 
-    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);
-      });
+    // act
+    fireEvent.click(submitButton);
 
-      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('error');
+      expect(result.current.toasts[0].title).toEqual('Error saving settings');
     });
   });
 
-  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');
-
-      render(<SettingsContainer data={{ current, latest }} />);
-
-      const updateButton = screen.getByText('Update');
-      act(() => {
-        fireEvent.click(updateButton);
-      });
+  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' });
 
-      await waitFor(() => {
-        expect(removeItem).toBeCalledWith('token');
-        expect(result.current.pollStatus).toBe(true);
-      });
+    // act
+    fireEvent.click(submitButton);
 
-      unmount();
+    await waitFor(() => {
+      expect(screen.getByText('invalid ip')).toBeInTheDocument();
     });
+  });
 
-    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);
-      });
+  it('should show toast if updateSettings mutation succeeds', async () => {
+    // arrange
+    const { result } = renderHook(() => useToastStore());
+    render(<SettingsContainer />);
+    const submitButton = screen.getByRole('button', { name: 'Save' });
 
-      await waitFor(() => {
-        expect(result.current.toasts[0].description).toBe(error);
-      });
+    // act
+    fireEvent.click(submitButton);
 
-      unmount();
+    // assert
+    await waitFor(() => {
+      expect(result.current.toasts).toHaveLength(1);
+      expect(result.current.toasts[0].status).toEqual('success');
     });
   });
 });

+ 18 - 88
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<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);
-    },
-    onSuccess: async () => {
-      setPollStatus(true);
-      localStorage.removeItem('token');
-    },
-    onError: (error) => {
-      updateDisclosure.close();
-      addToast({ title: 'Error', description: error.message, status: 'error' });
-    },
-    onSettled: () => {
-      setLoading(false);
+  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 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);
+      addToast({ title: 'Error saving settings', description: e.message, status: 'error' });
     },
   });
 
-  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>
   );
 };

+ 0 - 14
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(<SettingsPage />);
-
-    await waitFor(() => {
-      expect(screen.getByText(error)).toBeInTheDocument();
-    });
-  });
 });

+ 17 - 8
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 (
     <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>
   );
 };