mirror of
https://github.com/Sonarr/Sonarr
synced 2025-12-06 08:28:37 +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 ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { getValidationFailures } from 'Helpers/Hooks/useApiMutation';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import { SeriesType } from 'Series/Series';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
|
|
@ -50,7 +51,10 @@ function AddNewSeriesModalContent({
|
|||
const { isAdding, addError, addSeries } = useAddSeries();
|
||||
|
||||
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
||||
return selectSettings(options, {}, addError);
|
||||
return {
|
||||
...selectSettings(options, {}),
|
||||
...getValidationFailures(addError),
|
||||
};
|
||||
}, [options, addError]);
|
||||
|
||||
const [seriesType, setSeriesType] = useState<SeriesType>(
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import ReleasesAppState from './ReleasesAppState';
|
|||
import RootFolderAppState from './RootFolderAppState';
|
||||
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
|
||||
import SettingsAppState from './SettingsAppState';
|
||||
import TagsAppState from './TagsAppState';
|
||||
|
||||
export interface FilterBuilderPropOption {
|
||||
id: string;
|
||||
|
|
@ -97,7 +96,6 @@ interface AppState {
|
|||
seriesHistory: SeriesHistoryAppState;
|
||||
seriesIndex: SeriesIndexAppState;
|
||||
settings: SettingsAppState;
|
||||
tags: TagsAppState;
|
||||
}
|
||||
|
||||
export default AppState;
|
||||
|
|
|
|||
|
|
@ -1,25 +1,9 @@
|
|||
import ModelBase from 'App/ModelBase';
|
||||
import AppSectionState, {
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState,
|
||||
} from 'App/State/AppSectionState';
|
||||
|
||||
export interface Tag extends ModelBase {
|
||||
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[];
|
||||
}
|
||||
import { TagDetail } from 'Tags/useTagDetails';
|
||||
import { Tag } from 'Tags/useTags';
|
||||
|
||||
export interface TagDetailAppState
|
||||
extends AppSectionState<TagDetail>,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import { useTagList } from 'Tags/useTags';
|
||||
import FilterBuilderRowValue, {
|
||||
FilterBuilderRowValueProps,
|
||||
} from './FilterBuilderRowValue';
|
||||
|
|
@ -11,7 +10,7 @@ type TagFilterBuilderRowValueProps<T> = Omit<
|
|||
>;
|
||||
|
||||
function TagFilterBuilderRowValue<T>(props: TagFilterBuilderRowValueProps<T>) {
|
||||
const tags = useSelector(createTagsSelector());
|
||||
const tags = useTagList();
|
||||
|
||||
const tagList = useMemo(() => {
|
||||
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 { inputTypes } from 'Helpers/Props';
|
||||
import { InputType } from 'Helpers/Props/inputTypes';
|
||||
|
|
@ -9,6 +9,7 @@ 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';
|
||||
|
|
@ -206,11 +207,27 @@ function FormInputGroup<T, C extends InputType>(
|
|||
helpTextWarning,
|
||||
helpLink,
|
||||
pending,
|
||||
errors = [],
|
||||
warnings = [],
|
||||
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;
|
||||
|
|
@ -220,44 +237,48 @@ function FormInputGroup<T, C extends InputType>(
|
|||
const hasButton = !!buttonsArray.length;
|
||||
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
{unit && (
|
||||
<div
|
||||
className={
|
||||
type === inputTypes.NUMBER
|
||||
? styles.inputUnitNumber
|
||||
: styles.inputUnit
|
||||
}
|
||||
>
|
||||
{unit}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{buttonsArray.map((button, index) => {
|
||||
if (!React.isValidElement<FormInputButtonProps>(button)) {
|
||||
return button;
|
||||
}
|
||||
{buttonsArray.map((button, index) => {
|
||||
if (!React.isValidElement<FormInputButtonProps>(button)) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return React.cloneElement(button, {
|
||||
isLastButton: index === lastButtonIndex,
|
||||
});
|
||||
})}
|
||||
return React.cloneElement(button, {
|
||||
isLastButton: index === lastButtonIndex,
|
||||
});
|
||||
})}
|
||||
|
||||
{/* <div className={styles.pendingChangesContainer}>
|
||||
{/* <div className={styles.pendingChangesContainer}>
|
||||
{
|
||||
pending &&
|
||||
<Icon
|
||||
|
|
@ -267,70 +288,71 @@ function FormInputGroup<T, C extends InputType>(
|
|||
/>
|
||||
}
|
||||
</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}
|
||||
{!checkInput && helpText ? <FormInputHelpText text={helpText} /> : 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) => {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(!checkInput || helpText) && helpTextWarning ? (
|
||||
<FormInputHelpText text={helpTextWarning} isWarning={true} />
|
||||
) : null}
|
||||
|
||||
{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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
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 { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { addTag } from 'Store/Actions/tagActions';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Tag, useAddTag, useSortedTagList } from 'Tags/useTags';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import { useFormInputGroup } from '../FormInputGroupContext';
|
||||
import TagInput, { TagBase, TagInputProps } from './TagInput';
|
||||
|
||||
interface SeriesTag extends TagBase {
|
||||
|
|
@ -22,45 +19,33 @@ export interface SeriesTagInputProps<V>
|
|||
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) {
|
||||
try {
|
||||
return !VALID_TAG_REGEX.test(tagName);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return {
|
||||
tags: tags.reduce((acc: SeriesTag[], tag) => {
|
||||
const matchingTag = sortedTags.find((t) => t.id === tag);
|
||||
|
||||
function createSeriesTagsSelector(tags: number[]) {
|
||||
return createSelector(createTagsSelector(), (tagList) => {
|
||||
const sortedTags = tagList.sort(sortByProp('label'));
|
||||
const filteredTagList = sortedTags.filter((tag) => !tags.includes(tag.id));
|
||||
if (matchingTag) {
|
||||
acc.push({
|
||||
id: tag,
|
||||
name: matchingTag.label,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tags: tags.reduce((acc: SeriesTag[], tag) => {
|
||||
const matchingTag = tagList.find((t) => t.id === tag);
|
||||
return acc;
|
||||
}, []),
|
||||
|
||||
if (matchingTag) {
|
||||
acc.push({
|
||||
id: tag,
|
||||
name: matchingTag.label,
|
||||
});
|
||||
}
|
||||
tagList: filteredTagList.map(({ id, label: name }) => {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
};
|
||||
}),
|
||||
|
||||
return acc;
|
||||
}, []),
|
||||
|
||||
tagList: filteredTagList.map(({ id, label: name }) => {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
};
|
||||
}),
|
||||
|
||||
allTags: sortedTags,
|
||||
};
|
||||
});
|
||||
allTags: sortedTags,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SeriesTagInput<V extends number | number[]>({
|
||||
|
|
@ -69,7 +54,7 @@ export default function SeriesTagInput<V extends number | number[]>({
|
|||
onChange,
|
||||
...otherProps
|
||||
}: SeriesTagInputProps<V>) {
|
||||
const dispatch = useDispatch();
|
||||
const formInputActions = useFormInputGroup();
|
||||
const isArray = Array.isArray(value);
|
||||
|
||||
const arrayValue = useMemo(() => {
|
||||
|
|
@ -80,12 +65,10 @@ export default function SeriesTagInput<V extends number | number[]>({
|
|||
return value === 0 ? [] : [value as number];
|
||||
}, [isArray, value]);
|
||||
|
||||
const { tags, tagList, allTags } = useSelector(
|
||||
createSeriesTagsSelector(arrayValue)
|
||||
);
|
||||
const { tags, tagList, allTags } = useSeriesTags(arrayValue);
|
||||
|
||||
const handleTagCreated = useCallback(
|
||||
(tag: SeriesTag) => {
|
||||
(tag: Tag) => {
|
||||
if (isArray) {
|
||||
onChange({ name, value: [...value, tag.id] as V });
|
||||
} else {
|
||||
|
|
@ -98,6 +81,8 @@ export default function SeriesTagInput<V extends number | number[]>({
|
|||
[name, value, isArray, onChange]
|
||||
);
|
||||
|
||||
const { addTag, addTagError } = useAddTag(handleTagCreated);
|
||||
|
||||
const handleTagAdd = useCallback(
|
||||
(newTag: SeriesTag) => {
|
||||
if (newTag.id) {
|
||||
|
|
@ -112,16 +97,13 @@ export default function SeriesTagInput<V extends number | number[]>({
|
|||
|
||||
const existingTag = allTags.some((t) => t.label === newTag.name);
|
||||
|
||||
if (isValidTag(newTag.name) && !existingTag) {
|
||||
dispatch(
|
||||
addTag({
|
||||
tag: { label: newTag.name },
|
||||
onTagCreated: handleTagCreated,
|
||||
})
|
||||
);
|
||||
if (!existingTag) {
|
||||
addTag({
|
||||
label: newTag.name,
|
||||
});
|
||||
}
|
||||
},
|
||||
[name, value, isArray, allTags, handleTagCreated, onChange, dispatch]
|
||||
[name, value, isArray, allTags, onChange, addTag]
|
||||
);
|
||||
|
||||
const handleTagDelete = useCallback(
|
||||
|
|
@ -138,6 +120,15 @@ export default function SeriesTagInput<V extends number | number[]>({
|
|||
[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 (
|
||||
<TagInput
|
||||
{...otherProps}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ interface ErrorPageProps {
|
|||
translationsError?: Error;
|
||||
seriesError?: Error;
|
||||
customFiltersError?: Error;
|
||||
tagsError?: Error;
|
||||
tagsError: ApiError | null;
|
||||
qualityProfilesError?: Error;
|
||||
uiSettingsError?: Error;
|
||||
systemStatusError: ApiError | null;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import Autosuggest from 'react-autosuggest';
|
|||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import Icon from 'Components/Icon';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
|
||||
|
|
@ -22,7 +21,7 @@ import { icons } from 'Helpers/Props';
|
|||
import Series from 'Series/Series';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
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 SeriesSearchResult from './SeriesSearchResult';
|
||||
import styles from './SeriesSearchInput.css';
|
||||
|
|
@ -70,60 +69,57 @@ interface Section {
|
|||
suggestions: SeriesSuggestion[] | AddNewSeriesSuggestion[];
|
||||
}
|
||||
|
||||
function createUnoptimizedSelector() {
|
||||
return createSelector(
|
||||
createAllSeriesSelector(),
|
||||
createTagsSelector(),
|
||||
(allSeries, allTags) => {
|
||||
return allSeries.map((series): SuggestedSeries => {
|
||||
const {
|
||||
title,
|
||||
titleSlug,
|
||||
sortTitle,
|
||||
images,
|
||||
alternateTitles = [],
|
||||
tvdbId,
|
||||
tvMazeId,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
tags = [],
|
||||
} = series;
|
||||
function createUnoptimizedSelector(tagList: Tag[]) {
|
||||
return createSelector(createAllSeriesSelector(), (allSeries) => {
|
||||
return allSeries.map((series): SuggestedSeries => {
|
||||
const {
|
||||
title,
|
||||
titleSlug,
|
||||
sortTitle,
|
||||
images,
|
||||
alternateTitles = [],
|
||||
tvdbId,
|
||||
tvMazeId,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
tags = [],
|
||||
} = series;
|
||||
|
||||
return {
|
||||
title,
|
||||
titleSlug,
|
||||
sortTitle,
|
||||
images,
|
||||
alternateTitles,
|
||||
tvdbId,
|
||||
tvMazeId,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
firstCharacter: title.charAt(0).toLowerCase(),
|
||||
tags: tags.reduce<Tag[]>((acc, id) => {
|
||||
const matchingTag = allTags.find((tag) => tag.id === id);
|
||||
return {
|
||||
title,
|
||||
titleSlug,
|
||||
sortTitle,
|
||||
images,
|
||||
alternateTitles,
|
||||
tvdbId,
|
||||
tvMazeId,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
firstCharacter: title.charAt(0).toLowerCase(),
|
||||
tags: tags.reduce<Tag[]>((acc, id) => {
|
||||
const matchingTag = tagList.find((tag) => tag.id === id);
|
||||
|
||||
if (matchingTag) {
|
||||
acc.push(matchingTag);
|
||||
}
|
||||
if (matchingTag) {
|
||||
acc.push(matchingTag);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []),
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
return acc;
|
||||
}, []),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createSeriesSelector() {
|
||||
function createSeriesSelector(tagList: Tag[]) {
|
||||
return createDeepEqualSelector(
|
||||
createUnoptimizedSelector(),
|
||||
createUnoptimizedSelector(tagList),
|
||||
(series) => series
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesSearchInput() {
|
||||
const series = useSelector(createSeriesSelector());
|
||||
const tagList = useTagList();
|
||||
const series = useSelector(createSeriesSelector(tagList));
|
||||
const dispatch = useDispatch();
|
||||
const { bindShortcut, unbindShortcut } = useKeyboardShortcuts();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import { Tag } from 'Tags/useTags';
|
||||
import { SuggestedSeries } from './SeriesSearchInput';
|
||||
import styles from './SeriesSearchResult.css';
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ import { Error } from 'App/State/AppSectionState';
|
|||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { ApiError } from 'Utilities/Fetch/fetchJson';
|
||||
|
||||
interface PageSectionContentProps {
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
error?: Error;
|
||||
error?: Error | ApiError | null;
|
||||
errorMessage: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import { useTagList } from 'Tags/useTags';
|
||||
import TagList from './TagList';
|
||||
|
||||
interface SeriesTagListProps {
|
||||
|
|
@ -8,7 +7,7 @@ interface SeriesTagListProps {
|
|||
}
|
||||
|
||||
function SeriesTagList({ tags }: SeriesTagListProps) {
|
||||
const tagList = useSelector(createTagsSelector());
|
||||
const tagList = useTagList();
|
||||
|
||||
return <TagList tags={tags} tagList={tagList} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import {
|
|||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import { fetchSeries } from 'Store/Actions/seriesActions';
|
||||
import { fetchQualityDefinitions } from 'Store/Actions/settingsActions';
|
||||
import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions';
|
||||
import { repopulatePage } from 'Utilities/pagePopulator';
|
||||
import SignalRLogger from 'Utilities/SignalRLogger';
|
||||
|
||||
|
|
@ -303,11 +302,13 @@ function SignalRListener() {
|
|||
}
|
||||
|
||||
if (name === 'tag') {
|
||||
if (body.action === 'sync') {
|
||||
dispatch(fetchTags());
|
||||
dispatch(fetchTagDetails());
|
||||
if (version < 5 || body.action !== 'sync') {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['/tag'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/tag/detail'] });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import { Tag } from 'Tags/useTags';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import Label, { LabelProps } from './Label';
|
||||
import styles from './TagList.css';
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { Error } from 'App/State/AppSectionState';
|
||||
import fetchJson, { FetchJsonOptions } from 'Utilities/Fetch/fetchJson';
|
||||
import { ValidationFailures } from 'Store/Selectors/selectSettings';
|
||||
import {
|
||||
ValidationError,
|
||||
ValidationFailure,
|
||||
ValidationWarning,
|
||||
} from 'typings/pending';
|
||||
import fetchJson, {
|
||||
ApiError,
|
||||
FetchJsonOptions,
|
||||
} from 'Utilities/Fetch/fetchJson';
|
||||
import getQueryPath from 'Utilities/Fetch/getQueryPath';
|
||||
import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString';
|
||||
|
||||
interface MutationOptions<T, TData>
|
||||
extends Omit<FetchJsonOptions<TData>, 'method'> {
|
||||
method: 'POST' | 'PUT' | 'DELETE';
|
||||
mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>;
|
||||
mutationOptions?: Omit<UseMutationOptions<T, ApiError, TData>, 'mutationFn'>;
|
||||
queryParams?: QueryParams;
|
||||
}
|
||||
|
||||
|
|
@ -25,7 +33,7 @@ function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
|
|||
};
|
||||
}, [options]);
|
||||
|
||||
return useMutation<T, Error, TData>({
|
||||
return useMutation<T, ApiError, TData>({
|
||||
...options.mutationOptions,
|
||||
mutationFn: async (data?: TData) => {
|
||||
const { path, ...otherOptions } = requestOptions;
|
||||
|
|
@ -36,3 +44,30 @@ function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
|
|||
}
|
||||
|
||||
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,
|
||||
fetchUISettings,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import { fetchTags } from 'Store/Actions/tagActions';
|
||||
import useSystemStatus from 'System/Status/useSystemStatus';
|
||||
import useTags from 'Tags/useTags';
|
||||
import { ApiError } from 'Utilities/Fetch/fetchJson';
|
||||
|
||||
const createErrorsSelector = ({
|
||||
systemStatusError,
|
||||
tagsError,
|
||||
}: {
|
||||
systemStatusError: ApiError | null;
|
||||
tagsError: ApiError | null;
|
||||
}) =>
|
||||
createSelector(
|
||||
(state: AppState) => state.series.error,
|
||||
(state: AppState) => state.customFilters.error,
|
||||
(state: AppState) => state.tags.error,
|
||||
(state: AppState) => state.settings.ui.error,
|
||||
(state: AppState) => state.settings.qualityProfiles.error,
|
||||
(state: AppState) => state.settings.languages.error,
|
||||
|
|
@ -34,7 +35,6 @@ const createErrorsSelector = ({
|
|||
(
|
||||
seriesError,
|
||||
customFiltersError,
|
||||
tagsError,
|
||||
uiSettingsError,
|
||||
qualityProfilesError,
|
||||
languagesError,
|
||||
|
|
@ -45,13 +45,13 @@ const createErrorsSelector = ({
|
|||
const hasError = !!(
|
||||
seriesError ||
|
||||
customFiltersError ||
|
||||
tagsError ||
|
||||
uiSettingsError ||
|
||||
qualityProfilesError ||
|
||||
languagesError ||
|
||||
importListsError ||
|
||||
indexerFlagsError ||
|
||||
systemStatusError ||
|
||||
tagsError ||
|
||||
translationsError
|
||||
);
|
||||
|
||||
|
|
@ -78,22 +78,25 @@ const useAppPage = () => {
|
|||
const { isFetched: isSystemStatusFetched, error: systemStatusError } =
|
||||
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 =
|
||||
useSelector(
|
||||
(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;
|
||||
isAppStatePopulated && isSystemStatusFetched && isTagsFetched;
|
||||
|
||||
const { hasError, errors } = useSelector(
|
||||
createErrorsSelector({ systemStatusError })
|
||||
createErrorsSelector({ systemStatusError, tagsError })
|
||||
);
|
||||
|
||||
const isLocalStorageSupported = useMemo(() => {
|
||||
|
|
@ -112,7 +115,6 @@ const useAppPage = () => {
|
|||
useEffect(() => {
|
||||
dispatch(fetchSeries());
|
||||
dispatch(fetchCustomFilters());
|
||||
dispatch(fetchTags());
|
||||
dispatch(fetchQualityProfiles());
|
||||
dispatch(fetchLanguages());
|
||||
dispatch(fetchImportLists());
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import Label from 'Components/Label';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import useTags from 'Tags/useTags';
|
||||
import { useTagList } from 'Tags/useTags';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
|
||||
interface SeriesTagsProps {
|
||||
|
|
@ -11,7 +11,7 @@ interface SeriesTagsProps {
|
|||
|
||||
function SeriesTags({ seriesId }: SeriesTagsProps) {
|
||||
const series = useSeries(seriesId)!;
|
||||
const tagList = useTags();
|
||||
const tagList = useTagList();
|
||||
|
||||
const tags = series.tags
|
||||
.map((tagId) => tagList.find((tag) => tag.id === tagId))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { uniq } from 'lodash';
|
|||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSelect } from 'App/Select/SelectContext';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
|
|
@ -17,7 +16,7 @@ import ModalHeader from 'Components/Modal/ModalHeader';
|
|||
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||
import Series from 'Series/Series';
|
||||
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 styles from './TagsModalContent.css';
|
||||
|
||||
|
|
@ -31,7 +30,7 @@ function TagsModalContent({
|
|||
onApplyTagsPress,
|
||||
}: TagsModalContentProps) {
|
||||
const allSeries: Series[] = useSelector(createAllSeriesSelector());
|
||||
const tagList: Tag[] = useSelector(createTagsSelector());
|
||||
const tagList: Tag[] = useTagList();
|
||||
|
||||
const [tags, setTags] = useState<number[]>([]);
|
||||
const [applyTags, setApplyTags] = useState('add');
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
|
|||
import TagList from 'Components/TagList';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { deleteDownloadClient } from 'Store/Actions/settingsActions';
|
||||
import useTags from 'Tags/useTags';
|
||||
import { useTagList } from 'Tags/useTags';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditDownloadClientModal from './EditDownloadClientModal';
|
||||
import styles from './DownloadClient.css';
|
||||
|
|
@ -27,7 +27,7 @@ function DownloadClient({
|
|||
tags,
|
||||
}: DownloadClientProps) {
|
||||
const dispatch = useDispatch();
|
||||
const tagList = useTags();
|
||||
const tagList = useTagList();
|
||||
|
||||
const [isEditDownloadClientModalOpen, setIsEditDownloadClientModalOpen] =
|
||||
useState(false);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import React, { useCallback, useMemo, useState } from 'react';
|
|||
import { useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { DownloadClientAppState } from 'App/State/SettingsAppState';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
|
|
@ -16,7 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent';
|
|||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
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 translate from 'Utilities/String/translate';
|
||||
import styles from './TagsModalContent.css';
|
||||
|
|
@ -33,7 +32,7 @@ function TagsModalContent(props: TagsModalContentProps) {
|
|||
const allDownloadClients: DownloadClientAppState = useSelector(
|
||||
(state: AppState) => state.settings.downloadClients
|
||||
);
|
||||
const tagList: Tag[] = useSelector(createTagsSelector());
|
||||
const tagList: Tag[] = useTagList();
|
||||
|
||||
const [tags, setTags] = useState<number[]>([]);
|
||||
const [applyTags, setApplyTags] = useState('add');
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
|
|||
import TagList from 'Components/TagList';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import { deleteImportList } from 'Store/Actions/settingsActions';
|
||||
import useTags from 'Tags/useTags';
|
||||
import { useTagList } from 'Tags/useTags';
|
||||
import formatShortTimeSpan from 'Utilities/Date/formatShortTimeSpan';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditImportListModal from './EditImportListModal';
|
||||
|
|
@ -31,7 +31,7 @@ function ImportList({
|
|||
onCloneImportListPress,
|
||||
}: ImportListProps) {
|
||||
const dispatch = useDispatch();
|
||||
const tagList = useTags();
|
||||
const tagList = useTagList();
|
||||
|
||||
const [isEditImportListModalOpen, setIsEditImportListModalOpen] =
|
||||
useState(false);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import React, { useCallback, useMemo, useState } from 'react';
|
|||
import { useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { ImportListAppState } from 'App/State/SettingsAppState';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
|
|
@ -16,7 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent';
|
|||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
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 translate from 'Utilities/String/translate';
|
||||
import styles from './TagsModalContent.css';
|
||||
|
|
@ -33,7 +32,7 @@ function TagsModalContent(props: TagsModalContentProps) {
|
|||
const allImportLists: ImportListAppState = useSelector(
|
||||
(state: AppState) => state.settings.importLists
|
||||
);
|
||||
const tagList: Tag[] = useSelector(createTagsSelector());
|
||||
const tagList: Tag[] = useTagList();
|
||||
|
||||
const [tags, setTags] = useState<number[]>([]);
|
||||
const [applyTags, setApplyTags] = useState('add');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
|
|
@ -7,7 +7,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
|
|||
import TagList from 'Components/TagList';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import { deleteIndexer } from 'Store/Actions/settingsActions';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import { useTagList } from 'Tags/useTags';
|
||||
import IndexerModel from 'typings/Indexer';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditIndexerModal from './EditIndexerModal';
|
||||
|
|
@ -32,7 +32,7 @@ function Indexer({
|
|||
onCloneIndexerPress,
|
||||
}: IndexerProps) {
|
||||
const dispatch = useDispatch();
|
||||
const tagList = useSelector(createTagsSelector());
|
||||
const tagList = useTagList();
|
||||
|
||||
const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false);
|
||||
const [isDeleteIndexerModalOpen, setIsDeleteIndexerModalOpen] =
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import React, { useCallback, useMemo, useState } from 'react';
|
|||
import { useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { IndexerAppState } from 'App/State/SettingsAppState';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
|
|
@ -16,7 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent';
|
|||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
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 translate from 'Utilities/String/translate';
|
||||
import styles from './TagsModalContent.css';
|
||||
|
|
@ -33,7 +32,7 @@ function TagsModalContent(props: TagsModalContentProps) {
|
|||
const allIndexers: IndexerAppState = useSelector(
|
||||
(state: AppState) => state.settings.indexers
|
||||
);
|
||||
const tagList: Tag[] = useSelector(createTagsSelector());
|
||||
const tagList: Tag[] = useTagList();
|
||||
|
||||
const [tags, setTags] = useState<number[]>([]);
|
||||
const [applyTags, setApplyTags] = useState('add');
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
|
|||
import TagList from 'Components/TagList';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { deleteNotification } from 'Store/Actions/settingsActions';
|
||||
import useTags from 'Tags/useTags';
|
||||
import { useTagList } from 'Tags/useTags';
|
||||
import NotificationModel from 'typings/Notification';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditNotificationModal from './EditNotificationModal';
|
||||
|
|
@ -44,7 +44,7 @@ function Notification({
|
|||
tags,
|
||||
}: NotificationModel) {
|
||||
const dispatch = useDispatch();
|
||||
const tagList = useTags();
|
||||
const tagList = useTagList();
|
||||
|
||||
const [isEditNotificationModalOpen, setIsEditNotificationModalOpen] =
|
||||
useState(false);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import classNames from 'classnames';
|
|||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { DragSourceMonitor, useDrag, useDrop, XYCoord } from 'react-dnd';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
|
|
@ -10,6 +9,7 @@ import TagList from 'Components/TagList';
|
|||
import DragType from 'Helpers/DragType';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import { deleteDelayProfile } from 'Store/Actions/settingsActions';
|
||||
import { Tag } from 'Tags/useTags';
|
||||
import titleCase from 'Utilities/String/titleCase';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditDelayProfileModal from './EditDelayProfileModal';
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
fetchDelayProfiles,
|
||||
reorderDelayProfile,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import { useTagList } from 'Tags/useTags';
|
||||
import DelayProfileModel from 'typings/DelayProfile';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import DelayProfile from './DelayProfile';
|
||||
|
|
@ -60,7 +60,7 @@ function DelayProfiles() {
|
|||
createDisplayProfilesSelector()
|
||||
);
|
||||
|
||||
const tagList = useSelector(createTagsSelector());
|
||||
const tagList = useTagList();
|
||||
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import MiddleTruncate from 'Components/MiddleTruncate';
|
||||
|
|
@ -9,6 +8,7 @@ import TagList from 'Components/TagList';
|
|||
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { deleteReleaseProfile } from 'Store/Actions/Settings/releaseProfiles';
|
||||
import { Tag } from 'Tags/useTags';
|
||||
import Indexer from 'typings/Indexer';
|
||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { icons } from 'Helpers/Props';
|
|||
import { fetchIndexers } from 'Store/Actions/Settings/indexers';
|
||||
import { fetchReleaseProfiles } from 'Store/Actions/Settings/releaseProfiles';
|
||||
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import { useTagList } from 'Tags/useTags';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditReleaseProfileModal from './EditReleaseProfileModal';
|
||||
import ReleaseProfileItem from './ReleaseProfileItem';
|
||||
|
|
@ -21,7 +21,7 @@ function ReleaseProfiles() {
|
|||
const { items, isFetching, isPopulated, error }: ReleaseProfilesAppState =
|
||||
useSelector(createClientSideCollectionSelector('settings.releaseProfiles'));
|
||||
|
||||
const tagList = useSelector(createTagsSelector());
|
||||
const tagList = useTagList();
|
||||
const indexerList = useSelector(
|
||||
(state: AppState) => state.settings.indexers.items
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
|
|
@ -7,6 +6,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
|
|||
import TagList from 'Components/TagList';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import { Tag } from 'Tags/useTags';
|
||||
import { AutoTaggingSpecification } from 'typings/AutoTagging';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditAutoTaggingModal from './EditAutoTaggingModal';
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
fetchAutoTaggings,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import { useTagList } from 'Tags/useTags';
|
||||
import AutoTaggingModel from 'typings/AutoTagging';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
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 [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [tagsFromId, setTagsFromId] = useState<number>();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Card from 'Components/Card';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { deleteTag } from 'Store/Actions/tagActions';
|
||||
import createTagDetailsSelector from 'Store/Selectors/createTagDetailsSelector';
|
||||
import { useTagDetail } from 'Tags/useTagDetails';
|
||||
import { useDeleteTag } from 'Tags/useTags';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import TagDetailsModal from './Details/TagDetailsModal';
|
||||
import TagInUse from './TagInUse';
|
||||
|
|
@ -16,18 +15,18 @@ interface TagProps {
|
|||
}
|
||||
|
||||
function Tag({ id, label }: TagProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { deleteTag } = useDeleteTag(id);
|
||||
const {
|
||||
delayProfileIds = [],
|
||||
importListIds = [],
|
||||
notificationIds = [],
|
||||
restrictionIds = [],
|
||||
excludedReleaseProfileIds = [],
|
||||
indexerIds = [],
|
||||
downloadClientIds = [],
|
||||
autoTagIds = [],
|
||||
seriesIds = [],
|
||||
} = useSelector(createTagDetailsSelector(id)) ?? {};
|
||||
delayProfileIds,
|
||||
importListIds,
|
||||
notificationIds,
|
||||
restrictionIds,
|
||||
excludedReleaseProfileIds,
|
||||
indexerIds,
|
||||
downloadClientIds,
|
||||
autoTagIds,
|
||||
seriesIds,
|
||||
} = useTagDetail(id);
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
const [isDeleteTagModalOpen, setIsDeleteTagModalOpen] = useState(false);
|
||||
|
||||
|
|
@ -61,8 +60,8 @@ function Tag({ id, label }: TagProps) {
|
|||
}, []);
|
||||
|
||||
const handleConfirmDeleteTag = useCallback(() => {
|
||||
dispatch(deleteTag({ id }));
|
||||
}, [id, dispatch]);
|
||||
deleteTag();
|
||||
}, [deleteTag]);
|
||||
|
||||
const handleDeleteTagModalClose = useCallback(() => {
|
||||
setIsDeleteTagModalOpen(false);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import TagsAppState, { Tag as TagModel } from 'App/State/TagsAppState';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Alert from 'Components/Alert';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
|
|
@ -13,31 +12,24 @@ import {
|
|||
fetchNotifications,
|
||||
fetchReleaseProfiles,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import useTagDetails from 'Tags/useTagDetails';
|
||||
import useTags, { useSortedTagList } from 'Tags/useTags';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import Tag from './Tag';
|
||||
import styles from './Tags.css';
|
||||
|
||||
function Tags() {
|
||||
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 {
|
||||
isFetching: isDetailsFetching,
|
||||
isPopulated: isDetailsPopulated,
|
||||
isFetched: isDetailsFetched,
|
||||
error: detailsError,
|
||||
} = details;
|
||||
} = useTagDetails();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchTags());
|
||||
dispatch(fetchTagDetails());
|
||||
dispatch(fetchDelayProfiles());
|
||||
dispatch(fetchImportLists());
|
||||
dispatch(fetchNotifications());
|
||||
|
|
@ -58,7 +50,7 @@ function Tags() {
|
|||
errorMessage={translate('TagsLoadError')}
|
||||
error={error || detailsError}
|
||||
isFetching={isFetching || isDetailsFetching}
|
||||
isPopulated={isPopulated || isDetailsPopulated}
|
||||
isPopulated={isFetched && isDetailsFetched}
|
||||
>
|
||||
<div className={styles.tags}>
|
||||
{items.map((item) => {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import * as series from './seriesActions';
|
|||
import * as seriesHistory from './seriesHistoryActions';
|
||||
import * as seriesIndex from './seriesIndexActions';
|
||||
import * as settings from './settingsActions';
|
||||
import * as tags from './tagActions';
|
||||
|
||||
export default [
|
||||
app,
|
||||
|
|
@ -40,6 +39,5 @@ export default [
|
|||
series,
|
||||
seriesHistory,
|
||||
seriesIndex,
|
||||
settings,
|
||||
tags
|
||||
settings
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
import isEmpty from 'Utilities/Object/isEmpty';
|
||||
|
||||
interface ValidationFailures {
|
||||
export interface ValidationFailures {
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
}
|
||||
|
||||
function getValidationFailures(saveError?: Error | null): ValidationFailures {
|
||||
export function getValidationFailures(
|
||||
saveError?: Error | null
|
||||
): ValidationFailures {
|
||||
if (!saveError || saveError.status !== 400) {
|
||||
return {
|
||||
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 createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
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 = () => {
|
||||
return useSelector(createTagsSelector());
|
||||
const { queryKey, ...result } = useApiQuery<Tag[]>({
|
||||
path: '/tag',
|
||||
queryOptions: {
|
||||
gcTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: result.data ?? DEFAULT_TAGS,
|
||||
};
|
||||
};
|
||||
|
||||
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