diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx index 5305a7fa3..27fde88d8 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.tsx @@ -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( diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 76180df56..9e5b9d8d6 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -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; diff --git a/frontend/src/App/State/TagsAppState.ts b/frontend/src/App/State/TagsAppState.ts index 33461a83c..a7c971ea6 100644 --- a/frontend/src/App/State/TagsAppState.ts +++ b/frontend/src/App/State/TagsAppState.ts @@ -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, diff --git a/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValue.tsx index e5716af14..9bf9a7996 100644 --- a/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValue.tsx +++ b/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValue.tsx @@ -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 = Omit< >; function TagFilterBuilderRowValue(props: TagFilterBuilderRowValueProps) { - const tags = useSelector(createTagsSelector()); + const tags = useTagList(); const tagList = useMemo(() => { return tags.map((tag) => { diff --git a/frontend/src/Components/Form/FormInputGroup.tsx b/frontend/src/Components/Form/FormInputGroup.tsx index 87839438c..4a57e561f 100644 --- a/frontend/src/Components/Form/FormInputGroup.tsx +++ b/frontend/src/Components/Form/FormInputGroup.tsx @@ -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( 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( const hasButton = !!buttonsArray.length; return ( -
-
-
- {/* @ts-expect-error - types are validated already */} - + +
+
+
+ {/* @ts-expect-error - types are validated already */} + - {unit && ( -
- {unit} -
- )} -
+ {unit && ( +
+ {unit} +
+ )} +
- {buttonsArray.map((button, index) => { - if (!React.isValidElement(button)) { - return button; - } + {buttonsArray.map((button, index) => { + if (!React.isValidElement(button)) { + return button; + } - return React.cloneElement(button, { - isLastButton: index === lastButtonIndex, - }); - })} + return React.cloneElement(button, { + isLastButton: index === lastButtonIndex, + }); + })} - {/*
+ {/*
{ pending && ( /> }
*/} -
- - {!checkInput && helpText ? : null} - - {!checkInput && helpTexts ? ( -
- {helpTexts.map((text, index) => { - return ( - - ); - })}
- ) : null} - {(!checkInput || helpText) && helpTextWarning ? ( - - ) : null} + {!checkInput && helpText ? : null} - {helpLink ? {translate('MoreInfo')} : null} + {!checkInput && helpTexts ? ( +
+ {helpTexts.map((text, index) => { + return ( + + ); + })} +
+ ) : null} - {errors.map((error, index) => { - return 'errorMessage' in error ? ( - - ) : ( - - ); - })} + {(!checkInput || helpText) && helpTextWarning ? ( + + ) : null} - {warnings.map((warning, index) => { - return 'errorMessage' in warning ? ( - - ) : ( - - ); - })} -
+ {helpLink ? {translate('MoreInfo')} : null} + + {errors.map((error, index) => { + return 'errorMessage' in error ? ( + + ) : ( + + ); + })} + + {warnings.map((warning, index) => { + return 'errorMessage' in warning ? ( + + ) : ( + + ); + })} +
+ ); } diff --git a/frontend/src/Components/Form/FormInputGroupContext.tsx b/frontend/src/Components/Form/FormInputGroupContext.tsx new file mode 100644 index 000000000..4d0e9c19f --- /dev/null +++ b/frontend/src/Components/Form/FormInputGroupContext.tsx @@ -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 ( + + {children} + + ); +} + +export function useFormInputGroup() { + return React.useContext(FormInputGroupContext); +} diff --git a/frontend/src/Components/Form/Tag/SeriesTagInput.tsx b/frontend/src/Components/Form/Tag/SeriesTagInput.tsx index d418bbfac..618795a66 100644 --- a/frontend/src/Components/Form/Tag/SeriesTagInput.tsx +++ b/frontend/src/Components/Form/Tag/SeriesTagInput.tsx @@ -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 onChange: (change: InputChanged) => 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({ @@ -69,7 +54,7 @@ export default function SeriesTagInput({ onChange, ...otherProps }: SeriesTagInputProps) { - const dispatch = useDispatch(); + const formInputActions = useFormInputGroup(); const isArray = Array.isArray(value); const arrayValue = useMemo(() => { @@ -80,12 +65,10 @@ export default function SeriesTagInput({ 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({ [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({ 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({ [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 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((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((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(); diff --git a/frontend/src/Components/Page/Header/SeriesSearchResult.tsx b/frontend/src/Components/Page/Header/SeriesSearchResult.tsx index 83feb693d..9da3ee471 100644 --- a/frontend/src/Components/Page/Header/SeriesSearchResult.tsx +++ b/frontend/src/Components/Page/Header/SeriesSearchResult.tsx @@ -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'; diff --git a/frontend/src/Components/Page/PageSectionContent.tsx b/frontend/src/Components/Page/PageSectionContent.tsx index f3470fe7e..24a79c12c 100644 --- a/frontend/src/Components/Page/PageSectionContent.tsx +++ b/frontend/src/Components/Page/PageSectionContent.tsx @@ -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; } diff --git a/frontend/src/Components/SeriesTagList.tsx b/frontend/src/Components/SeriesTagList.tsx index bec6c28d5..7fb09c9d2 100644 --- a/frontend/src/Components/SeriesTagList.tsx +++ b/frontend/src/Components/SeriesTagList.tsx @@ -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 ; } diff --git a/frontend/src/Components/SignalRListener.tsx b/frontend/src/Components/SignalRListener.tsx index d87fdad0e..434eec104 100644 --- a/frontend/src/Components/SignalRListener.tsx +++ b/frontend/src/Components/SignalRListener.tsx @@ -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; } diff --git a/frontend/src/Components/TagList.tsx b/frontend/src/Components/TagList.tsx index bd913c1a5..8d56f7d2f 100644 --- a/frontend/src/Components/TagList.tsx +++ b/frontend/src/Components/TagList.tsx @@ -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'; diff --git a/frontend/src/Helpers/Hooks/useApiMutation.ts b/frontend/src/Helpers/Hooks/useApiMutation.ts index c3fe85958..641db3d50 100644 --- a/frontend/src/Helpers/Hooks/useApiMutation.ts +++ b/frontend/src/Helpers/Hooks/useApiMutation.ts @@ -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 extends Omit, 'method'> { method: 'POST' | 'PUT' | 'DELETE'; - mutationOptions?: Omit, 'mutationFn'>; + mutationOptions?: Omit, 'mutationFn'>; queryParams?: QueryParams; } @@ -25,7 +33,7 @@ function useApiMutation(options: MutationOptions) { }; }, [options]); - return useMutation({ + return useMutation({ ...options.mutationOptions, mutationFn: async (data?: TData) => { const { path, ...otherOptions } = requestOptions; @@ -36,3 +44,30 @@ function useApiMutation(options: MutationOptions) { } 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: [], + } + ); +} diff --git a/frontend/src/Helpers/Hooks/useAppPage.ts b/frontend/src/Helpers/Hooks/useAppPage.ts index 0fe2fac64..4b27d38e1 100644 --- a/frontend/src/Helpers/Hooks/useAppPage.ts +++ b/frontend/src/Helpers/Hooks/useAppPage.ts @@ -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()); diff --git a/frontend/src/Series/Details/SeriesTags.tsx b/frontend/src/Series/Details/SeriesTags.tsx index a5d61bd4d..731e6372a 100644 --- a/frontend/src/Series/Details/SeriesTags.tsx +++ b/frontend/src/Series/Details/SeriesTags.tsx @@ -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)) diff --git a/frontend/src/Series/Index/Select/Tags/TagsModalContent.tsx b/frontend/src/Series/Index/Select/Tags/TagsModalContent.tsx index bf94179cd..7c4bc9dda 100644 --- a/frontend/src/Series/Index/Select/Tags/TagsModalContent.tsx +++ b/frontend/src/Series/Index/Select/Tags/TagsModalContent.tsx @@ -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([]); const [applyTags, setApplyTags] = useState('add'); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.tsx index a560cf545..d57106a96 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.tsx @@ -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); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.tsx index 508880777..a870b73df 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Tags/TagsModalContent.tsx @@ -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([]); const [applyTags, setApplyTags] = useState('add'); diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportList.tsx b/frontend/src/Settings/ImportLists/ImportLists/ImportList.tsx index 6b7e38116..d5ebf4fa9 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/ImportList.tsx +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportList.tsx @@ -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); diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.tsx index 1c708a8ed..9c1904083 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.tsx +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.tsx @@ -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([]); const [applyTags, setApplyTags] = useState('add'); diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.tsx b/frontend/src/Settings/Indexers/Indexers/Indexer.tsx index 5804a2cc1..a9f068888 100644 --- a/frontend/src/Settings/Indexers/Indexers/Indexer.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Indexer.tsx @@ -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] = diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx index 47d61336e..a3657b524 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx @@ -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([]); const [applyTags, setApplyTags] = useState('add'); diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.tsx b/frontend/src/Settings/Notifications/Notifications/Notification.tsx index 53981d89f..1b99c074b 100644 --- a/frontend/src/Settings/Notifications/Notifications/Notification.tsx +++ b/frontend/src/Settings/Notifications/Notifications/Notification.tsx @@ -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); diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.tsx b/frontend/src/Settings/Profiles/Delay/DelayProfile.tsx index 878ed530f..12bd60ac2 100644 --- a/frontend/src/Settings/Profiles/Delay/DelayProfile.tsx +++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.tsx @@ -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'; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.tsx b/frontend/src/Settings/Profiles/Delay/DelayProfiles.tsx index f70877321..96c03884c 100644 --- a/frontend/src/Settings/Profiles/Delay/DelayProfiles.tsx +++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.tsx @@ -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(null); const [dropIndex, setDropIndex] = useState(null); diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfileItem.tsx b/frontend/src/Settings/Profiles/Release/ReleaseProfileItem.tsx index 59330de89..b23a77c5a 100644 --- a/frontend/src/Settings/Profiles/Release/ReleaseProfileItem.tsx +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfileItem.tsx @@ -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'; diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.tsx b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.tsx index c0a34a46c..330d1ba1c 100644 --- a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.tsx +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.tsx @@ -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 ); diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.tsx b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.tsx index cd484a33f..a53654897 100644 --- a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.tsx +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.tsx @@ -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'; diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.tsx b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.tsx index 01685af9c..69d718511 100644 --- a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.tsx +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.tsx @@ -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(); diff --git a/frontend/src/Settings/Tags/Tag.tsx b/frontend/src/Settings/Tags/Tag.tsx index 0e40fafe6..e76add139 100644 --- a/frontend/src/Settings/Tags/Tag.tsx +++ b/frontend/src/Settings/Tags/Tag.tsx @@ -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); diff --git a/frontend/src/Settings/Tags/Tags.tsx b/frontend/src/Settings/Tags/Tags.tsx index 85768d565..55f937f79 100644 --- a/frontend/src/Settings/Tags/Tags.tsx +++ b/frontend/src/Settings/Tags/Tags.tsx @@ -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( - '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} >
{items.map((item) => { diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 0c5db21d0..e48521739 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -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 ]; diff --git a/frontend/src/Store/Actions/tagActions.js b/frontend/src/Store/Actions/tagActions.js deleted file mode 100644 index 6800b1d58..000000000 --- a/frontend/src/Store/Actions/tagActions.js +++ /dev/null @@ -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); diff --git a/frontend/src/Store/Selectors/createTagDetailsSelector.ts b/frontend/src/Store/Selectors/createTagDetailsSelector.ts deleted file mode 100644 index aa330a657..000000000 --- a/frontend/src/Store/Selectors/createTagDetailsSelector.ts +++ /dev/null @@ -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; diff --git a/frontend/src/Store/Selectors/createTagsSelector.ts b/frontend/src/Store/Selectors/createTagsSelector.ts deleted file mode 100644 index 2ec629ceb..000000000 --- a/frontend/src/Store/Selectors/createTagsSelector.ts +++ /dev/null @@ -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; diff --git a/frontend/src/Store/Selectors/selectSettings.ts b/frontend/src/Store/Selectors/selectSettings.ts index bdcbe21a8..8c81885b0 100644 --- a/frontend/src/Store/Selectors/selectSettings.ts +++ b/frontend/src/Store/Selectors/selectSettings.ts @@ -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: [], diff --git a/frontend/src/Tags/useTagDetails.ts b/frontend/src/Tags/useTagDetails.ts new file mode 100644 index 000000000..e3061cbc7 --- /dev/null +++ b/frontend/src/Tags/useTagDetails.ts @@ -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({ + 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: [], + } + ); +}; diff --git a/frontend/src/Tags/useTags.ts b/frontend/src/Tags/useTags.ts index 5f2d58d34..8e4f3da44 100644 --- a/frontend/src/Tags/useTags.ts +++ b/frontend/src/Tags/useTags.ts @@ -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({ + 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(null); + + const { mutate, isPending } = useApiMutation>({ + path: '/tag', + method: 'POST', + mutationOptions: { + onMutate: () => { + setError(null); + }, + onSuccess: (data) => { + queryClient.setQueryData(['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(null); + + const { mutate, isPending } = useApiMutation({ + path: `/tag/${id}`, + method: 'DELETE', + mutationOptions: { + onMutate: () => { + setError(null); + }, + onSuccess: () => { + queryClient.setQueryData(['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, + }; +};