cleanup forms

This commit is contained in:
Ildar Kamalov
2025-01-21 15:31:08 +03:00
parent 290987d020
commit edd9bf7b59
9 changed files with 293 additions and 301 deletions

View File

@@ -1,12 +1,12 @@
import React, { ComponentProps, forwardRef, ReactNode } from 'react';
import clsx from 'clsx';
interface Props extends ComponentProps<'input'> {
type Props = ComponentProps<'input'> & {
label?: string;
leftAddon?: ReactNode;
rightAddon?: ReactNode;
error?: string;
}
};
export const Input = forwardRef<HTMLInputElement, Props>(
({ name, label, className, leftAddon, rightAddon, error, ...rest }, ref) => (
@@ -18,10 +18,10 @@ export const Input = forwardRef<HTMLInputElement, Props>(
)}
<div className="input-group">
{leftAddon && <div>{leftAddon}</div>}
<input className={clsx('form-control', className)} ref={ref} {...rest} />
<input className={clsx('form-control', { 'is-invalid': !!error }, className)} ref={ref} {...rest} />
{rightAddon && <div>{rightAddon}</div>}
</div>
{error && <div className="form__message form__message--error">{error}</div>}
{error && <div className="form__message form__message--error mt-1">{error}</div>}
</div>
),
);

View File

@@ -0,0 +1,27 @@
import React, { ComponentProps, forwardRef } from 'react';
import clsx from 'clsx';
type SelectProps = ComponentProps<'select'> & {
label?: string;
error?: string;
};
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ name, label, className, error, children, ...rest }, ref) => (
<div className={clsx('form-group', { 'has-error': !!error })}>
{label && (
<label className="form__label" htmlFor={name}>
{label}
</label>
)}
<div className="input-group">
<select className={clsx('form-control custom-select', className)} ref={ref} {...rest}>
{children}
</select>
</div>
{error && <div className="form__message form__message--error mt-1">{error}</div>}
</div>
),
);
Select.displayName = 'Select';

View File

