feat: move settings form to rsc

This commit is contained in:
Nicolas Meienberger 2023-10-01 15:27:09 +02:00
parent a7d6c183ab
commit a65940dfb6
12 changed files with 78 additions and 211 deletions

View file

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

View file

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

View file

@ -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<SettingsFormValues>;
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) => {
<IconUser className="me-2" />
<h2 className="text-2xl font-bold">{t('user-settings-title')}</h2>
</div>
<LanguageSelector showLabel />
<LanguageSelector showLabel locale={currentLocale} />
<form className="flex flex-col mt-2" onSubmit={handleSubmit(validate)}>
<div className="d-flex">
<IconAdjustmentsAlt className="me-2" />

View file

@ -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<Metadata> {
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 (
<div className="card d-flex">
@ -26,7 +31,9 @@ export default async function SettingsPage({ searchParams }: { searchParams: { t
<TabsContent value="actions">
<GeneralActions version={version} />
</TabsContent>
<TabsContent value="settings">{/* <SettingsContainer /> */}</TabsContent>
<TabsContent value="settings">
<SettingsContainer initialValues={settings} currentLocale={locale} />
</TabsContent>
<TabsContent value="security">{/* <SecurityContainer /> */}</TabsContent>
</Tabs>
</div>

View file

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

View file

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

View file

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

View file

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

View file

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