feat: move settings form to rsc
This commit is contained in:
parent
a7d6c183ab
commit
a65940dfb6
12 changed files with 78 additions and 211 deletions
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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" />
|
|
@ -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>
|
||||
|
|
27
src/app/actions/settings/update-settings.ts
Normal file
27
src/app/actions/settings/update-settings.ts
Normal 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);
|
||||
}
|
||||
});
|
|
@ -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')}
|
||||
<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>
|
||||
);
|
||||
};
|
|
@ -1 +0,0 @@
|
|||
export { LanguageSelector } from './LanguageSelector';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
Loading…
Add table
Reference in a new issue