@@ -1,11 +1,11 @@
import React, { ComponentProps, forwardRef } from 'react';
import clsx from 'clsx';
interface Props extends ComponentProps<'textarea'> {
type Props = ComponentProps<'textarea'> & {
className?: string;
label?: string;
error?: string;
}
};
export const Textarea = forwardRef<HTMLTextAreaElement, Props>(({ name, label, className, error, ...rest }, ref) => (
<div className={clsx('form-group', { 'has-error': !!error })}>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Trans } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import { Controller, useForm } from 'react-hook-form';
import i18next from 'i18next';
import cn from 'classnames';
@@ -13,6 +13,7 @@ import {
validatePort,
validateIsSafePort,
} from '../../../helpers/validators';
import { Input } from '../Controls/Input';
const getDownloadLink = (host: string, clientId: string, protocol: string, invalid: boolean) => {
if (!host || invalid) {
@@ -23,7 +24,7 @@ const getDownloadLink = (host: string, clientId: string, protocol: string, inval
);
}
const linkParams: { host: string, client_id?: string } = { host };
const linkParams: { host: string; client_id?: string } = { host };
if (clientId) {
linkParams.client_id = clientId;
@@ -33,8 +34,7 @@ const getDownloadLink = (host: string, clientId: string, protocol: string, inval
<a
href={getPathWithQueryString(protocol, linkParams)}
className={cn('btn btn-success btn-standard btn-large')}
download
>
download>
{i18next.t('download_mobileconfig')}
</a>
);
@@ -51,24 +51,32 @@ type FormValues = {
clientId: string;
protocol: string;
port?: number;
}
};
type Props = {
initialValues?: FormValues;
}
};
const defaultFormValues = {
host: '',
clientId: '',
protocol: MOBILE_CONFIG_LINKS.DOT,
port: undefined,
};
export const MobileConfigForm = ({ initialValues }: Props) => {
const { t } = useTranslation();
const {
register,
watch,
formState: { errors, isValid },
} = useForm<FormValues>({
control,
formState: { isValid },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: {
host: initialValues?.host || '',
clientId: initialValues?.clientId || '',
protocol: initialValues?.protocol || MOBILE_CONFIG_LINKS.DOT,
port: initialValues?.port || undefined,
...defaultFormValues,
...initialValues,
},
});
@@ -91,50 +99,46 @@ export const MobileConfigForm = ({ initialValues }: Props) => {
<div className="form__group form__group--settings">
<div className="row">
<div className="col">
<label htmlFor="host" className="form__label">
{i18next.t('dhcp_table_hostname')}
</label>
<input
id="host"
type="text"
className="form-control"
placeholder={i18next.t('form_enter_hostname')}
{...register('host', {
validate: validateServerName,
})}
<Controller
name="host"
control={control}
rules={{ validate: validateServerName }}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
label={t('dhcp_table_hostname')}
placeholder={t('form_enter_hostname')}
error={fieldState.error?.message}
/>
)}
/>
{errors.host && (
<div className="form__message form__message--error">
{errors.host.message}
</div>
)}
</div>
{protocol === MOBILE_CONFIG_LINKS.DOH && (
<div className="col">
<label htmlFor="port" className="form__label">
{i18next.t('encryption_https')}
</label>
<input
id="port"
type="number"
className="form-control"
placeholder={i18next.t('encryption_https')}
{...register('port', {
setValueAs: (val) => toNumber(val),
<Controller
name="port"
control={control}
rules={{
validate: {
range: (value) => validatePort(value) || true,
safety: (value) => validateIsSafePort(value) || true,
},
})}
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="number"
label={t('encryption_https')}
placeholder={t('encryption_https')}
error={fieldState.error?.message}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/>
{errors.port && (
<div className="form__message form__message--error">
{errors.port.message}
</div>
)}
</div>
)}
</div>
@@ -149,21 +153,21 @@ export const MobileConfigForm = ({ initialValues }: Props) => {
<Trans components={{ a: githubLink }}>client_id_desc</Trans>
</div>
<input
id="clientId"
type="text"
className="form-control"
placeholder={i18next.t('client_id_placeholder')}
{...register('clientId', {
<Controller
name="clientId"
control={control}
rules={{
validate: validateConfigClientId,
})}
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
placeholder={t('client_id_placeholder')}
error={fieldState.error?.message}
/>
)}
/>
{errors.clientId && (
<div className="form__message form__message--error">
{errors.clientId.message}
</div>
)}
</div>
<div className="form__group form__group--settings">
@@ -171,11 +175,7 @@ export const MobileConfigForm = ({ initialValues }: Props) => {
{i18next.t('protocol')}
</label>
<select
id="protocol"
className="form-control"
{...register('protocol')}
>
<select id="protocol" className="form-control" {...register('protocol')}>
<option value={MOBILE_CONFIG_LINKS.DOT}>{i18next.t('dns_over_tls')}</option>
<option value={MOBILE_CONFIG_LINKS.DOH}>{i18next.t('dns_over_https')}</option>
</select>

View File

@@ -91,6 +91,7 @@ export const STANDARD_WEB_PORT = 80;
export const STANDARD_HTTPS_PORT = 443;
export const DNS_OVER_TLS_PORT = 853;
export const DNS_OVER_QUIC_PORT = 853;
export const MIN_PORT = 1;
export const MAX_PORT = 65535;
export const EMPTY_DATE = '0001-01-01T00:00:00Z';

View File

@@ -1,27 +1,28 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { withTranslation, Trans } from 'react-i18next';
import flow from 'lodash/flow';
import cn from 'classnames';
import i18n from '../../i18n';
import { Controller, useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import Controls from './Controls';
import { validatePasswordLength } from '../../helpers/validators';
import { validatePasswordLength, validateRequiredValue } from '../../helpers/validators';
import { Input } from '../../components/ui/Controls/Input';
type AuthFormValues = {
username: string;
password: string;
confirm_password: string;
};
type Props = {
onAuthSubmit: (...args: unknown[]) => string;
pristine: boolean;
invalid: boolean;
t: (...args: unknown[]) => string;
}
onAuthSubmit: (values: AuthFormValues) => void;
};
const Auth = (props: Props) => {
const { t, onAuthSubmit } = props;
export const Auth = ({ onAuthSubmit }: Props) => {
const { t } = useTranslation();
const {
register,
handleSubmit,
watch,
formState: { errors, isDirty, isValid },
} = useForm({
control,
formState: { isDirty, isValid },
} = useForm<AuthFormValues>({
mode: 'onChange',
defaultValues: {
username: '',
@@ -34,7 +35,7 @@ const Auth = (props: Props) => {
const validateConfirmPassword = (value: string) => {
if (value !== password) {
return i18n.t('form_error_password');
return t('form_error_password');
}
return undefined;
};
@@ -51,74 +52,71 @@ const Auth = (props: Props) => {
</p>
<div className="form-group">
<label>
<Trans>install_auth_username</Trans>
</label>
<input
{...register('username', { required: t('form_error_required') })}
type="text"
className={cn('form-control', { 'is-invalid': errors.username })}
placeholder={t('install_auth_username_enter')}
autoComplete="username"
<Controller
name="username"
control={control}
rules={{ validate: validateRequiredValue }}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
label={t('install_auth_username')}
placeholder={t('install_auth_username_enter')}
error={fieldState.error?.message}
autoComplete="username"
/>
)}
/>
{errors.username && (
<div className="form__message form__message--error">
{errors.username.message}
</div>
)}
</div>
<div className="form-group">
<label>
<Trans>install_auth_password</Trans>
</label>
<input
{...register('password', {
required: t('form_error_required'),
validate: validatePasswordLength,
})}
type="password"
className={cn('form-control', { 'is-invalid': errors.password })}
placeholder={t('install_auth_password_enter')}
autoComplete="new-password"
<Controller
name="password"
control={control}
rules={{
validate: {
required: validateRequiredValue,
passwordLength: validatePasswordLength,
},
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="password"
label={t('install_auth_password')}
placeholder={t('install_auth_password_enter')}
error={fieldState.error?.message}
autoComplete="new-password"
/>
)}
/>
{errors.password && (
<div className="form__message form__message--error">
{errors.password.message}
</div>
)}
</div>
<div className="form-group">
<label>
<Trans>install_auth_confirm</Trans>
</label>
<input
{...register('confirm_password', {
required: t('form_error_required'),
validate: validateConfirmPassword,
})}
type="password"
className={cn('form-control', { 'is-invalid': errors.confirm_password })}
placeholder={t('install_auth_confirm')}
autoComplete="new-password"
<Controller
name="confirm_password"
control={control}
rules={{
validate: {
required: validateRequiredValue,
confirmPassword: validateConfirmPassword,
},
}}
render={({ field, fieldState }) => (
<Input
{...field}
type="password"
label={t('install_auth_confirm')}
placeholder={t('install_auth_confirm')}
error={fieldState.error?.message}
autoComplete="new-password"
/>
)}
/>
{errors.confirm_password && (
<div className="invalid-feedback">
{errors.confirm_password.message}
</div>
)}
</div>
</div>
<Controls
isDirty={isDirty}
isValid={isValid}
/>
<Controls isDirty={isDirty} isValid={isValid} />
</form>
);
};
export default flow([
withTranslation(),
])(Auth);

View File

@@ -15,14 +15,17 @@ import {
STANDARD_DNS_PORT,
STANDARD_WEB_PORT,
MAX_PORT,
MIN_PORT,
} from '../../helpers/constants';
import { toNumber } from '../../helpers/form';
import { validateRequiredValue } from '../../helpers/validators';
import { DhcpInterface } from '../../initialState';
import { Input } from '../../components/ui/Controls/Input';
import { Select } from '../../components/ui/Controls/Select';
import { toNumber } from '../../helpers/form';
const validateInstallPort = (value: any) => {
if (value < 1 || value > MAX_PORT) {
const validateInstallPort = (value: number) => {
if (value < MIN_PORT || value > MAX_PORT) {
return i18n.t('form_error_port');
}
return undefined;
@@ -77,13 +80,7 @@ const renderInterfaces = (interfaces: DhcpInterface[]) =>
return null;
});
const Settings: React.FC<Props> = ({
handleSubmit,
handleFix,
validateForm,
config,
interfaces,
}) => {
export const Settings = ({ handleSubmit, handleFix, validateForm, config, interfaces }: Props) => {
const { t } = useTranslation();
const defaultValues = {
@@ -101,7 +98,7 @@ const Settings: React.FC<Props> = ({
control,
watch,
handleSubmit: reactHookFormSubmit,
formState: { isValid, errors },
formState: { isValid },
} = useForm({
defaultValues,
mode: 'onChange',
@@ -113,12 +110,16 @@ const Settings: React.FC<Props> = ({
const { status: dnsStatus, can_autofix: isDnsFixAvailable } = config.dns;
const { staticIp } = config;
const webIpVal = watch("web.ip");
const webPortVal = watch("web.port");
const dnsIpVal = watch("dns.ip");
const dnsPortVal = watch("dns.port");
const webIpVal = watch('web.ip');
const webPortVal = watch('web.port');
const dnsIpVal = watch('dns.ip');
const dnsPortVal = watch('dns.port');
useEffect(() => {
if (!isValid || validateInstallPort(webPortVal) || validateInstallPort(dnsPortVal)) {
return;
}
validateForm({
web: {
ip: webIpVal,
@@ -171,44 +172,46 @@ const Settings: React.FC<Props> = ({
}
};
const getStaticIpMessage = useCallback((staticIp: StaticIpType) => {
const { static: status, ip } = staticIp;
const getStaticIpMessage = useCallback(
(staticIp: StaticIpType) => {
const { static: status, ip } = staticIp;
switch (status) {
case STATUS_RESPONSE.NO:
return (
<>
<div className="mb-2">
<Trans values={{ ip }} components={[<strong key="0">text</strong>]}>
install_static_configure
</Trans>
switch (status) {
case STATUS_RESPONSE.NO:
return (
<>
<div className="mb-2">
<Trans values={{ ip }} components={[<strong key="0">text</strong>]}>
install_static_configure
</Trans>
</div>
<button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={() => handleStaticIp(ip)}>
<Trans>set_static_ip</Trans>
</button>
</>
);
case STATUS_RESPONSE.ERROR:
return (
<div className="text-danger">
<Trans>install_static_error</Trans>
</div>
<button
type="button"
className="btn btn-outline-primary btn-sm"
onClick={() => handleStaticIp(ip)}
>
<Trans>set_static_ip</Trans>
</button>
</>
);
case STATUS_RESPONSE.ERROR:
return (
<div className="text-danger">
<Trans>install_static_error</Trans>
</div>
);
case STATUS_RESPONSE.YES:
return (
<div className="text-success">
<Trans>install_static_ok</Trans>
</div>
);
default:
return null;
}
}, [handleStaticIp]);
);
case STATUS_RESPONSE.YES:
return (
<div className="text-success">
<Trans>install_static_ok</Trans>
</div>
);
default:
return null;
}
},
[handleStaticIp],
);
const onSubmit = (data: any) => {
validateForm(data);
@@ -232,17 +235,12 @@ const Settings: React.FC<Props> = ({
name="web.ip"
control={control}
render={({ field }) => (
<select
{...field}
className="form-control custom-select"
onChange={(e) => {
field.onChange(e);
}}>
<Select {...field}>
<option value={ALL_INTERFACES_IP}>
{t('install_settings_all_interfaces')}
</option>
{renderInterfaces(interfaces)}
</select>
</Select>
)}
/>
</div>
@@ -257,29 +255,24 @@ const Settings: React.FC<Props> = ({
name="web.port"
control={control}
rules={{
required: t('form_error_required'),
validate: {
required: validateRequiredValue,
installPort: validateInstallPort,
},
}}
render={({ field }) => (
<input
render={({ field, fieldState }) => (
<Input
{...field}
type="number"
className="form-control"
placeholder={STANDARD_WEB_PORT.toString()}
error={fieldState.error?.message}
onChange={(e) => {
const val = toNumber(e.target.value);
field.onChange(val);
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/>
{errors.web?.port && (
<div className="form__message form__message--error">
{errors.web.port.message}
</div>
)}
</div>
</div>
@@ -291,8 +284,7 @@ const Settings: React.FC<Props> = ({
<button
type="button"
className="btn btn-secondary btn-sm ml-2"
onClick={() => handleAutofix('web')}
>
onClick={() => handleAutofix('web')}>
<Trans>fix</Trans>
</button>
)}
@@ -331,17 +323,12 @@ const Settings: React.FC<Props> = ({
name="dns.ip"
control={control}
render={({ field }) => (
<select
{...field}
className="form-control custom-select"
onChange={(e) => {
field.onChange(e);
}}>
<Select {...field}>
<option value={ALL_INTERFACES_IP}>
{t('install_settings_all_interfaces')}
</option>
{renderInterfaces(interfaces)}
</select>
</Select>
)}
/>
</div>
@@ -362,24 +349,19 @@ const Settings: React.FC<Props> = ({
installPort: validateInstallPort,
},
}}
render={({ field }) => (
<input
render={({ field, fieldState }) => (
<Input
{...field}
type="number"
className="form-control"
error={fieldState.error?.message}
placeholder={STANDARD_WEB_PORT.toString()}
onChange={(e) => {
const val = toNumber(e.target.value);
field.onChange(val);
const { value } = e.target;
field.onChange(toNumber(value));
}}
/>
)}
/>
{errors.dns?.port.message && (
<div className="form__message form__message--error">
{t(errors.dns.port.message)}
</div>
)}
</div>
</div>
@@ -415,16 +397,10 @@ const Settings: React.FC<Props> = ({
dnsStatus?.includes(ADDRESS_IN_USE_TEXT) && (
<Trans
components={[
<a
href={PORT_53_FAQ_LINK}
key="0"
target="_blank"
rel="noopener noreferrer"
>
<a href={PORT_53_FAQ_LINK} key="0" target="_blank" rel="noopener noreferrer">
link
</a>,
]}
>
]}>
port_53_faq_link
</Trans>
)}
@@ -463,5 +439,3 @@ const Settings: React.FC<Props> = ({
</form>
);
};
export default Settings;

View File

@@ -9,10 +9,11 @@ import { INSTALL_TOTAL_STEPS, ALL_INTERFACES_IP, DEBOUNCE_TIMEOUT } from '../../
import Loading from '../../components/ui/Loading';
import Greeting from './Greeting';
import Settings from './Settings';
import { Settings } from './Settings';
import Devices from './Devices';
import Submit from './Submit';
import Progress from './Progress';
import { Auth } from './Auth';
import Toasts from '../../components/Toasts';
import Footer from '../../components/ui/Footer';
import Icons from '../../components/ui/Icons';
@@ -20,7 +21,6 @@ import { Logo } from '../../components/ui/svg/logo';
import './Setup.css';
import '../../components/ui/Tabler.css';
import Auth from './Auth';
const Setup = () => {
const dispatch = useDispatch();
@@ -37,11 +37,13 @@ const Setup = () => {
delete config.staticIp;
if (web.port && dns.port) {
dispatch(actionCreators.setAllSettings({
web,
dns,
...config,
}));
dispatch(
actionCreators.setAllSettings({
web,
dns,
...config,
}),
);
}
};
@@ -120,4 +122,4 @@ const Setup = () => {
);
};
export default Setup;
export default Setup;

View File

@@ -1,23 +1,25 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Input } from '../../components/ui/Controls/Input';
import { validateRequiredValue } from '../../helpers/validators';
type FormValues = {
username: string;
password: string;
}
};
type LoginFormProps = {
onSubmit: (data: FormValues) => void;
processing: boolean;
}
};
const Form = ({ onSubmit, processing }: LoginFormProps) => {
const { t } = useTranslation();
const {
register,
handleSubmit,
formState: { errors, isValid },
control,
formState: { isValid },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: {
@@ -30,58 +32,46 @@ const Form = ({ onSubmit, processing }: LoginFormProps) => {
<form onSubmit={handleSubmit(onSubmit)} className="card">
<div className="card-body p-6">
<div className="form__group form__group--settings">
<label className="form__label" htmlFor="username">
{t('username_label')}
</label>
<input
id="username"
type="text"
className="form-control"
placeholder={t('username_placeholder')}
autoComplete="username"
autoCapitalize="none"
disabled={processing}
{...register('username', {
required: t('form_error_required'),
})}
<Controller
name="username"
control={control}
rules={{ validate: validateRequiredValue }}
render={({ field, fieldState }) => (
<Input
{...field}
type="text"
label={t('username_label')}
placeholder={t('username_placeholder')}
error={fieldState.error?.message}
autoComplete="username"
autoCapitalize="none"
disabled={processing}
/>
)}
/>
{errors.username && (
<span className="form__message form__message--error">
{errors.username.message}
</span>
)}
</div>
<div className="form__group form__group--settings">
<label className="form__label" htmlFor="password">
{t('password_label')}
</label>
<input
id="password"
type="password"
className="form-control"
placeholder={t('password_placeholder')}
autoComplete="current-password"
disabled={processing}
{...register('password', {
required: t('form_error_required'),
})}
<Controller
name="password"
control={control}
rules={{ validate: validateRequiredValue }}
render={({ field, fieldState }) => (
<Input
{...field}
type="password"
label={t('username_label')}
placeholder={t('password_placeholder')}
error={fieldState.error?.message}
autoComplete="current-password"
disabled={processing}
/>
)}
/>
{errors.password && (
<span className="form__message form__message--error">
{errors.password.message}
</span>
)}
</div>
<div className="form-footer">
<button
type="submit"
className="btn btn-success btn-block"
disabled={processing || !isValid}
>
<button type="submit" className="btn btn-success btn-block" disabled={processing || !isValid}>
{t('sign_in')}
</button>
</div>
@@ -90,4 +80,4 @@ const Form = ({ onSubmit, processing }: LoginFormProps) => {
);
};
export default Form;
export default Form;