feat: move settings form to rsc
This commit is contained in:
parent
7b533bb0bc
commit
b3e1245da2
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 { 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" />
|
|
@ -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
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