mirror of
https://github.com/Sonarr/Sonarr
synced 2025-12-06 16:32:24 +01:00
Use react-query for tags
New: Show error when tag cannot be created Closes #7796
This commit is contained in:
parent
20ad1b4410
commit
0809a72ce5
40 changed files with 541 additions and 426 deletions
|
|
@ -20,6 +20,7 @@ import ModalContent from 'Components/Modal/ModalContent';
|
||||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
import Popover from 'Components/Tooltip/Popover';
|
import Popover from 'Components/Tooltip/Popover';
|
||||||
|
import { getValidationFailures } from 'Helpers/Hooks/useApiMutation';
|
||||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||||
import { SeriesType } from 'Series/Series';
|
import { SeriesType } from 'Series/Series';
|
||||||
import SeriesPoster from 'Series/SeriesPoster';
|
import SeriesPoster from 'Series/SeriesPoster';
|
||||||
|
|
@ -50,7 +51,10 @@ function AddNewSeriesModalContent({
|
||||||
const { isAdding, addError, addSeries } = useAddSeries();
|
const { isAdding, addError, addSeries } = useAddSeries();
|
||||||
|
|
||||||
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
||||||
return selectSettings(options, {}, addError);
|
return {
|
||||||
|
...selectSettings(options, {}),
|
||||||
|
...getValidationFailures(addError),
|
||||||
|
};
|
||||||
}, [options, addError]);
|
}, [options, addError]);
|
||||||
|
|
||||||
const [seriesType, setSeriesType] = useState<SeriesType>(
|
const [seriesType, setSeriesType] = useState<SeriesType>(
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import ReleasesAppState from './ReleasesAppState';
|
||||||
import RootFolderAppState from './RootFolderAppState';
|
import RootFolderAppState from './RootFolderAppState';
|
||||||
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
|
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
|
||||||
import SettingsAppState from './SettingsAppState';
|
import SettingsAppState from './SettingsAppState';
|
||||||
import TagsAppState from './TagsAppState';
|
|
||||||
|
|
||||||
export interface FilterBuilderPropOption {
|
export interface FilterBuilderPropOption {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -97,7 +96,6 @@ interface AppState {
|
||||||
seriesHistory: SeriesHistoryAppState;
|
seriesHistory: SeriesHistoryAppState;
|
||||||
seriesIndex: SeriesIndexAppState;
|
seriesIndex: SeriesIndexAppState;
|
||||||
settings: SettingsAppState;
|
settings: SettingsAppState;
|
||||||
tags: TagsAppState;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AppState;
|
export default AppState;
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,9 @@
|
||||||
import ModelBase from 'App/ModelBase';
|
|
||||||
import AppSectionState, {
|
import AppSectionState, {
|
||||||
AppSectionDeleteState,
|
AppSectionDeleteState,
|
||||||
AppSectionSaveState,
|
AppSectionSaveState,
|
||||||
} from 'App/State/AppSectionState';
|
} from 'App/State/AppSectionState';
|
||||||
|
import { TagDetail } from 'Tags/useTagDetails';
|
||||||
export interface Tag extends ModelBase {
|
import { Tag } from 'Tags/useTags';
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TagDetail extends ModelBase {
|
|
||||||
label: string;
|
|
||||||
autoTagIds: number[];
|
|
||||||
delayProfileIds: number[];
|
|
||||||
downloadClientIds: [];
|
|
||||||
importListIds: number[];
|
|
||||||
indexerIds: number[];
|
|
||||||
notificationIds: number[];
|
|
||||||
restrictionIds: number[];
|
|
||||||
excludedReleaseProfileIds: number[];
|
|
||||||
seriesIds: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TagDetailAppState
|
export interface TagDetailAppState
|
||||||
extends AppSectionState<TagDetail>,
|
extends AppSectionState<TagDetail>,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useTagList } from 'Tags/useTags';
|
||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
|
||||||
import FilterBuilderRowValue, {
|
import FilterBuilderRowValue, {
|
||||||
FilterBuilderRowValueProps,
|
FilterBuilderRowValueProps,
|
||||||
} from './FilterBuilderRowValue';
|
} from './FilterBuilderRowValue';
|
||||||
|
|
@ -11,7 +10,7 @@ type TagFilterBuilderRowValueProps<T> = Omit<
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function TagFilterBuilderRowValue<T>(props: TagFilterBuilderRowValueProps<T>) {
|
function TagFilterBuilderRowValue<T>(props: TagFilterBuilderRowValueProps<T>) {
|
||||||
const tags = useSelector(createTagsSelector());
|
const tags = useTagList();
|
||||||
|
|
||||||
const tagList = useMemo(() => {
|
const tagList = useMemo(() => {
|
||||||
return tags.map((tag) => {
|
return tags.map((tag) => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { ElementType, ReactNode } from 'react';
|
import React, { ElementType, ReactNode, useMemo, useState } from 'react';
|
||||||
import Link from 'Components/Link/Link';
|
import Link from 'Components/Link/Link';
|
||||||
import { inputTypes } from 'Helpers/Props';
|
import { inputTypes } from 'Helpers/Props';
|
||||||
import { InputType } from 'Helpers/Props/inputTypes';
|
import { InputType } from 'Helpers/Props/inputTypes';
|
||||||
|
|
@ -9,6 +9,7 @@ import CaptchaInput, { CaptchaInputProps } from './CaptchaInput';
|
||||||
import CheckInput, { CheckInputProps } from './CheckInput';
|
import CheckInput, { CheckInputProps } from './CheckInput';
|
||||||
import FloatInput, { FloatInputProps } from './FloatInput';
|
import FloatInput, { FloatInputProps } from './FloatInput';
|
||||||
import { FormInputButtonProps } from './FormInputButton';
|
import { FormInputButtonProps } from './FormInputButton';
|
||||||
|
import { FormInputGroupProvider } from './FormInputGroupContext';
|
||||||
import FormInputHelpText from './FormInputHelpText';
|
import FormInputHelpText from './FormInputHelpText';
|
||||||
import KeyValueListInput, { KeyValueListInputProps } from './KeyValueListInput';
|
import KeyValueListInput, { KeyValueListInputProps } from './KeyValueListInput';
|
||||||
import NumberInput, { NumberInputProps } from './NumberInput';
|
import NumberInput, { NumberInputProps } from './NumberInput';
|
||||||
|
|
@ -206,11 +207,27 @@ function FormInputGroup<T, C extends InputType>(
|
||||||
helpTextWarning,
|
helpTextWarning,
|
||||||
helpLink,
|
helpLink,
|
||||||
pending,
|
pending,
|
||||||
errors = [],
|
errors: serverErrors = [],
|
||||||
warnings = [],
|
warnings: serverWarnings = [],
|
||||||
...otherProps
|
...otherProps
|
||||||
} = props;
|
} = 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 InputComponent = componentMap[type];
|
||||||
const checkInput = type === inputTypes.CHECK;
|
const checkInput = type === inputTypes.CHECK;
|
||||||
const hasError = !!errors.length;
|
const hasError = !!errors.length;
|
||||||
|
|
@ -220,44 +237,48 @@ function FormInputGroup<T, C extends InputType>(
|
||||||
const hasButton = !!buttonsArray.length;
|
const hasButton = !!buttonsArray.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={containerClassName}>
|
<FormInputGroupProvider
|
||||||
<div className={className}>
|
setClientErrors={setClientErrors}
|
||||||
<div className={styles.inputContainer}>
|
setClientWarnings={setClientWarnings}
|
||||||
{/* @ts-expect-error - types are validated already */}
|
>
|
||||||
<InputComponent
|
<div className={containerClassName}>
|
||||||
className={inputClassName}
|
<div className={className}>
|
||||||
helpText={helpText}
|
<div className={styles.inputContainer}>
|
||||||
helpTextWarning={helpTextWarning}
|
{/* @ts-expect-error - types are validated already */}
|
||||||
hasError={hasError}
|
<InputComponent
|
||||||
hasWarning={hasWarning}
|
className={inputClassName}
|
||||||
hasButton={hasButton}
|
helpText={helpText}
|
||||||
{...otherProps}
|
helpTextWarning={helpTextWarning}
|
||||||
/>
|
hasError={hasError}
|
||||||
|
hasWarning={hasWarning}
|
||||||
|
hasButton={hasButton}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
|
||||||
{unit && (
|
{unit && (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
type === inputTypes.NUMBER
|
type === inputTypes.NUMBER
|
||||||
? styles.inputUnitNumber
|
? styles.inputUnitNumber
|
||||||
: styles.inputUnit
|
: styles.inputUnit
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{unit}
|
{unit}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{buttonsArray.map((button, index) => {
|
{buttonsArray.map((button, index) => {
|
||||||
if (!React.isValidElement<FormInputButtonProps>(button)) {
|
if (!React.isValidElement<FormInputButtonProps>(button)) {
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
return React.cloneElement(button, {
|
return React.cloneElement(button, {
|
||||||
isLastButton: index === lastButtonIndex,
|
isLastButton: index === lastButtonIndex,
|
||||||
});
|
});
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* <div className={styles.pendingChangesContainer}>
|
{/* <div className={styles.pendingChangesContainer}>
|
||||||
{
|
{
|
||||||
pending &&
|
pending &&
|
||||||
<Icon
|
<Icon
|
||||||
|
|
@ -267,70 +288,71 @@ function FormInputGroup<T, C extends InputType>(
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</div> */}
|
</div> */}
|
||||||
</div>
|
|
||||||
|
|
||||||
{!checkInput && helpText ? <FormInputHelpText text={helpText} /> : null}
|
|
||||||
|
|
||||||
{!checkInput && helpTexts ? (
|
|
||||||
<div>
|
|
||||||
{helpTexts.map((text, index) => {
|
|
||||||
return (
|
|
||||||
<FormInputHelpText
|
|
||||||
key={index}
|
|
||||||
text={text}
|
|
||||||
isCheckInput={checkInput}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{(!checkInput || helpText) && helpTextWarning ? (
|
{!checkInput && helpText ? <FormInputHelpText text={helpText} /> : null}
|
||||||
<FormInputHelpText text={helpTextWarning} isWarning={true} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{helpLink ? <Link to={helpLink}>{translate('MoreInfo')}</Link> : null}
|
{!checkInput && helpTexts ? (
|
||||||
|
<div>
|
||||||
|
{helpTexts.map((text, index) => {
|
||||||
|
return (
|
||||||
|
<FormInputHelpText
|
||||||
|
key={index}
|
||||||
|
text={text}
|
||||||
|
isCheckInput={checkInput}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{errors.map((error, index) => {
|
{(!checkInput || helpText) && helpTextWarning ? (
|
||||||
return 'errorMessage' in error ? (
|
<FormInputHelpText text={helpTextWarning} isWarning={true} />
|
||||||
<FormInputHelpText
|
) : null}
|
||||||
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) => {
|
{helpLink ? <Link to={helpLink}>{translate('MoreInfo')}</Link> : null}
|
||||||
return 'errorMessage' in warning ? (
|
|
||||||
<FormInputHelpText
|
{errors.map((error, index) => {
|
||||||
key={index}
|
return 'errorMessage' in error ? (
|
||||||
text={warning.errorMessage}
|
<FormInputHelpText
|
||||||
link={warning.infoLink}
|
key={index}
|
||||||
tooltip={warning.detailedDescription}
|
text={error.errorMessage}
|
||||||
isWarning={true}
|
link={error.infoLink}
|
||||||
isCheckInput={checkInput}
|
tooltip={error.detailedDescription}
|
||||||
/>
|
isError={true}
|
||||||
) : (
|
isCheckInput={checkInput}
|
||||||
<FormInputHelpText
|
/>
|
||||||
key={index}
|
) : (
|
||||||
text={warning.message}
|
<FormInputHelpText
|
||||||
isWarning={true}
|
key={index}
|
||||||
isCheckInput={checkInput}
|
text={error.message}
|
||||||
/>
|
isError={true}
|
||||||
);
|
isCheckInput={checkInput}
|
||||||
})}
|
/>
|
||||||
</div>
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
44
frontend/src/Components/Form/FormInputGroupContext.tsx
Normal file
44
frontend/src/Components/Form/FormInputGroupContext.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import React, { createContext, PropsWithChildren, useMemo } from 'react';
|
||||||
|
import { ValidationError, ValidationWarning } from 'typings/pending';
|
||||||
|
import { ValidationMessage } from './FormInputGroup';
|
||||||
|
|
||||||
|
interface FormInputGroupProviderProps extends PropsWithChildren {
|
||||||
|
setClientErrors: (errors: (ValidationMessage | ValidationError)[]) => void;
|
||||||
|
setClientWarnings: (
|
||||||
|
warnings: (ValidationMessage | ValidationWarning)[]
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormInputGroupContextProps {
|
||||||
|
setClientErrors: (errors: (ValidationMessage | ValidationError)[]) => void;
|
||||||
|
setClientWarnings: (
|
||||||
|
warnings: (ValidationMessage | ValidationWarning)[]
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormInputGroupContext = createContext<
|
||||||
|
FormInputGroupContextProps | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
export function FormInputGroupProvider({
|
||||||
|
setClientErrors,
|
||||||
|
setClientWarnings,
|
||||||
|
children,
|
||||||
|
}: FormInputGroupProviderProps) {
|
||||||
|
const value = useMemo(() => {
|
||||||
|
return {
|
||||||
|
setClientErrors,
|
||||||
|
setClientWarnings,
|
||||||
|
};
|
||||||
|
}, [setClientErrors, setClientWarnings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormInputGroupContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</FormInputGroupContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFormInputGroup() {
|
||||||
|
return React.useContext(FormInputGroupContext);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { Tag, useAddTag, useSortedTagList } from 'Tags/useTags';
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import { addTag } from 'Store/Actions/tagActions';
|
|
||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
|
||||||
import { InputChanged } from 'typings/inputs';
|
import { InputChanged } from 'typings/inputs';
|
||||||
import sortByProp from 'Utilities/Array/sortByProp';
|
import { useFormInputGroup } from '../FormInputGroupContext';
|
||||||
import TagInput, { TagBase, TagInputProps } from './TagInput';
|
import TagInput, { TagBase, TagInputProps } from './TagInput';
|
||||||
|
|
||||||
interface SeriesTag extends TagBase {
|
interface SeriesTag extends TagBase {
|
||||||
|
|
@ -22,45 +19,33 @@ export interface SeriesTagInputProps<V>
|
||||||
onChange: (change: InputChanged<V>) => void;
|
onChange: (change: InputChanged<V>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALID_TAG_REGEX = new RegExp('[^-_a-z0-9]', 'i');
|
function useSeriesTags(tags: number[]) {
|
||||||
|
const sortedTags = useSortedTagList();
|
||||||
|
const filteredTagList = sortedTags.filter((tag) => !tags.includes(tag.id));
|
||||||
|
|
||||||
function isValidTag(tagName: string) {
|
return {
|
||||||
try {
|
tags: tags.reduce((acc: SeriesTag[], tag) => {
|
||||||
return !VALID_TAG_REGEX.test(tagName);
|
const matchingTag = sortedTags.find((t) => t.id === tag);
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSeriesTagsSelector(tags: number[]) {
|
if (matchingTag) {
|
||||||
return createSelector(createTagsSelector(), (tagList) => {
|
acc.push({
|
||||||
const sortedTags = tagList.sort(sortByProp('label'));
|
id: tag,
|
||||||
const filteredTagList = sortedTags.filter((tag) => !tags.includes(tag.id));
|
name: matchingTag.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return acc;
|
||||||
tags: tags.reduce((acc: SeriesTag[], tag) => {
|
}, []),
|
||||||
const matchingTag = tagList.find((t) => t.id === tag);
|
|
||||||
|
|
||||||
if (matchingTag) {
|
tagList: filteredTagList.map(({ id, label: name }) => {
|
||||||
acc.push({
|
return {
|
||||||
id: tag,
|
id,
|
||||||
name: matchingTag.label,
|
name,
|
||||||
});
|
};
|
||||||
}
|
}),
|
||||||
|
|
||||||
return acc;
|
allTags: sortedTags,
|
||||||
}, []),
|
};
|
||||||
|
|
||||||
tagList: filteredTagList.map(({ id, label: name }) => {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
allTags: sortedTags,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SeriesTagInput<V extends number | number[]>({
|
export default function SeriesTagInput<V extends number | number[]>({
|
||||||
|
|
@ -69,7 +54,7 @@ export default function SeriesTagInput<V extends number | number[]>({
|
||||||
onChange,
|
onChange,
|
||||||
...otherProps
|
...otherProps
|
||||||
}: SeriesTagInputProps<V>) {
|
}: SeriesTagInputProps<V>) {
|
||||||
const dispatch = useDispatch();
|
const formInputActions = useFormInputGroup();
|
||||||
const isArray = Array.isArray(value);
|
const isArray = Array.isArray(value);
|
||||||
|
|
||||||
const arrayValue = useMemo(() => {
|
const arrayValue = useMemo(() => {
|
||||||
|
|
@ -80,12 +65,10 @@ export default function SeriesTagInput<V extends number | number[]>({
|
||||||
return value === 0 ? [] : [value as number];
|
return value === 0 ? [] : [value as number];
|
||||||
}, [isArray, value]);
|
}, [isArray, value]);
|
||||||
|
|
||||||
const { tags, tagList, allTags } = useSelector(
|
const { tags, tagList, allTags } = useSeriesTags(arrayValue);
|
||||||
createSeriesTagsSelector(arrayValue)
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTagCreated = useCallback(
|
const handleTagCreated = useCallback(
|
||||||
(tag: SeriesTag) => {
|
(tag: Tag) => {
|
||||||
if (isArray) {
|
if (isArray) {
|
||||||
onChange({ name, value: [...value, tag.id] as V });
|
onChange({ name, value: [...value, tag.id] as V });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -98,6 +81,8 @@ export default function SeriesTagInput<V extends number | number[]>({
|
||||||
[name, value, isArray, onChange]
|
[name, value, isArray, onChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { addTag, addTagError } = useAddTag(handleTagCreated);
|
||||||
|
|
||||||
const handleTagAdd = useCallback(
|
const handleTagAdd = useCallback(
|
||||||
(newTag: SeriesTag) => {
|
(newTag: SeriesTag) => {
|
||||||
if (newTag.id) {
|
if (newTag.id) {
|
||||||
|
|
@ -112,16 +97,13 @@ export default function SeriesTagInput<V extends number | number[]>({
|
||||||
|
|
||||||
const existingTag = allTags.some((t) => t.label === newTag.name);
|
const existingTag = allTags.some((t) => t.label === newTag.name);
|
||||||
|
|
||||||
if (isValidTag(newTag.name) && !existingTag) {
|
if (!existingTag) {
|
||||||
dispatch(
|
addTag({
|
||||||
addTag({
|
label: newTag.name,
|
||||||
tag: { label: newTag.name },
|
});
|
||||||
onTagCreated: handleTagCreated,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[name, value, isArray, allTags, handleTagCreated, onChange, dispatch]
|
[name, value, isArray, allTags, onChange, addTag]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTagDelete = useCallback(
|
const handleTagDelete = useCallback(
|
||||||
|
|
@ -138,6 +120,15 @@ export default function SeriesTagInput<V extends number | number[]>({
|
||||||
[name, value, isArray, onChange]
|
[name, value, isArray, onChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
formInputActions?.setClientErrors(addTagError?.errors ?? []);
|
||||||
|
formInputActions?.setClientWarnings(addTagError?.warnings ?? []);
|
||||||
|
}, [addTagError, formInputActions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.info('\x1b[36m[MarkTest] formInputActions has changed\x1b[0m');
|
||||||
|
}, [formInputActions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TagInput
|
<TagInput
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ interface ErrorPageProps {
|
||||||
translationsError?: Error;
|
translationsError?: Error;
|
||||||
seriesError?: Error;
|
seriesError?: Error;
|
||||||
customFiltersError?: Error;
|
customFiltersError?: Error;
|
||||||
tagsError?: Error;
|
tagsError: ApiError | null;
|
||||||
qualityProfilesError?: Error;
|
qualityProfilesError?: Error;
|
||||||
uiSettingsError?: Error;
|
uiSettingsError?: Error;
|
||||||
systemStatusError: ApiError | null;
|
systemStatusError: ApiError | null;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import Autosuggest from 'react-autosuggest';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
import { Tag } from 'App/State/TagsAppState';
|
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
|
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
|
||||||
|
|
@ -22,7 +21,7 @@ import { icons } from 'Helpers/Props';
|
||||||
import Series from 'Series/Series';
|
import Series from 'Series/Series';
|
||||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||||
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
|
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
|
||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
import { Tag, useTagList } from 'Tags/useTags';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import SeriesSearchResult from './SeriesSearchResult';
|
import SeriesSearchResult from './SeriesSearchResult';
|
||||||
import styles from './SeriesSearchInput.css';
|
import styles from './SeriesSearchInput.css';
|
||||||
|
|
@ -70,60 +69,57 @@ interface Section {
|
||||||
suggestions: SeriesSuggestion[] | AddNewSeriesSuggestion[];
|
suggestions: SeriesSuggestion[] | AddNewSeriesSuggestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function createUnoptimizedSelector() {
|
function createUnoptimizedSelector(tagList: Tag[]) {
|
||||||
return createSelector(
|
return createSelector(createAllSeriesSelector(), (allSeries) => {
|
||||||
createAllSeriesSelector(),
|
return allSeries.map((series): SuggestedSeries => {
|
||||||
createTagsSelector(),
|
const {
|
||||||
(allSeries, allTags) => {
|
title,
|
||||||
return allSeries.map((series): SuggestedSeries => {
|
titleSlug,
|
||||||
const {
|
sortTitle,
|
||||||
title,
|
images,
|
||||||
titleSlug,
|
alternateTitles = [],
|
||||||
sortTitle,
|
tvdbId,
|
||||||
images,
|
tvMazeId,
|
||||||
alternateTitles = [],
|
imdbId,
|
||||||
tvdbId,
|
tmdbId,
|
||||||
tvMazeId,
|
tags = [],
|
||||||
imdbId,
|
} = series;
|
||||||
tmdbId,
|
|
||||||
tags = [],
|
|
||||||
} = series;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
titleSlug,
|
titleSlug,
|
||||||
sortTitle,
|
sortTitle,
|
||||||
images,
|
images,
|
||||||
alternateTitles,
|
alternateTitles,
|
||||||
tvdbId,
|
tvdbId,
|
||||||
tvMazeId,
|
tvMazeId,
|
||||||
imdbId,
|
imdbId,
|
||||||
tmdbId,
|
tmdbId,
|
||||||
firstCharacter: title.charAt(0).toLowerCase(),
|
firstCharacter: title.charAt(0).toLowerCase(),
|
||||||
tags: tags.reduce<Tag[]>((acc, id) => {
|
tags: tags.reduce<Tag[]>((acc, id) => {
|
||||||
const matchingTag = allTags.find((tag) => tag.id === id);
|
const matchingTag = tagList.find((tag) => tag.id === id);
|
||||||
|
|
||||||
if (matchingTag) {
|
if (matchingTag) {
|
||||||
acc.push(matchingTag);
|
acc.push(matchingTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, []),
|
}, []),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSeriesSelector() {
|
function createSeriesSelector(tagList: Tag[]) {
|
||||||
return createDeepEqualSelector(
|
return createDeepEqualSelector(
|
||||||
createUnoptimizedSelector(),
|
createUnoptimizedSelector(tagList),
|
||||||
(series) => series
|
(series) => series
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SeriesSearchInput() {
|
function SeriesSearchInput() {
|
||||||
const series = useSelector(createSeriesSelector());
|
const tagList = useTagList();
|
||||||
|
const series = useSelector(createSeriesSelector(tagList));
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { bindShortcut, unbindShortcut } = useKeyboardShortcuts();
|
const { bindShortcut, unbindShortcut } = useKeyboardShortcuts();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tag } from 'App/State/TagsAppState';
|
|
||||||
import Label from 'Components/Label';
|
import Label from 'Components/Label';
|
||||||
import { kinds } from 'Helpers/Props';
|
import { kinds } from 'Helpers/Props';
|
||||||
import SeriesPoster from 'Series/SeriesPoster';
|
import SeriesPoster from 'Series/SeriesPoster';
|
||||||
|
import { Tag } from 'Tags/useTags';
|
||||||
import { SuggestedSeries } from './SeriesSearchInput';
|
import { SuggestedSeries } from './SeriesSearchInput';
|
||||||
import styles from './SeriesSearchResult.css';
|
import styles from './SeriesSearchResult.css';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@ import { Error } from 'App/State/AppSectionState';
|
||||||
import Alert from 'Components/Alert';
|
import Alert from 'Components/Alert';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
import { kinds } from 'Helpers/Props';
|
import { kinds } from 'Helpers/Props';
|
||||||
|
import { ApiError } from 'Utilities/Fetch/fetchJson';
|
||||||
|
|
||||||
interface PageSectionContentProps {
|
interface PageSectionContentProps {
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
isPopulated: boolean;
|
isPopulated: boolean;
|
||||||
error?: Error;
|
error?: Error | ApiError | null;
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useTagList } from 'Tags/useTags';
|
||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
|
||||||
import TagList from './TagList';
|
import TagList from './TagList';
|
||||||
|
|
||||||
interface SeriesTagListProps {
|
interface SeriesTagListProps {
|
||||||
|
|
@ -8,7 +7,7 @@ interface SeriesTagListProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function SeriesTagList({ tags }: SeriesTagListProps) {
|
function SeriesTagList({ tags }: SeriesTagListProps) {
|
||||||
const tagList = useSelector(createTagsSelector());
|
const tagList = useTagList();
|
||||||
|
|
||||||
return <TagList tags={tags} tagList={tagList} />;
|
return <TagList tags={tags} tagList={tagList} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import {
|
||||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||||
import { fetchSeries } from 'Store/Actions/seriesActions';
|
import { fetchSeries } from 'Store/Actions/seriesActions';
|
||||||
import { fetchQualityDefinitions } from 'Store/Actions/settingsActions';
|
import { fetchQualityDefinitions } from 'Store/Actions/settingsActions';
|
||||||
import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions';
|
|
||||||
import { repopulatePage } from 'Utilities/pagePopulator';
|
import { repopulatePage } from 'Utilities/pagePopulator';
|
||||||
import SignalRLogger from 'Utilities/SignalRLogger';
|
import SignalRLogger from 'Utilities/SignalRLogger';
|
||||||
|
|
||||||
|
|
@ -303,11 +302,13 @@ function SignalRListener() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'tag') {
|
if (name === 'tag') {
|
||||||
if (body.action === 'sync') {
|
if (version < 5 || body.action !== 'sync') {
|
||||||
dispatch(fetchTags());
|
return;
|
||||||
dispatch(fetchTagDetails());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/tag'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/tag/detail'] });
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tag } from 'App/State/TagsAppState';
|
|
||||||
import { kinds } from 'Helpers/Props';
|
import { kinds } from 'Helpers/Props';
|
||||||
import { Kind } from 'Helpers/Props/kinds';
|
import { Kind } from 'Helpers/Props/kinds';
|
||||||
|
import { Tag } from 'Tags/useTags';
|
||||||
import sortByProp from 'Utilities/Array/sortByProp';
|
import sortByProp from 'Utilities/Array/sortByProp';
|
||||||
import Label, { LabelProps } from './Label';
|
import Label, { LabelProps } from './Label';
|
||||||
import styles from './TagList.css';
|
import styles from './TagList.css';
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,22 @@
|
||||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Error } from 'App/State/AppSectionState';
|
import { ValidationFailures } from 'Store/Selectors/selectSettings';
|
||||||
import fetchJson, { FetchJsonOptions } from 'Utilities/Fetch/fetchJson';
|
import {
|
||||||
|
ValidationError,
|
||||||
|
ValidationFailure,
|
||||||
|
ValidationWarning,
|
||||||
|
} from 'typings/pending';
|
||||||
|
import fetchJson, {
|
||||||
|
ApiError,
|
||||||
|
FetchJsonOptions,
|
||||||
|
} from 'Utilities/Fetch/fetchJson';
|
||||||
import getQueryPath from 'Utilities/Fetch/getQueryPath';
|
import getQueryPath from 'Utilities/Fetch/getQueryPath';
|
||||||
import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString';
|
import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString';
|
||||||
|
|
||||||
interface MutationOptions<T, TData>
|
interface MutationOptions<T, TData>
|
||||||
extends Omit<FetchJsonOptions<TData>, 'method'> {
|
extends Omit<FetchJsonOptions<TData>, 'method'> {
|
||||||
method: 'POST' | 'PUT' | 'DELETE';
|
method: 'POST' | 'PUT' | 'DELETE';
|
||||||
mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>;
|
mutationOptions?: Omit<UseMutationOptions<T, ApiError, TData>, 'mutationFn'>;
|
||||||
queryParams?: QueryParams;
|
queryParams?: QueryParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,7 +33,7 @@ function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
|
||||||
};
|
};
|
||||||
}, [options]);
|
}, [options]);
|
||||||
|
|
||||||
return useMutation<T, Error, TData>({
|
return useMutation<T, ApiError, TData>({
|
||||||
...options.mutationOptions,
|
...options.mutationOptions,
|
||||||
mutationFn: async (data?: TData) => {
|
mutationFn: async (data?: TData) => {
|
||||||
const { path, ...otherOptions } = requestOptions;
|
const { path, ...otherOptions } = requestOptions;
|
||||||
|
|
@ -36,3 +44,30 @@ function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useApiMutation;
|
export default useApiMutation;
|
||||||
|
|
||||||
|
export function getValidationFailures(
|
||||||
|
error?: ApiError | null
|
||||||
|
): ValidationFailures {
|
||||||
|
if (!error || error.statusCode !== 400) {
|
||||||
|
return {
|
||||||
|
errors: [],
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((error.statusBody ?? []) as ValidationFailure[]).reduce(
|
||||||
|
(acc: ValidationFailures, failure: ValidationFailure) => {
|
||||||
|
if (failure.isWarning) {
|
||||||
|
acc.warnings.push(failure as ValidationWarning);
|
||||||
|
} else {
|
||||||
|
acc.errors.push(failure as ValidationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
errors: [],
|
||||||
|
warnings: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,19 +12,20 @@ import {
|
||||||
fetchQualityProfiles,
|
fetchQualityProfiles,
|
||||||
fetchUISettings,
|
fetchUISettings,
|
||||||
} from 'Store/Actions/settingsActions';
|
} from 'Store/Actions/settingsActions';
|
||||||
import { fetchTags } from 'Store/Actions/tagActions';
|
|
||||||
import useSystemStatus from 'System/Status/useSystemStatus';
|
import useSystemStatus from 'System/Status/useSystemStatus';
|
||||||
|
import useTags from 'Tags/useTags';
|
||||||
import { ApiError } from 'Utilities/Fetch/fetchJson';
|
import { ApiError } from 'Utilities/Fetch/fetchJson';
|
||||||
|
|
||||||
const createErrorsSelector = ({
|
const createErrorsSelector = ({
|
||||||
systemStatusError,
|
systemStatusError,
|
||||||
|
tagsError,
|
||||||
}: {
|
}: {
|
||||||
systemStatusError: ApiError | null;
|
systemStatusError: ApiError | null;
|
||||||
|
tagsError: ApiError | null;
|
||||||
}) =>
|
}) =>
|
||||||
createSelector(
|
createSelector(
|
||||||
(state: AppState) => state.series.error,
|
(state: AppState) => state.series.error,
|
||||||
(state: AppState) => state.customFilters.error,
|
(state: AppState) => state.customFilters.error,
|
||||||
(state: AppState) => state.tags.error,
|
|
||||||
(state: AppState) => state.settings.ui.error,
|
(state: AppState) => state.settings.ui.error,
|
||||||
(state: AppState) => state.settings.qualityProfiles.error,
|
(state: AppState) => state.settings.qualityProfiles.error,
|
||||||
(state: AppState) => state.settings.languages.error,
|
(state: AppState) => state.settings.languages.error,
|
||||||
|
|
@ -34,7 +35,6 @@ const createErrorsSelector = ({
|
||||||
(
|
(
|
||||||
seriesError,
|
seriesError,
|
||||||
customFiltersError,
|
customFiltersError,
|
||||||
tagsError,
|
|
||||||
uiSettingsError,
|
uiSettingsError,
|
||||||
qualityProfilesError,
|
qualityProfilesError,
|
||||||
languagesError,
|
languagesError,
|
||||||
|
|
@ -45,13 +45,13 @@ const createErrorsSelector = ({
|
||||||
const hasError = !!(
|
const hasError = !!(
|
||||||
seriesError ||
|
seriesError ||
|
||||||
customFiltersError ||
|
customFiltersError ||
|
||||||
tagsError ||
|
|
||||||
uiSettingsError ||
|
uiSettingsError ||
|
||||||
qualityProfilesError ||
|
qualityProfilesError ||
|
||||||
languagesError ||
|
languagesError ||
|
||||||
importListsError ||
|
importListsError ||
|
||||||
indexerFlagsError ||
|
indexerFlagsError ||
|
||||||
systemStatusError ||
|
systemStatusError ||
|
||||||
|
tagsError ||
|
||||||
translationsError
|
translationsError
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -78,22 +78,25 @@ const useAppPage = () => {
|
||||||
const { isFetched: isSystemStatusFetched, error: systemStatusError } =
|
const { isFetched: isSystemStatusFetched, error: systemStatusError } =
|
||||||
useSystemStatus();
|
useSystemStatus();
|
||||||
|
|
||||||
|
const { isFetched: isTagsFetched, error: tagsError } = useTags();
|
||||||
|
|
||||||
|
const isAppStatePopulated = useSelector(
|
||||||
|
(state: AppState) =>
|
||||||
|
state.series.isPopulated &&
|
||||||
|
state.customFilters.isPopulated &&
|
||||||
|
state.settings.ui.isPopulated &&
|
||||||
|
state.settings.qualityProfiles.isPopulated &&
|
||||||
|
state.settings.languages.isPopulated &&
|
||||||
|
state.settings.importLists.isPopulated &&
|
||||||
|
state.settings.indexerFlags.isPopulated &&
|
||||||
|
state.app.translations.isPopulated
|
||||||
|
);
|
||||||
|
|
||||||
const isPopulated =
|
const isPopulated =
|
||||||
useSelector(
|
isAppStatePopulated && isSystemStatusFetched && isTagsFetched;
|
||||||
(state: AppState) =>
|
|
||||||
state.series.isPopulated &&
|
|
||||||
state.customFilters.isPopulated &&
|
|
||||||
state.tags.isPopulated &&
|
|
||||||
state.settings.ui.isPopulated &&
|
|
||||||
state.settings.qualityProfiles.isPopulated &&
|
|
||||||
state.settings.languages.isPopulated &&
|
|
||||||
state.settings.importLists.isPopulated &&
|
|
||||||
state.settings.indexerFlags.isPopulated &&
|
|
||||||
state.app.translations.isPopulated
|
|
||||||
) && isSystemStatusFetched;
|
|
||||||
|
|
||||||
const { hasError, errors } = useSelector(
|
const { hasError, errors } = useSelector(
|
||||||
createErrorsSelector({ systemStatusError })
|
createErrorsSelector({ systemStatusError, tagsError })
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLocalStorageSupported = useMemo(() => {
|
const isLocalStorageSupported = useMemo(() => {
|
||||||
|
|
@ -112,7 +115,6 @@ const useAppPage = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchSeries());
|
dispatch(fetchSeries());
|
||||||
dispatch(fetchCustomFilters());
|
dispatch(fetchCustomFilters());
|
||||||
dispatch(fetchTags());
|
|
||||||
dispatch(fetchQualityProfiles());
|
dispatch(fetchQualityProfiles());
|
||||||
dispatch(fetchLanguages());
|
dispatch(fetchLanguages());
|
||||||
dispatch(fetchImportLists());
|
dispatch(fetchImportLists());
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||||
import Label from 'Components/Label';
|
import Label from 'Components/Label';
|
||||||
import { kinds, sizes } from 'Helpers/Props';
|
import { kinds, sizes } from 'Helpers/Props';
|
||||||
import useSeries from 'Series/useSeries';
|
import useSeries from 'Series/useSeries';
|
||||||
import useTags from 'Tags/useTags';
|
import { useTagList } from 'Tags/useTags';
|
||||||
import sortByProp from 'Utilities/Array/sortByProp';
|
import sortByProp from 'Utilities/Array/sortByProp';
|
||||||
|
|
||||||
interface SeriesTagsProps {
|
interface SeriesTagsProps {
|
||||||
|
|
@ -11,7 +11,7 @@ interface SeriesTagsProps {
|
||||||
|
|
||||||
function SeriesTags({ seriesId }: SeriesTagsProps) {
|
function SeriesTags({ seriesId }: SeriesTagsProps) {
|
||||||
const series = useSeries(seriesId)!;
|
const series = useSeries(seriesId)!;
|
||||||
const tagList = useTags();
|
const tagList = useTagList();
|
||||||
|
|
||||||
const tags = series.tags
|
const tags = series.tags
|
||||||
.map((tagId) => tagList.find((tag) => tag.id === tagId))
|
.map((tagId) => tagList.find((tag) => tag.id === tagId))
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { uniq } from 'lodash';
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { useSelect } from 'App/Select/SelectContext';
|
import { useSelect } from 'App/Select/SelectContext';
|
||||||
import { Tag } from 'App/State/TagsAppState';
|
|
||||||
import Form from 'Components/Form/Form';
|
import Form from 'Components/Form/Form';
|
||||||
import FormGroup from 'Components/Form/FormGroup';
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
|
|
@ -17,7 +16,7 @@ import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||||
import Series from 'Series/Series';
|
import Series from 'Series/Series';
|
||||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
import { Tag, useTagList } from 'Tags/useTags';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import styles from './TagsModalContent.css';
|
import styles from './TagsModalContent.css';
|
||||||
|
|
||||||
|
|
@ -31,7 +30,7 @@ function TagsModalContent({
|
||||||
onApplyTagsPress,
|
onApplyTagsPress,
|
||||||
}: TagsModalContentProps) {
|
}: TagsModalContentProps) {
|
||||||
const allSeries: Series[] = useSelector(createAllSeriesSelector());
|
const allSeries: Series[] = useSelector(createAllSeriesSelector());
|
||||||
const tagList: Tag[] = useSelector(createTagsSelector());
|
const tagList: Tag[] = useTagList();
|
||||||
|
|
||||||
const [tags, setTags] = useState<number[]>([]);
|
const [tags, setTags] = useState<number[]>([]);
|
||||||
const [applyTags, setApplyTags] = useState('add');
|
const [applyTags, setApplyTags] = useState('add');
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||||
import TagList from 'Components/TagList';
|
import TagList from 'Components/TagList';
|
||||||
import { kinds } from 'Helpers/Props';
|
import { kinds } from 'Helpers/Props';
|
||||||
import { deleteDownloadClient } from 'Store/Actions/settingsActions';
|
import { deleteDownloadClient } from 'Store/Actions/settingsActions';
|
||||||
import useTags from 'Tags/useTags';
|
import { useTagList } from 'Tags/useTags';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import EditDownloadClientModal from './EditDownloadClientModal';
|
import EditDownloadClientModal from './EditDownloadClientModal';
|
||||||
import styles from './DownloadClient.css';
|
import styles from './DownloadClient.css';
|
||||||
|
|
@ -27,7 +27,7 @@ function DownloadClient({
|
||||||
tags,
|
tags,
|
||||||
}: DownloadClientProps) {
|
}: DownloadClientProps) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const tagList = useTags();
|
const tagList = useTagList();
|
||||||
|
|
||||||
const [isEditDownloadClientModalOpen, setIsEditDownloadClientModalOpen] =
|
const [isEditDownloadClientModalOpen, setIsEditDownloadClientModalOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import AppState from 'App/State/AppState';
|
import AppState from 'App/State/AppState';
|
||||||
import { DownloadClientAppState } from 'App/State/SettingsAppState';
|
import { DownloadClientAppState } from 'App/State/SettingsAppState';
|
||||||
import { Tag } from 'App/State/TagsAppState';
|
|
||||||
import Form from 'Components/Form/Form';
|
import Form from 'Components/Form/Form';
|
||||||
import FormGroup from 'Components/Form/FormGroup';
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
|
|
@ -16,7 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent';
|
||||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
import { Tag, useTagList } from 'Tags/useTags';
|
||||||
import DownloadClient from 'typings/DownloadClient';
|
import DownloadClient from 'typings/DownloadClient';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import styles from './TagsModalContent.css';
|
import styles from './TagsModalContent.css';
|
||||||
|
|
@ -33,7 +32,7 @@ function TagsModalContent(props: TagsModalContentProps) {
|
||||||
const allDownloadClients: DownloadClientAppState = useSelector(
|
const allDownloadClients: DownloadClientAppState = useSelector(
|
||||||
(state: AppState) => state.settings.downloadClients
|
(state: AppState) => state.settings.downloadClients
|
||||||
);
|
);
|
||||||
const tagList: Tag[] = useSelector(createTagsSelector());
|
const tagList: Tag[] = useTagList();
|
||||||
|
|
||||||
const [tags, setTags] = useState<number[]>([]);
|
const [tags, setTags] = useState<number[]>([]);
|
||||||
const [applyTags, setApplyTags] = useState('add');
|
const [applyTags, setApplyTags] = useState('add');
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||||
import TagList from 'Components/TagList';
|
import TagList from 'Components/TagList';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import { deleteImportList } from 'Store/Actions/settingsActions';
|
import { deleteImportList } from 'Store/Actions/settingsActions';
|
||||||
import useTags from 'Tags/useTags';
|
import { useTagList } from 'Tags/useTags';
|
||||||
import formatShortTimeSpan from 'Utilities/Date/formatShortTimeSpan';
|
import formatShortTimeSpan from 'Utilities/Date/formatShortTimeSpan';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import EditImportListModal from './EditImportListModal';
|
import EditImportListModal from './EditImportListModal';
|
||||||
|
|
@ -31,7 +31,7 @@ function ImportList({
|
||||||
onCloneImportListPress,
|
onCloneImportListPress,
|
||||||
}: ImportListProps) {
|
}: ImportListProps) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const tagList = useTags();
|
const tagList = useTagList();
|
||||||
|
|
||||||
const [isEditImportListModalOpen, setIsEditImportListModalOpen] =
|
const [isEditImportListModalOpen, setIsEditImportListModalOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import AppState from 'App/State/AppState';
|
import AppState from 'App/State/AppState';
|
||||||
import { ImportListAppState } from 'App/State/SettingsAppState';
|
import { ImportListAppState } from 'App/State/SettingsAppState';
|
||||||
import { Tag } from 'App/State/TagsAppState';
|
|
||||||
import Form from 'Components/Form/Form';
|
import Form from 'Components/Form/Form';
|
||||||
import FormGroup from 'Components/Form/FormGroup';
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
|
|
@ -16,7 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent';
|
||||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
import { Tag, useTagList } from 'Tags/useTags';
|
||||||
import ImportList from 'typings/ImportList';
|
import ImportList from 'typings/ImportList';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import styles from './TagsModalContent.css';
|
import styles from './TagsModalContent.css';
|
||||||
|
|
@ -33,7 +32,7 @@ function TagsModalContent(props: TagsModalContentProps) {
|
||||||
const allImportLists: ImportListAppState = useSelector(
|
const allImportLists: ImportListAppState = useSelector(
|
||||||
(state: AppState) => state.settings.importLists
|
(state: AppState) => state.settings.importLists
|
||||||
);
|
);
|
||||||
const tagList: Tag[] = useSelector(createTagsSelector());
|
const tagList: Tag[] = useTagList();
|
||||||
|
|
||||||
const [tags, setTags] = useState<number[]>([]);
|
const [tags, setTags] = useState<number[]>([]);
|
||||||
const [applyTags, setApplyTags] = useState('add');
|
const [applyTags, setApplyTags] = useState('add');
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import Card from 'Components/Card';
|
import Card from 'Components/Card';
|
||||||
import Label from 'Components/Label';
|
import Label from 'Components/Label';
|
||||||
import IconButton from 'Components/Link/IconButton';
|
import IconButton from 'Components/Link/IconButton';
|
||||||
|
|
@ -7,7 +7,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||||
import TagList from 'Components/TagList';
|
import TagList from 'Components/TagList';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import { deleteIndexer } from 'Store/Actions/settingsActions';
|
import { deleteIndexer } from 'Store/Actions/settingsActions';
|
||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
import { useTagList } from 'Tags/useTags';
|
||||||
import IndexerModel from 'typings/Indexer';
|
import IndexerModel from 'typings/Indexer';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import EditIndexerModal from './EditIndexerModal';
|
import EditIndexerModal from './EditIndexerModal';
|
||||||
|
|
@ -32,7 +32,7 @@ function Indexer({
|
||||||
onCloneIndexerPress,
|
onCloneIndexerPress,
|
||||||
}: IndexerProps) {
|
}: IndexerProps) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const tagList = useSelector(createTagsSelector());
|
const tagList = useTagList();
|
||||||
|
|
||||||
const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false);
|
const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false);
|
||||||
const [isDeleteIndexerModalOpen, setIsDeleteIndexerModalOpen] =
|
const [isDeleteIndexerModalOpen, setIsDeleteIndexerModalOpen] =
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import AppState from 'App/State/AppState';
|
import AppState from 'App/State/AppState';
|
||||||
import { IndexerAppState } from 'App/State/SettingsAppState';
|
import { IndexerAppState } from 'App/State/SettingsAppState';
|
||||||
import { Tag } from 'App/State/TagsAppState';
|
|
||||||
import Form from 'Components/Form/Form';
|
import Form from 'Components/Form/Form';
|
||||||
import FormGroup from 'Components/Form/FormGroup';
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
|
|
@ -16,7 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent';
|
||||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
import { Tag, useTagList } from 'Tags/useTags';
|
||||||
import Indexer from 'typings/Indexer';
|
import Indexer from 'typings/Indexer';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import styles from './TagsModalContent.css';
|
import styles from './TagsModalContent.css';
|
||||||
|
|
@ -33,7 +32,7 @@ function TagsModalContent(props: TagsModalContentProps) {
|
||||||
const allIndexers: IndexerAppState = useSelector(
|
const allIndexers: IndexerAppState = useSelector(
|
||||||
(state: AppState) => state.settings.indexers
|
(state: AppState) => state.settings.indexers
|
||||||
);
|
);
|
||||||
const tagList: Tag[] = useSelector(createTagsSelector());
|
const tagList: Tag[] = useTagList();
|
||||||
|
|
||||||
const [tags, setTags] = useState<number[]>([]);
|
const [tags, setTags] = useState<number[]>([]);
|
||||||
const [applyTags, setApplyTags] = useState('add');
|
const [applyTags, setApplyTags] = useState('add');
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||||
import TagList from 'Components/TagList';
|
import TagList from 'Components/TagList';
|
||||||
import { kinds } from 'Helpers/Props';
|
import { kinds } from 'Helpers/Props';
|
||||||
import { deleteNotification } from 'Store/Actions/settingsActions';
|
import { deleteNotification } from 'Store/Actions/settingsActions';
|
||||||
import useTags from 'Tags/useTags';
|
import { useTagList } from 'Tags/useTags';
|
||||||
import NotificationModel from 'typings/Notification';
|
import NotificationModel from 'typings/Notification';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import EditNotificationModal from './EditNotificationModal';
|
import EditNotificationModal from './EditNotificationModal';
|
||||||
|
|
@ -44,7 +44,7 @@ function Notification({
|
||||||
tags,
|
tags,
|
||||||
}: NotificationModel) {
|
}: NotificationModel) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const tagList = useTags();
|
const tagList = useTagList();
|
||||||
|
|
||||||
const [isEditNotificationModalOpen, setIsEditNotificationModalOpen] =
|
const [isEditNotificationModalOpen, setIsEditNotificationModalOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import classNames from 'classnames';
|
||||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { DragSourceMonitor, useDrag, useDrop, XYCoord } from 'react-dnd';
|
import { DragSourceMonitor, useDrag, useDrop, XYCoord } from 'react-dnd';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { Tag } from 'App/State/TagsAppState';
|
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
import Link from 'Components/Link/Link';
|
import Link from 'Components/Link/Link';
|
||||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||||
|
|
@ -10,6 +9,7 @@ import TagList from 'Components/TagList';
|
||||||
import DragType from 'Helpers/DragType';
|
import DragType from 'Helpers/DragType';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import { deleteDelayProfile } from 'Store/Actions/settingsActions';
|
import { deleteDelayProfile } from 'Store/Actions/settingsActions';
|
||||||
|
import { Tag } from 'Tags/useTags';
|
||||||
import titleCase from 'Utilities/String/titleCase';
|
import titleCase from 'Utilities/String/titleCase';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import EditDelayProfileModal from './EditDelayProfileModal';
|
import EditDelayProfileModal from './EditDelayProfileModal';
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
fetchDelayProfiles,
|
fetchDelayProfiles,
|
||||||
reorderDelayProfile,
|
reorderDelayProfile,
|
||||||
} from 'Store/Actions/settingsActions';
|
} from 'Store/Actions/settingsActions';
|
||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
import { useTagList } from 'Tags/useTags';
|
||||||
import DelayProfileModel from 'typings/DelayProfile';
|
import DelayProfileModel from 'typings/DelayProfile';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import DelayProfile from './DelayProfile';
|
import DelayProfile from './DelayProfile';
|
||||||
|
|
@ -60,7 +60,7 @@ function DelayProfiles() {
|
||||||
createDisplayProfilesSelector()
|
createDisplayProfilesSelector()
|
||||||
);
|
);
|
||||||
|
|
||||||
const tagList = useSelector(createTagsSelector());
|
const tagList = useTagList();
|
||||||
|
|
||||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { Tag } from 'App/State/TagsAppState';
|
|
||||||
import Card from 'Components/Card';
|
import Card from 'Components/Card';
|
||||||
import Label from 'Components/Label';
|
import Label from 'Components/Label';
|
||||||
import MiddleTruncate from 'Components/MiddleTruncate';
|
import MiddleTruncate from 'Components/MiddleTruncate';
|
||||||
|
|
@ -9,6 +8,7 @@ import TagList from 'Components/TagList';
|
||||||
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
|
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
|
||||||
import { kinds } from 'Helpers/Props';
|
import { kinds } from 'Helpers/Props';
|
||||||
import { deleteReleaseProfile } from 'Store/Actions/Settings/releaseProfiles';
|
import { deleteReleaseProfile } from 'Store/Actions/Settings/releaseProfiles';
|
||||||
|
import { Tag } from 'Tags/useTags';
|
||||||
import Indexer from 'typings/Indexer';
|
import Indexer from 'typings/Indexer';
|
||||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { icons } from 'Helpers/Props';
|
||||||
import { fetchIndexers } from 'Store/Actions/Settings/indexers';
|
import { fetchIndexers } from 'Store/Actions/Settings/indexers';
|
||||||
import { fetchReleaseProfiles } from 'Store/Actions/Settings/releaseProfiles';
|
import { fetchReleaseProfiles } from 'Store/Actions/Settings/releaseProfiles';
|
||||||
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
|
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
|
||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
import { useTagList } from 'Tags/useTags';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import EditReleaseProfileModal from './EditReleaseProfileModal';
|
import EditReleaseProfileModal from './EditReleaseProfileModal';
|
||||||
import ReleaseProfileItem from './ReleaseProfileItem';
|
import ReleaseProfileItem from './ReleaseProfileItem';
|
||||||
|
|
@ -21,7 +21,7 @@ function ReleaseProfiles() {
|
||||||
const { items, isFetching, isPopulated, error }: ReleaseProfilesAppState =
|
const { items, isFetching, isPopulated, error }: ReleaseProfilesAppState =
|
||||||
useSelector(createClientSideCollectionSelector('settings.releaseProfiles'));
|
useSelector(createClientSideCollectionSelector('settings.releaseProfiles'));
|
||||||
|
|
||||||
const tagList = useSelector(createTagsSelector());
|
const tagList = useTagList();
|
||||||
const indexerList = useSelector(
|
const indexerList = useSelector(
|
||||||
(state: AppState) => state.settings.indexers.items
|
(state: AppState) => state.settings.indexers.items
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { Tag } from 'App/State/TagsAppState';
|
|
||||||
import Card from 'Components/Card';
|
import Card from 'Components/Card';
|
||||||
import Label from 'Components/Label';
|
import Label from 'Components/Label';
|
||||||
import IconButton from 'Components/Link/IconButton';
|
import IconButton from 'Components/Link/IconButton';
|
||||||
|
|
@ -7,6 +6,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||||
import TagList from 'Components/TagList';
|
import TagList from 'Components/TagList';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import { Kind } from 'Helpers/Props/kinds';
|
import { Kind } from 'Helpers/Props/kinds';
|
||||||
|
import { Tag } from 'Tags/useTags';
|
||||||
import { AutoTaggingSpecification } from 'typings/AutoTagging';
|
import { AutoTaggingSpecification } from 'typings/AutoTagging';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import EditAutoTaggingModal from './EditAutoTaggingModal';
|
import EditAutoTaggingModal from './EditAutoTaggingModal';
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
fetchAutoTaggings,
|
fetchAutoTaggings,
|
||||||
} from 'Store/Actions/settingsActions';
|
} from 'Store/Actions/settingsActions';
|
||||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
import { useTagList } from 'Tags/useTags';
|
||||||
import AutoTaggingModel from 'typings/AutoTagging';
|
import AutoTaggingModel from 'typings/AutoTagging';
|
||||||
import sortByProp from 'Utilities/Array/sortByProp';
|
import sortByProp from 'Utilities/Array/sortByProp';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
@ -29,7 +29,7 @@ export default function AutoTaggings() {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const tagList = useSelector(createTagsSelector());
|
const tagList = useTagList();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
const [tagsFromId, setTagsFromId] = useState<number>();
|
const [tagsFromId, setTagsFromId] = useState<number>();
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import Card from 'Components/Card';
|
import Card from 'Components/Card';
|
||||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||||
import { kinds } from 'Helpers/Props';
|
import { kinds } from 'Helpers/Props';
|
||||||
import { deleteTag } from 'Store/Actions/tagActions';
|
import { useTagDetail } from 'Tags/useTagDetails';
|
||||||
import createTagDetailsSelector from 'Store/Selectors/createTagDetailsSelector';
|
import { useDeleteTag } from 'Tags/useTags';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import TagDetailsModal from './Details/TagDetailsModal';
|
import TagDetailsModal from './Details/TagDetailsModal';
|
||||||
import TagInUse from './TagInUse';
|
import TagInUse from './TagInUse';
|
||||||
|
|
@ -16,18 +15,18 @@ interface TagProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tag({ id, label }: TagProps) {
|
function Tag({ id, label }: TagProps) {
|
||||||
const dispatch = useDispatch();
|
const { deleteTag } = useDeleteTag(id);
|
||||||
const {
|
const {
|
||||||
delayProfileIds = [],
|
delayProfileIds,
|
||||||
importListIds = [],
|
importListIds,
|
||||||
notificationIds = [],
|
notificationIds,
|
||||||
restrictionIds = [],
|
restrictionIds,
|
||||||
excludedReleaseProfileIds = [],
|
excludedReleaseProfileIds,
|
||||||
indexerIds = [],
|
indexerIds,
|
||||||
downloadClientIds = [],
|
downloadClientIds,
|
||||||
autoTagIds = [],
|
autoTagIds,
|
||||||
seriesIds = [],
|
seriesIds,
|
||||||
} = useSelector(createTagDetailsSelector(id)) ?? {};
|
} = useTagDetail(id);
|
||||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||||
const [isDeleteTagModalOpen, setIsDeleteTagModalOpen] = useState(false);
|
const [isDeleteTagModalOpen, setIsDeleteTagModalOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -61,8 +60,8 @@ function Tag({ id, label }: TagProps) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleConfirmDeleteTag = useCallback(() => {
|
const handleConfirmDeleteTag = useCallback(() => {
|
||||||
dispatch(deleteTag({ id }));
|
deleteTag();
|
||||||
}, [id, dispatch]);
|
}, [deleteTag]);
|
||||||
|
|
||||||
const handleDeleteTagModalClose = useCallback(() => {
|
const handleDeleteTagModalClose = useCallback(() => {
|
||||||
setIsDeleteTagModalOpen(false);
|
setIsDeleteTagModalOpen(false);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import TagsAppState, { Tag as TagModel } from 'App/State/TagsAppState';
|
|
||||||
import Alert from 'Components/Alert';
|
import Alert from 'Components/Alert';
|
||||||
import FieldSet from 'Components/FieldSet';
|
import FieldSet from 'Components/FieldSet';
|
||||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||||
|
|
@ -13,31 +12,24 @@ import {
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
fetchReleaseProfiles,
|
fetchReleaseProfiles,
|
||||||
} from 'Store/Actions/settingsActions';
|
} from 'Store/Actions/settingsActions';
|
||||||
import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions';
|
import useTagDetails from 'Tags/useTagDetails';
|
||||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
import useTags, { useSortedTagList } from 'Tags/useTags';
|
||||||
import sortByProp from 'Utilities/Array/sortByProp';
|
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import Tag from './Tag';
|
import Tag from './Tag';
|
||||||
import styles from './Tags.css';
|
import styles from './Tags.css';
|
||||||
|
|
||||||
function Tags() {
|
function Tags() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { items, isFetching, isPopulated, error, details } = useSelector(
|
|
||||||
createSortedSectionSelector<TagModel, TagsAppState>(
|
|
||||||
'tags',
|
|
||||||
sortByProp('label')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const { isFetching, isFetched, error } = useTags();
|
||||||
|
const items = useSortedTagList();
|
||||||
const {
|
const {
|
||||||
isFetching: isDetailsFetching,
|
isFetching: isDetailsFetching,
|
||||||
isPopulated: isDetailsPopulated,
|
isFetched: isDetailsFetched,
|
||||||
error: detailsError,
|
error: detailsError,
|
||||||
} = details;
|
} = useTagDetails();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchTags());
|
|
||||||
dispatch(fetchTagDetails());
|
|
||||||
dispatch(fetchDelayProfiles());
|
dispatch(fetchDelayProfiles());
|
||||||
dispatch(fetchImportLists());
|
dispatch(fetchImportLists());
|
||||||
dispatch(fetchNotifications());
|
dispatch(fetchNotifications());
|
||||||
|
|
@ -58,7 +50,7 @@ function Tags() {
|
||||||
errorMessage={translate('TagsLoadError')}
|
errorMessage={translate('TagsLoadError')}
|
||||||
error={error || detailsError}
|
error={error || detailsError}
|
||||||
isFetching={isFetching || isDetailsFetching}
|
isFetching={isFetching || isDetailsFetching}
|
||||||
isPopulated={isPopulated || isDetailsPopulated}
|
isPopulated={isFetched && isDetailsFetched}
|
||||||
>
|
>
|
||||||
<div className={styles.tags}>
|
<div className={styles.tags}>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import * as series from './seriesActions';
|
||||||
import * as seriesHistory from './seriesHistoryActions';
|
import * as seriesHistory from './seriesHistoryActions';
|
||||||
import * as seriesIndex from './seriesIndexActions';
|
import * as seriesIndex from './seriesIndexActions';
|
||||||
import * as settings from './settingsActions';
|
import * as settings from './settingsActions';
|
||||||
import * as tags from './tagActions';
|
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
app,
|
app,
|
||||||
|
|
@ -40,6 +39,5 @@ export default [
|
||||||
series,
|
series,
|
||||||
seriesHistory,
|
seriesHistory,
|
||||||
seriesIndex,
|
seriesIndex,
|
||||||
settings,
|
settings
|
||||||
tags
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import { createThunk, handleThunks } from 'Store/thunks';
|
|
||||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
|
||||||
import { update } from './baseActions';
|
|
||||||
import createFetchHandler from './Creators/createFetchHandler';
|
|
||||||
import createHandleActions from './Creators/createHandleActions';
|
|
||||||
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
|
|
||||||
|
|
||||||
//
|
|
||||||
// Variables
|
|
||||||
|
|
||||||
export const section = 'tags';
|
|
||||||
|
|
||||||
//
|
|
||||||
// State
|
|
||||||
|
|
||||||
export const defaultState = {
|
|
||||||
isFetching: false,
|
|
||||||
isPopulated: false,
|
|
||||||
error: null,
|
|
||||||
items: [],
|
|
||||||
|
|
||||||
details: {
|
|
||||||
isFetching: false,
|
|
||||||
isPopulated: false,
|
|
||||||
error: null,
|
|
||||||
items: []
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Actions Types
|
|
||||||
|
|
||||||
export const FETCH_TAGS = 'tags/fetchTags';
|
|
||||||
export const ADD_TAG = 'tags/addTag';
|
|
||||||
export const DELETE_TAG = 'tags/deleteTag';
|
|
||||||
export const FETCH_TAG_DETAILS = 'tags/fetchTagDetails';
|
|
||||||
|
|
||||||
//
|
|
||||||
// Action Creators
|
|
||||||
|
|
||||||
export const fetchTags = createThunk(FETCH_TAGS);
|
|
||||||
export const addTag = createThunk(ADD_TAG);
|
|
||||||
export const deleteTag = createThunk(DELETE_TAG);
|
|
||||||
export const fetchTagDetails = createThunk(FETCH_TAG_DETAILS);
|
|
||||||
|
|
||||||
//
|
|
||||||
// Action Handlers
|
|
||||||
|
|
||||||
export const actionHandlers = handleThunks({
|
|
||||||
[FETCH_TAGS]: createFetchHandler(section, '/tag'),
|
|
||||||
|
|
||||||
[ADD_TAG]: function(getState, payload, dispatch) {
|
|
||||||
const promise = createAjaxRequest({
|
|
||||||
url: '/tag',
|
|
||||||
method: 'POST',
|
|
||||||
data: JSON.stringify(payload.tag),
|
|
||||||
dataType: 'json'
|
|
||||||
}).request;
|
|
||||||
|
|
||||||
promise.done((data) => {
|
|
||||||
const tags = getState().tags.items.slice();
|
|
||||||
tags.push(data);
|
|
||||||
|
|
||||||
dispatch(update({ section, data: tags }));
|
|
||||||
payload.onTagCreated(data);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
[DELETE_TAG]: createRemoveItemHandler(section, '/tag'),
|
|
||||||
[FETCH_TAG_DETAILS]: createFetchHandler('tags.details', '/tag/detail')
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
//
|
|
||||||
// Reducers
|
|
||||||
export const reducers = createHandleActions({}, defaultState, section);
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
|
|
||||||
function createTagDetailsSelector(id: number) {
|
|
||||||
return createSelector(
|
|
||||||
(state: AppState) => state.tags.details.items,
|
|
||||||
(tagDetails) => {
|
|
||||||
return tagDetails.find((t) => t.id === id);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default createTagDetailsSelector;
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import { Tag } from 'App/State/TagsAppState';
|
|
||||||
|
|
||||||
function createTagsSelector(): (state: AppState) => Tag[] {
|
|
||||||
return createSelector(
|
|
||||||
(state: AppState) => state.tags.items,
|
|
||||||
(tags) => {
|
|
||||||
return tags;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default createTagsSelector;
|
|
||||||
|
|
@ -12,12 +12,14 @@ import {
|
||||||
} from 'typings/pending';
|
} from 'typings/pending';
|
||||||
import isEmpty from 'Utilities/Object/isEmpty';
|
import isEmpty from 'Utilities/Object/isEmpty';
|
||||||
|
|
||||||
interface ValidationFailures {
|
export interface ValidationFailures {
|
||||||
errors: ValidationError[];
|
errors: ValidationError[];
|
||||||
warnings: ValidationWarning[];
|
warnings: ValidationWarning[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getValidationFailures(saveError?: Error | null): ValidationFailures {
|
export function getValidationFailures(
|
||||||
|
saveError?: Error | null
|
||||||
|
): ValidationFailures {
|
||||||
if (!saveError || saveError.status !== 400) {
|
if (!saveError || saveError.status !== 400) {
|
||||||
return {
|
return {
|
||||||
errors: [],
|
errors: [],
|
||||||
|
|
|
||||||
48
frontend/src/Tags/useTagDetails.ts
Normal file
48
frontend/src/Tags/useTagDetails.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import ModelBase from 'App/ModelBase';
|
||||||
|
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||||
|
|
||||||
|
const DEFAULT_TAG_DETAILS: TagDetail[] = [];
|
||||||
|
|
||||||
|
export interface TagDetail extends ModelBase {
|
||||||
|
label: string;
|
||||||
|
autoTagIds: number[];
|
||||||
|
delayProfileIds: number[];
|
||||||
|
downloadClientIds: [];
|
||||||
|
importListIds: number[];
|
||||||
|
indexerIds: number[];
|
||||||
|
notificationIds: number[];
|
||||||
|
restrictionIds: number[];
|
||||||
|
excludedReleaseProfileIds: number[];
|
||||||
|
seriesIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const useTagDetails = () => {
|
||||||
|
const { queryKey, ...result } = useApiQuery<TagDetail[]>({
|
||||||
|
path: '/tag/detail',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data ?? DEFAULT_TAG_DETAILS,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useTagDetails;
|
||||||
|
|
||||||
|
export const useTagDetail = (id: number) => {
|
||||||
|
const { data: tagDetails } = useTagDetails();
|
||||||
|
|
||||||
|
return (
|
||||||
|
tagDetails.find((tagDetail) => tagDetail.id === id) ?? {
|
||||||
|
delayProfileIds: [],
|
||||||
|
importListIds: [],
|
||||||
|
notificationIds: [],
|
||||||
|
restrictionIds: [],
|
||||||
|
excludedReleaseProfileIds: [],
|
||||||
|
indexerIds: [],
|
||||||
|
downloadClientIds: [],
|
||||||
|
autoTagIds: [],
|
||||||
|
seriesIds: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,8 +1,115 @@
|
||||||
import { useSelector } from 'react-redux';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
import { useMemo, useState } from 'react';
|
||||||
|
import ModelBase from 'App/ModelBase';
|
||||||
|
import useApiMutation, {
|
||||||
|
getValidationFailures,
|
||||||
|
} from 'Helpers/Hooks/useApiMutation';
|
||||||
|
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||||
|
import { ValidationFailures } from 'Store/Selectors/selectSettings';
|
||||||
|
import sortByProp from 'Utilities/Array/sortByProp';
|
||||||
|
|
||||||
|
const DEFAULT_TAGS: Tag[] = [];
|
||||||
|
|
||||||
|
export interface Tag extends ModelBase {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
const useTags = () => {
|
const useTags = () => {
|
||||||
return useSelector(createTagsSelector());
|
const { queryKey, ...result } = useApiQuery<Tag[]>({
|
||||||
|
path: '/tag',
|
||||||
|
queryOptions: {
|
||||||
|
gcTime: Infinity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data ?? DEFAULT_TAGS,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useTags;
|
export default useTags;
|
||||||
|
|
||||||
|
export const useTagList = () => {
|
||||||
|
const { data: tags } = useTags();
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSortedTagList = () => {
|
||||||
|
const tagList = useTagList();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return tagList.sort(sortByProp('label'));
|
||||||
|
}, [tagList]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAddTag = (onTagCreated?: (tag: Tag) => void) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [error, setError] = useState<ValidationFailures | null>(null);
|
||||||
|
|
||||||
|
const { mutate, isPending } = useApiMutation<Tag, Pick<Tag, 'label'>>({
|
||||||
|
path: '/tag',
|
||||||
|
method: 'POST',
|
||||||
|
mutationOptions: {
|
||||||
|
onMutate: () => {
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData<Tag[]>(['tag'], (oldData) => {
|
||||||
|
if (!oldData) {
|
||||||
|
return oldData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...oldData, data];
|
||||||
|
});
|
||||||
|
|
||||||
|
onTagCreated?.(data);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const validationFailures = getValidationFailures(error);
|
||||||
|
|
||||||
|
setError(validationFailures);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
addTag: mutate,
|
||||||
|
isAddingTag: isPending,
|
||||||
|
addTagError: error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteTag = (id: number) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { mutate, isPending } = useApiMutation<Tag, void>({
|
||||||
|
path: `/tag/${id}`,
|
||||||
|
method: 'DELETE',
|
||||||
|
mutationOptions: {
|
||||||
|
onMutate: () => {
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.setQueryData<Tag[]>(['tag'], (oldData) => {
|
||||||
|
if (!oldData) {
|
||||||
|
return oldData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return oldData.filter((tag) => tag.id === id);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setError('Error deleting tag');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleteTag: mutate,
|
||||||
|
isDeletingTag: isPending,
|
||||||
|
deleteTagError: error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue