Ver código fonte

feat: move settings form to rsc

Nicolas Meienberger 1 ano atrás
pai
commit
b3e1245da2

+ 0 - 61
src/app/(dashboard)/settings/components/GeneralActions/GeneralActions.test.tsx

@@ -1,61 +0,0 @@
-import React from 'react';
-import { getTRPCMock, getTRPCMockError } from '@/client/mocks/getTrpcMock';
-import { server } from '@/client/mocks/server';
-import { StatusProvider } from '@/components/hoc/StatusProvider';
-import { renderHook } from '@testing-library/react';
-import { useSystemStore } from '@/client/state/systemStore';
-import { GeneralActions } from './GeneralActions';
-import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
-
-describe('Test: GeneralActions', () => {
-  it('should render without error', () => {
-    render(<GeneralActions />);
-
-    expect(screen.getByText('Actions')).toBeInTheDocument();
-  });
-
-  it('should show toast if restart mutation fails', async () => {
-    // arrange
-    server.use(getTRPCMockError({ path: ['system', 'restart'], type: 'mutation', status: 500, message: 'Something went badly' }));
-    render(<GeneralActions />);
-    const restartButton = screen.getByRole('button', { name: /Restart/i });
-
-    // act
-    fireEvent.click(restartButton);
-    const restartButtonModal = screen.getByRole('button', { name: /Restart/i });
-    fireEvent.click(restartButtonModal);
-
-    // assert
-    await waitFor(() => {
-      expect(screen.getByText(/Something went badly/)).toBeInTheDocument();
-    });
-  });
-
-  it('should set poll status to true if restart mutation succeeds', async () => {
-    // arrange
-    server.use(getTRPCMock({ path: ['system', 'restart'], type: 'mutation', response: true }));
-    const { result } = renderHook(() => useSystemStore());
-    result.current.setStatus('RUNNING');
-
-    render(
-      <StatusProvider>
-        <GeneralActions />
-      </StatusProvider>,
-    );
-
-    const restartButton = screen.getByRole('button', { name: /Restart/i });
-
-    // act
-    fireEvent.click(restartButton);
-    const restartButtonModal = screen.getByRole('button', { name: /Restart/i });
-    fireEvent.click(restartButtonModal);
-
-    result.current.setStatus('RESTARTING');
-
-    // assert
-    await waitFor(() => {
-      expect(screen.getByText('Your system is restarting...')).toBeInTheDocument();
-    });
-    expect(result.current.pollStatus).toBe(true);
-  });
-});

+ 38 - 0
src/app/(dashboard)/settings/components/SettingsContainer/SettingsContainer.tsx

@@ -0,0 +1,38 @@
+'use client';
+
+import React from 'react';
+import { toast } from 'react-hot-toast';
+import { useTranslations } from 'next-intl';
+import { useAction } from 'next-safe-action/hook';
+import { updateSettingsAction } from '@/actions/settings/update-settings';
+import { Locale } from '@/shared/internationalization/locales';
+import { SettingsForm, SettingsFormValues } from '../SettingsForm';
+
+type Props = {
+  initialValues?: SettingsFormValues;
+  currentLocale: Locale;
+};
+
+export const SettingsContainer = ({ initialValues, currentLocale }: Props) => {
+  const t = useTranslations();
+
+  const updateSettingsMutation = useAction(updateSettingsAction, {
+    onSuccess: (data) => {
+      if (!data.success) {
+        toast.error(data.failure.reason);
+      } else {
+        toast.success(t('settings.settings.settings-updated'));
+      }
+    },
+  });
+
+  const onSubmit = (values: SettingsFormValues) => {
+    updateSettingsMutation.execute(values);
+  };
+
+  return (
+    <div className="card-body">
+      <SettingsForm initalValues={initialValues} currentLocale={currentLocale} loading={updateSettingsMutation.isExecuting} onSubmit={onSubmit} />
+    </div>
+  );
+};

