diff --git a/src/app/(dashboard)/settings/components/GeneralActions/GeneralActions.test.tsx b/src/app/(dashboard)/settings/components/GeneralActions/GeneralActions.test.tsx deleted file mode 100644 index 8f1ed860..00000000 --- a/src/app/(dashboard)/settings/components/GeneralActions/GeneralActions.test.tsx +++ /dev/null @@ -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(); - - 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(); - 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( - - - , - ); - - 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); - }); -}); diff --git a/src/app/(dashboard)/settings/components/SettingsContainer/SettingsContainer.tsx b/src/app/(dashboard)/settings/components/SettingsContainer/SettingsContainer.tsx new file mode 100644 index 00000000..0e667c07 --- /dev/null +++ b/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 ( +
+ +
+ ); +}; diff --git a/src/client/modules/Settings/containers/SettingsContainer/index.ts b/src/app/(dashboard)/settings/components/SettingsContainer/index.ts similarity index 100% rename from src/client/modules/Settings/containers/SettingsContainer/index.ts rename to src/app/(dashboard)/settings/components/SettingsContainer/index.ts diff --git a/src/client/modules/Settings/components/SettingsForm/SettingsForm.test.tsx b/src/app/(dashboard)/settings/components/SettingsForm/SettingsForm.test.tsx similarity index 100% rename from src/client/modules/Settings/components/SettingsForm/SettingsForm.test.tsx rename to src/app/(dashboard)/settings/components/SettingsForm/SettingsForm.test.tsx diff --git a/src/client/modules/Settings/components/SettingsForm/SettingsForm.tsx b/src/app/(dashboard)/settings/components/SettingsForm/SettingsForm.tsx similarity index 95% rename from src/client/modules/Settings/components/SettingsForm/SettingsForm.tsx rename to src/app/(dashboard)/settings/components/SettingsForm/SettingsForm.tsx index 490d4f0a..cf644a37 100644 --- a/src/client/modules/Settings/components/SettingsForm/SettingsForm.tsx +++ b/src/app/(dashboard)/settings/components/SettingsForm/SettingsForm.tsx @@ -1,4 +1,3 @@ -import { LanguageSelector } from '@/components/LanguageSelector'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { IconAdjustmentsAlt, IconUser } from '@tabler/icons-react'; @@ -8,6 +7,8 @@ import React, { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { Tooltip } from 'react-tooltip'; import validator from 'validator'; +import { Locale } from '@/shared/internationalization/locales'; +import { LanguageSelector } from '../../../../components/LanguageSelector'; export type SettingsFormValues = { dnsIp?: string; @@ -19,6 +20,7 @@ export type SettingsFormValues = { }; interface IProps { + currentLocale?: Locale; onSubmit: (values: SettingsFormValues) => void; initalValues?: Partial; loading?: boolean; @@ -26,7 +28,7 @@ interface 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 validateFields = (values: SettingsFormValues) => { @@ -104,7 +106,7 @@ export const SettingsForm = (props: IProps) => {

{t('user-settings-title')}

- +
diff --git a/src/client/modules/Settings/components/SettingsForm/index.ts b/src/app/(dashboard)/settings/components/SettingsForm/index.ts similarity index 100% rename from src/client/modules/Settings/components/SettingsForm/index.ts rename to src/app/(dashboard)/settings/components/SettingsForm/index.ts diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 108b353c..b104eaf3 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -3,8 +3,11 @@ import { getTranslatorFromCookie } from '@/lib/get-translator'; import { Metadata } from 'next'; import React from 'react'; import { SystemServiceClass } from '@/server/services/system'; +import { getSettings } from '@/server/core/TipiConfig'; +import { getCurrentLocale } from 'src/utils/getCurrentLocale'; import { SettingsTabTriggers } from './components/SettingsTabTriggers'; import { GeneralActions } from './components/GeneralActions'; +import { SettingsContainer } from './components/SettingsContainer'; export async function generateMetadata(): Promise { const translator = await getTranslatorFromCookie(); @@ -18,6 +21,8 @@ export default async function SettingsPage({ searchParams }: { searchParams: { t const { tab } = searchParams; const systemService = new SystemServiceClass(); const version = await systemService.getVersion(); + const settings = getSettings(); + const locale = getCurrentLocale(); return (
@@ -26,7 +31,9 @@ export default async function SettingsPage({ searchParams }: { searchParams: { t - {/* */} + + + {/* */}
diff --git a/src/app/actions/settings/update-settings.ts b/src/app/actions/settings/update-settings.ts new file mode 100644 index 00000000..0f1148c9 --- /dev/null +++ b/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); + + return { success: true }; + } catch (e) { + return handleActionError(e); + } +}); diff --git a/src/client/components/LanguageSelector/LanguageSelector.tsx b/src/client/components/LanguageSelector/LanguageSelector.tsx deleted file mode 100644 index a3e58b4f..00000000 --- a/src/client/components/LanguageSelector/LanguageSelector.tsx +++ /dev/null @@ -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 ( - - ); -}; diff --git a/src/client/components/LanguageSelector/index.ts b/src/client/components/LanguageSelector/index.ts deleted file mode 100644 index 493cac82..00000000 --- a/src/client/components/LanguageSelector/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LanguageSelector } from './LanguageSelector'; diff --git a/src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.test.tsx b/src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.test.tsx deleted file mode 100644 index 2e1dacda..00000000 --- a/src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.test.tsx +++ /dev/null @@ -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(); - - 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(); - 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(); - 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(); - 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(); - }); - }); -}); diff --git a/src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.tsx b/src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.tsx deleted file mode 100644 index 55d1e91d..00000000 --- a/src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.tsx +++ /dev/null @@ -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>({}); - 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 ( -
- -
- ); -};