Sonarr/frontend/src/Components/Form/FormInputGroup.tsx
Mark McDowall 0809a72ce5 Use react-query for tags
New: Show error when tag cannot be created
Closes #7796
2025-11-15 10:35:05 -08:00

359 lines
11 KiB
TypeScript

import React, { ElementType, ReactNode, useMemo, useState } from 'react';
import Link from 'Components/Link/Link';
import { inputTypes } from 'Helpers/Props';
import { InputType } from 'Helpers/Props/inputTypes';
import { ValidationError, ValidationWarning } from 'typings/pending';
import translate from 'Utilities/String/translate';
import AutoCompleteInput, { AutoCompleteInputProps } from './AutoCompleteInput';
import CaptchaInput, { CaptchaInputProps } from './CaptchaInput';
import CheckInput, { CheckInputProps } from './CheckInput';
import FloatInput, { FloatInputProps } from './FloatInput';
import { FormInputButtonProps } from './FormInputButton';
import { FormInputGroupProvider } from './FormInputGroupContext';
import FormInputHelpText from './FormInputHelpText';
import KeyValueListInput, { KeyValueListInputProps } from './KeyValueListInput';
import NumberInput, { NumberInputProps } from './NumberInput';
import OAuthInput, { OAuthInputProps } from './OAuthInput';
import PasswordInput from './PasswordInput';
import PathInput, { PathInputProps } from './PathInput';
import DownloadClientSelectInput, {
DownloadClientSelectInputProps,
} from './Select/DownloadClientSelectInput';
import EnhancedSelectInput, {
EnhancedSelectInputProps,
} from './Select/EnhancedSelectInput';
import IndexerFlagsSelectInput, {
IndexerFlagsSelectInputProps,
} from './Select/IndexerFlagsSelectInput';
import IndexerSelectInput, {
IndexerSelectInputProps,
} from './Select/IndexerSelectInput';
import LanguageSelectInput, {
LanguageSelectInputProps,
} from './Select/LanguageSelectInput';
import MonitorEpisodesSelectInput, {
MonitorEpisodesSelectInputProps,
} from './Select/MonitorEpisodesSelectInput';
import MonitorNewItemsSelectInput, {
MonitorNewItemsSelectInputProps,
} from './Select/MonitorNewItemsSelectInput';
import ProviderDataSelectInput, {
ProviderOptionSelectInputProps,
} from './Select/ProviderOptionSelectInput';
import QualityProfileSelectInput, {
QualityProfileSelectInputProps,
} from './Select/QualityProfileSelectInput';
import RootFolderSelectInput, {
RootFolderSelectInputProps,
} from './Select/RootFolderSelectInput';
import SeriesTypeSelectInput, {
SeriesTypeSelectInputProps,
} from './Select/SeriesTypeSelectInput';
import UMaskInput, { UMaskInputProps } from './Select/UMaskInput';
import DeviceInput, { DeviceInputProps } from './Tag/DeviceInput';
import SeriesTagInput, { SeriesTagInputProps } from './Tag/SeriesTagInput';
import TagSelectInput, { TagSelectInputProps } from './Tag/TagSelectInput';
import TextTagInput, { TextTagInputProps } from './Tag/TextTagInput';
import TextArea, { TextAreaProps } from './TextArea';
import TextInput, { TextInputProps } from './TextInput';
import styles from './FormInputGroup.css';
const componentMap: Record<InputType, ElementType> = {
autoComplete: AutoCompleteInput,
captcha: CaptchaInput,
check: CheckInput,
date: TextInput,
device: DeviceInput,
downloadClientSelect: DownloadClientSelectInput,
dynamicSelect: ProviderDataSelectInput,
file: TextInput,
float: FloatInput,
indexerFlagsSelect: IndexerFlagsSelectInput,
indexerSelect: IndexerSelectInput,
keyValueList: KeyValueListInput,
languageSelect: LanguageSelectInput,
monitorEpisodesSelect: MonitorEpisodesSelectInput,
monitorNewItemsSelect: MonitorNewItemsSelectInput,
number: NumberInput,
oauth: OAuthInput,
password: PasswordInput,
path: PathInput,
qualityProfileSelect: QualityProfileSelectInput,
rootFolderSelect: RootFolderSelectInput,
select: EnhancedSelectInput,
seriesTag: SeriesTagInput,
seriesTypeSelect: SeriesTypeSelectInput,
tag: SeriesTagInput,
tagSelect: TagSelectInput,
text: TextInput,
textArea: TextArea,
textTag: TextTagInput,
umask: UMaskInput,
} as const;
// type Components = typeof componentMap;
type PickProps<V, C extends InputType> = C extends 'text'
? TextInputProps
: C extends 'autoComplete'
? AutoCompleteInputProps
: C extends 'captcha'
? CaptchaInputProps
: C extends 'check'
? CheckInputProps
: C extends 'date'
? TextInputProps
: C extends 'device'
? DeviceInputProps
: C extends 'downloadClientSelect'
? DownloadClientSelectInputProps
: C extends 'dynamicSelect'
? ProviderOptionSelectInputProps
: C extends 'file'
? TextInputProps
: C extends 'float'
? FloatInputProps
: C extends 'indexerFlagsSelect'
? IndexerFlagsSelectInputProps
: C extends 'indexerSelect'
? IndexerSelectInputProps
: C extends 'keyValueList'
? KeyValueListInputProps
: C extends 'languageSelect'
? LanguageSelectInputProps
: C extends 'monitorEpisodesSelect'
? MonitorEpisodesSelectInputProps
: C extends 'monitorNewItemsSelect'
? MonitorNewItemsSelectInputProps
: C extends 'number'
? NumberInputProps
: C extends 'oauth'
? OAuthInputProps
: C extends 'password'
? TextInputProps
: C extends 'path'
? PathInputProps
: C extends 'qualityProfileSelect'
? QualityProfileSelectInputProps
: C extends 'rootFolderSelect'
? RootFolderSelectInputProps
: C extends 'select'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
EnhancedSelectInputProps<any, V>
: C extends 'seriesTag'
? SeriesTagInputProps<V>
: C extends 'seriesTypeSelect'
? SeriesTypeSelectInputProps
: C extends 'tag'
? SeriesTagInputProps<V>
: C extends 'tagSelect'
? TagSelectInputProps
: C extends 'text'
? TextInputProps
: C extends 'textArea'
? TextAreaProps
: C extends 'textTag'
? TextTagInputProps
: C extends 'umask'
? UMaskInputProps
: never;
export interface FormInputGroupValues<T> {
key: T;
value: string;
hint?: string;
}
// TODO: Remove once all parent components are updated to TSX and we can refactor to a consistent type
export interface ValidationMessage {
message: string;
}
export type FormInputGroupProps<V, C extends InputType> = Omit<
PickProps<V, C>,
'className'
> & {
type: C;
className?: string;
containerClassName?: string;
inputClassName?: string;
autoFocus?: boolean;
autocomplete?: string;
name: string;
buttons?: ReactNode | ReactNode[];
helpText?: string;
helpTexts?: string[];
helpTextWarning?: string;
helpLink?: string;
pending?: boolean;
placeholder?: string;
unit?: string;
errors?: (ValidationMessage | ValidationError)[];
warnings?: (ValidationMessage | ValidationWarning)[];
};
function FormInputGroup<T, C extends InputType>(
props: FormInputGroupProps<T, C>
) {
const {
className = styles.inputGroup,
containerClassName = styles.inputGroupContainer,
inputClassName,
type,
unit,
buttons = [],
helpText,
helpTexts = [],
helpTextWarning,
helpLink,
pending,
errors: serverErrors = [],
warnings: serverWarnings = [],
...otherProps
} = props;
const [clientErrors, setClientErrors] = useState<
(ValidationMessage | ValidationError)[]
>([]);
const [clientWarnings, setClientWarnings] = useState<
(ValidationMessage | ValidationWarning)[]
>([]);
const errors = useMemo(() => {
return [...clientErrors, ...serverErrors];
}, [clientErrors, serverErrors]);
const warnings = useMemo(() => {
return [...clientWarnings, ...serverWarnings];
}, [clientWarnings, serverWarnings]);
const InputComponent = componentMap[type];
const checkInput = type === inputTypes.CHECK;
const hasError = !!errors.length;
const hasWarning = !hasError && !!warnings.length;
const buttonsArray = React.Children.toArray(buttons);
const lastButtonIndex = buttonsArray.length - 1;
const hasButton = !!buttonsArray.length;
return (
<FormInputGroupProvider
setClientErrors={setClientErrors}
setClientWarnings={setClientWarnings}
>
<div className={containerClassName}>
<div className={className}>
<div className={styles.inputContainer}>
{/* @ts-expect-error - types are validated already */}
<InputComponent
className={inputClassName}
helpText={helpText}
helpTextWarning={helpTextWarning}
hasError={hasError}
hasWarning={hasWarning}
hasButton={hasButton}
{...otherProps}
/>
{unit && (
<div
className={
type === inputTypes.NUMBER
? styles.inputUnitNumber
: styles.inputUnit
}
>
{unit}
</div>
)}
</div>
{buttonsArray.map((button, index) => {
if (!React.isValidElement<FormInputButtonProps>(button)) {
return button;
}
return React.cloneElement(button, {
isLastButton: index === lastButtonIndex,
});
})}
{/* <div className={styles.pendingChangesContainer}>
{
pending &&
<Icon
name={icons.UNSAVED_SETTING}
className={styles.pendingChangesIcon}
title="Change has not been saved yet"
/>
}
</div> */}
</div>
{!checkInput && helpText ? <FormInputHelpText text={helpText} /> : null}
{!checkInput && helpTexts ? (
<div>
{helpTexts.map((text, index) => {
return (
<FormInputHelpText
key={index}
text={text}
isCheckInput={checkInput}
/>
);
})}
</div>
) : null}
{(!checkInput || helpText) && helpTextWarning ? (
<FormInputHelpText text={helpTextWarning} isWarning={true} />
) : null}
{helpLink ? <Link to={helpLink}>{translate('MoreInfo')}</Link> : null}
{errors.map((error, index) => {
return 'errorMessage' in error ? (
<FormInputHelpText
key={index}
text={error.errorMessage}
link={error.infoLink}
tooltip={error.detailedDescription}
isError={true}
isCheckInput={checkInput}
/>
) : (
<FormInputHelpText
key={index}
text={error.message}
isError={true}
isCheckInput={checkInput}
/>
);
})}
{warnings.map((warning, index) => {
return 'errorMessage' in warning ? (
<FormInputHelpText
key={index}
text={warning.errorMessage}
link={warning.infoLink}
tooltip={warning.detailedDescription}
isWarning={true}
isCheckInput={checkInput}
/>
) : (
<FormInputHelpText
key={index}
text={warning.message}
isWarning={true}
isCheckInput={checkInput}
/>
);
})}
</div>
</FormInputGroupProvider>
);
}
export default FormInputGroup;