+ 0 - 0
src/client/modules/Settings/containers/SettingsContainer/index.ts → src/app/(dashboard)/settings/components/SettingsContainer/index.ts


+ 0 - 0
src/client/modules/Settings/components/SettingsForm/SettingsForm.test.tsx → src/app/(dashboard)/settings/components/SettingsForm/SettingsForm.test.tsx


+ 5 - 3
src/client/modules/Settings/components/SettingsForm/SettingsForm.tsx → src/app/(dashboard)/settings/components/SettingsForm/SettingsForm.tsx

@@ -1,4 +1,3 @@
-import { LanguageSelector } from '@/components/LanguageSelector';
 import { Button } from '@/components/ui/Button';
 import { Button } from '@/components/ui/Button';
 import { Input } from '@/components/ui/Input';
 import { Input } from '@/components/ui/Input';
 import { IconAdjustmentsAlt, IconUser } from '@tabler/icons-react';
 import { IconAdjustmentsAlt, IconUser } from '@tabler/icons-react';
@@ -8,6 +7,8 @@ import React, { useEffect } from 'react';
 import { useForm } from 'react-hook-form';
 import { useForm } from 'react-hook-form';
 import { Tooltip } from 'react-tooltip';
 import { Tooltip } from 'react-tooltip';
 import validator from 'validator';
 import validator from 'validator';
+import { Locale } from '@/shared/internationalization/locales';
+import { LanguageSelector } from '../../../../components/LanguageSelector';
 
 
 export type SettingsFormValues = {
 export type SettingsFormValues = {
   dnsIp?: string;
   dnsIp?: string;
@@ -19,6 +20,7 @@ export type SettingsFormValues = {
 };
 };
 
 
 interface IProps {
 interface IProps {
+  currentLocale?: Locale;
   onSubmit: (values: SettingsFormValues) => void;
   onSubmit: (values: SettingsFormValues) => void;
   initalValues?: Partial<SettingsFormValues>;
   initalValues?: Partial<SettingsFormValues>;
   loading?: boolean;
   loading?: boolean;
@@ -26,7 +28,7 @@ interface IProps {
 }
 }
 
 
 export const SettingsForm = (props: IProps) => {
 export const SettingsForm = (props: IProps) => {
-  const { onSubmit, initalValues, loading, submitErrors } = props;
+  const { onSubmit, initalValues, loading, currentLocale = 'en-US', submitErrors } = props;
   const t = useTranslations('settings.settings');
   const t = useTranslations('settings.settings');
 
 
   const validateFields = (values: SettingsFormValues) => {
   const validateFields = (values: SettingsFormValues) => {
@@ -104,7 +106,7 @@ export const SettingsForm = (props: IProps) => {
         <IconUser className="me-2" />
         <IconUser className="me-2" />
         <h2 className="text-2xl font-bold">{t('user-settings-title')}</h2>
         <h2 className="text-2xl font-bold">{t('user-settings-title')}</h2>
       </div>
       </div>
-      <LanguageSelector showLabel />
+      <LanguageSelector showLabel locale={currentLocale} />
       <form className="flex flex-col mt-2" onSubmit={handleSubmit(validate)}>
       <form className="flex flex-col mt-2" onSubmit={handleSubmit(validate)}>
         <div className="d-flex">
         <div className="d-flex">
           <IconAdjustmentsAlt className="me-2" />
           <IconAdjustmentsAlt className="me-2" />

+ 0 - 0
src/client/modules/Settings/components/SettingsForm/index.ts → src/app/(dashboard)/settings/components/SettingsForm/index.ts


+ 8 - 1
src/app/(dashboard)/settings/page.tsx

@@ -3,8 +3,11 @@ import { getTranslatorFromCookie } from '@/lib/get-translator';
 import { Metadata } from 'next';
 import { Metadata } from 'next';
 import React from 'react';
 import React from 'react';
 import { SystemServiceClass } from '@/server/services/system';
 import { SystemServiceClass } from '@/server/services/system';
+import { getSettings } from '@/server/core/TipiConfig';
+import { getCurrentLocale } from 'src/utils/getCurrentLocale';
 import { SettingsTabTriggers } from './components/SettingsTabTriggers';
 import { SettingsTabTriggers } from './components/SettingsTabTriggers';
 import { GeneralActions } from './components/GeneralActions';
 import { GeneralActions } from './components/GeneralActions';
+import { SettingsContainer } from './components/SettingsContainer';
 
 
 export async function generateMetadata(): Promise<Metadata> {
 export async function generateMetadata(): Promise<Metadata> {
   const translator = await getTranslatorFromCookie();
   const translator = await getTranslatorFromCookie();
@@ -18,6 +21,8 @@ export default async function SettingsPage({ searchParams }: { searchParams: { t
   const { tab } = searchParams;
   const { tab } = searchParams;
   const systemService = new SystemServiceClass();
   const systemService = new SystemServiceClass();
   const version = await systemService.getVersion();
   const version = await systemService.getVersion();
+  const settings = getSettings();
+  const locale = getCurrentLocale();
 
 
   return (
   return (
     <div className="card d-flex">
     <div className="card d-flex">
@@ -26,7 +31,9 @@ export default async function SettingsPage({ searchParams }: { searchParams: { t
         <TabsContent value="actions">
         <TabsContent value="actions">
           <GeneralActions version={version} />
           <GeneralActions version={version} />
         </TabsContent>
         </TabsContent>
-        <TabsContent value="settings">{/* <SettingsContainer /> */}</TabsContent>
+        <TabsContent value="settings">
+          <SettingsContainer initialValues={settings} currentLocale={locale} />
+        </TabsContent>
         <TabsContent value="security">{/* <SecurityContainer /> */}</TabsContent>
         <TabsContent value="security">{/* <SecurityContainer /> */}</TabsContent>
       </Tabs>
       </Tabs>
     </div>
     </div>

+ 27 - 0
src/app/actions/settings/update-settings.ts

@@ -0,0 +1,27 @@
+'use server';
+
+import { z } from 'zod';
+import { action } from '@/lib/safe-action';
+import { getUserFromCookie } from '@/server/common/session.helpers';
+import { settingsSchema } from '@runtipi/shared';
+import { setSettings } from '@/server/core/TipiConfig';
+import { handleActionError } from '../utils/handle-action-error';
+
+/**
+ * Given a settings object, update the settings.json file
+ */
+export const updateSettingsAction = action(settingsSchema, async () => {
+  try {
+    const user = await getUserFromCookie();
+
+    if (!user?.operator) {
+      throw new Error('Not authorized');
+    }
+
+    await setSettings(settingsSchema as z.infer<typeof settingsSchema>);
+
+    return { success: true };
+  } catch (e) {
+    return handleActionError(e);
+  }
+});

+ 0 - 49
src/client/components/LanguageSelector/LanguageSelector.tsx

@@ -1,49 +0,0 @@
-import React from 'react';
-import { useLocale } from '@/client/hooks/useLocale';
-import { LOCALE_OPTIONS, Locale } from '@/shared/internationalization/locales';
-import { useTranslations } from 'next-intl';
-import { IconExternalLink } from '@tabler/icons-react';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/Select';
-
-type IProps = {
-  showLabel?: boolean;
-};
-
-export const LanguageSelector = (props: IProps) => {
-  const { showLabel = false } = props;
-  const t = useTranslations('settings.settings');
-  const { locale, changeLocale } = useLocale();
-
-  const onChange = (value: Locale) => {
-    changeLocale(value);
-  };
-
-  return (
-    <Select value={locale} defaultValue={locale} onValueChange={onChange}>
-      <SelectTrigger
-        className="mb-3"
-        name="language"
-        label={
-          showLabel && (
-            <span>
-              {t('language')}&nbsp;
-              <a href="https://crowdin.com/project/runtipi/invite?h=ae594e86cd807bc075310cab20a4aa921693663" target="_blank" rel="noreferrer">
-                {t('help-translate')}
-                <IconExternalLink className="ms-1 mb-1" size={16} />
-              </a>
-            </span>
-          )
-        }
-      >
-        <SelectValue placeholder="Language" />
-      </SelectTrigger>
-      <SelectContent>
-        {LOCALE_OPTIONS.map((option) => (
-          <SelectItem key={option.value} value={option.value}>
-            {option.label}
-          </SelectItem>
-        ))}
-      </SelectContent>
-    </Select>
-  );
-};

+ 0 - 1
src/client/components/LanguageSelector/index.ts

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

+ 0 - 60
src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.test.tsx

@@ -1,60 +0,0 @@
-import React from 'react';
-import { server } from '@/client/mocks/server';
-import { getTRPCMockError } from '@/client/mocks/getTrpcMock';
-import { SettingsContainer } from './SettingsContainer';
-import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
-
-describe('Test: SettingsContainer', () => {
-  it('should render without error', () => {
-    render(<SettingsContainer />);
-
-    expect(screen.getByText('General settings')).toBeInTheDocument();
-  });
-
-  it('should show toast if updateSettings mutation fails', async () => {
-    // arrange
-    server.use(getTRPCMockError({ path: ['system', 'updateSettings'], type: 'mutation', status: 500, message: 'Something went wrong' }));
-    render(<SettingsContainer />);
-    const submitButton = screen.getByRole('button', { name: 'Save' });
-
-    await waitFor(() => {
-      expect(screen.getByDisplayValue('1.1.1.1')).toBeInTheDocument();
-    });
-
-    // act
-    fireEvent.click(submitButton);
-
-    // assert
-    await waitFor(() => {
-      expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
-    });
-  });
-
-  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' });
-
-    // act
-    fireEvent.click(submitButton);
-
-    await waitFor(() => {
-      expect(screen.getByText('invalid ip')).toBeInTheDocument();
-    });
-  });
-
-  it('should show toast if updateSettings mutation succeeds', async () => {
-    // arrange
-    render(<SettingsContainer />);
-    const submitButton = screen.getByRole('button', { name: 'Save' });
-
-    // act
-    fireEvent.click(submitButton);
-
-    // assert
-    await waitFor(() => {
-      expect(screen.getByText(/Settings updated. Restart your instance to apply new settings./)).toBeInTheDocument();
-    });
-  });
-});

+ 0 - 36
src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.tsx

@@ -1,36 +0,0 @@
-'use client';
-
-import React, { useState } from 'react';
-import { trpc } from '@/utils/trpc';
-import { toast } from 'react-hot-toast';
-import { MessageKey } from '@/server/utils/errors';
-import { useTranslations } from 'next-intl';
-import { SettingsForm, SettingsFormValues } from '../../components/SettingsForm';
-
-export const SettingsContainer = () => {
-  const t = useTranslations();
-  const [errors, setErrors] = useState<Record<string, string>>({});
-  const getSettings = trpc.system.getSettings.useQuery();
-  const updateSettings = trpc.system.updateSettings.useMutation({
-    onSuccess: () => {
-      toast.success(t('settings.settings.settings-updated'));
-    },
-    onError: (e) => {
-      if (e.shape?.data.zodError) {
-        setErrors(e.shape.data.zodError);
-      }
-
-      toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables }));
-    },
-  });
-
-  const onSubmit = (values: SettingsFormValues) => {
-    updateSettings.mutate(values);
-  };
-
-  return (
-    <div className="card-body">
-      <SettingsForm submitErrors={errors} initalValues={getSettings.data} loading={updateSettings.isLoading} onSubmit={onSubmit} />
-    </div>
-  );
-};