diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index badce70dd..925e0a2b6 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -13,7 +13,6 @@ import InteractiveImportAppState from './InteractiveImportAppState'; import MessagesAppState from './MessagesAppState'; import OAuthAppState from './OAuthAppState'; import OrganizePreviewAppState from './OrganizePreviewAppState'; -import PathsAppState from './PathsAppState'; import ProviderOptionsAppState from './ProviderOptionsAppState'; import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState'; import SettingsAppState from './SettingsAppState'; @@ -85,7 +84,6 @@ interface AppState { interactiveImport: InteractiveImportAppState; oAuth: OAuthAppState; organizePreview: OrganizePreviewAppState; - paths: PathsAppState; providerOptions: ProviderOptionsAppState; series: SeriesAppState; seriesHistory: SeriesHistoryAppState; diff --git a/frontend/src/App/State/PathsAppState.ts b/frontend/src/App/State/PathsAppState.ts deleted file mode 100644 index 068a48dc0..000000000 --- a/frontend/src/App/State/PathsAppState.ts +++ /dev/null @@ -1,29 +0,0 @@ -interface BasePath { - name: string; - path: string; - size: number; - lastModified: string; -} - -interface File extends BasePath { - type: 'file'; -} - -interface Folder extends BasePath { - type: 'folder'; -} - -export type PathType = 'file' | 'folder' | 'drive' | 'computer' | 'parent'; -export type Path = File | Folder; - -interface PathsAppState { - currentPath: string; - isFetching: boolean; - isPopulated: boolean; - error: Error; - directories: Folder[]; - files: File[]; - parent: string | null; -} - -export default PathsAppState; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx b/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx index 53d3bfedd..aaaf9b4e0 100644 --- a/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import Alert from 'Components/Alert'; import { PathInputInternal } from 'Components/Form/PathInput'; import Button from 'Components/Link/Button'; @@ -15,11 +14,10 @@ import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import usePrevious from 'Helpers/Hooks/usePrevious'; import { kinds, scrollDirections } from 'Helpers/Props'; -import { clearPaths, fetchPaths } from 'Store/Actions/pathActions'; +import usePaths from 'Path/usePaths'; import { useSystemStatusData } from 'System/Status/useSystemStatus'; import { InputChanged } from 'typings/inputs'; import translate from 'Utilities/String/translate'; -import createPathsSelector from './createPathsSelector'; import FileBrowserRow from './FileBrowserRow'; import styles from './FileBrowserModalContent.css'; @@ -46,19 +44,25 @@ export interface FileBrowserModalContentProps { onModalClose: () => void; } -function FileBrowserModalContent(props: FileBrowserModalContentProps) { - const { name, value, includeFiles = true, onChange, onModalClose } = props; - - const dispatch = useDispatch(); - - const { isWindows, mode } = useSystemStatusData(); - - const { isFetching, isPopulated, error, parent, directories, files, paths } = - useSelector(createPathsSelector()); - +function FileBrowserModalContent({ + name, + value, + includeFiles = true, + onChange, + onModalClose, +}: FileBrowserModalContentProps) { const [currentPath, setCurrentPath] = useState(value); const scrollerRef = useRef(null); const previousValue = usePrevious(value); + const { isWindows, mode } = useSystemStatusData(); + + const { isFetching, isFetched, error, data } = usePaths({ + path: currentPath, + allowFoldersWithoutTrailingSlashes: true, + includeFiles, + }); + + const { directories, files, parent, paths } = data; const emptyParent = parent === ''; const isWindowsService = isWindows && mode === 'service'; @@ -70,20 +74,9 @@ function FileBrowserModalContent(props: FileBrowserModalContentProps) { [] ); - const handleRowPress = useCallback( - (path: string) => { - setCurrentPath(path); - - dispatch( - fetchPaths({ - path, - allowFoldersWithoutTrailingSlashes: true, - includeFiles, - }) - ); - }, - [includeFiles, dispatch, setCurrentPath] - ); + const handleRowPress = useCallback((path: string) => { + setCurrentPath(path); + }, []); const handleOkPress = useCallback(() => { onChange({ @@ -91,22 +84,12 @@ function FileBrowserModalContent(props: FileBrowserModalContentProps) { value: currentPath, }); - dispatch(clearPaths()); onModalClose(); - }, [name, currentPath, dispatch, onChange, onModalClose]); + }, [name, currentPath, onChange, onModalClose]); - const handleFetchPaths = useCallback( - (path: string) => { - dispatch( - fetchPaths({ - path, - allowFoldersWithoutTrailingSlashes: true, - includeFiles, - }) - ); - }, - [includeFiles, dispatch] - ); + const handleFetchPaths = useCallback((path: string) => { + setCurrentPath(path); + }, []); useEffect(() => { if (value !== previousValue && value !== currentPath) { @@ -114,26 +97,6 @@ function FileBrowserModalContent(props: FileBrowserModalContentProps) { } }, [value, previousValue, currentPath, setCurrentPath]); - useEffect( - () => { - dispatch( - fetchPaths({ - path: currentPath, - allowFoldersWithoutTrailingSlashes: true, - includeFiles, - }) - ); - - return () => { - dispatch(clearPaths()); - }; - }, - // This should only run once when the component mounts, - // so we don't need to include the other dependencies. - // eslint-disable-next-line react-hooks/exhaustive-deps - [dispatch] - ); - return ( {translate('FileBrowser')} @@ -172,7 +135,7 @@ function FileBrowserModalContent(props: FileBrowserModalContentProps) { > {error ?
{translate('ErrorLoadingContents')}
: null} - {isPopulated && !error ? ( + {isFetched && !error ? ( {emptyParent ? ( diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.tsx b/frontend/src/Components/FileBrowser/FileBrowserRow.tsx index fe47f1664..cc74f8484 100644 --- a/frontend/src/Components/FileBrowser/FileBrowserRow.tsx +++ b/frontend/src/Components/FileBrowser/FileBrowserRow.tsx @@ -1,9 +1,9 @@ import React, { useCallback } from 'react'; -import { PathType } from 'App/State/PathsAppState'; import Icon from 'Components/Icon'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowButton from 'Components/Table/TableRowButton'; import { icons } from 'Helpers/Props'; +import { PathType } from 'Path/usePaths'; import styles from './FileBrowserRow.css'; function getIconName(type: PathType) { diff --git a/frontend/src/Components/FileBrowser/createPathsSelector.ts b/frontend/src/Components/FileBrowser/createPathsSelector.ts deleted file mode 100644 index 5da830bd5..000000000 --- a/frontend/src/Components/FileBrowser/createPathsSelector.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createSelector } from 'reselect'; -import AppState from 'App/State/AppState'; - -function createPathsSelector() { - return createSelector( - (state: AppState) => state.paths, - (paths) => { - const { - isFetching, - isPopulated, - error, - parent, - currentPath, - directories, - files, - } = paths; - - const filteredPaths = [...directories, ...files].filter(({ path }) => { - return path.toLowerCase().startsWith(currentPath.toLowerCase()); - }); - - return { - isFetching, - isPopulated, - error, - parent, - currentPath, - directories, - files, - paths: filteredPaths, - }; - } - ); -} - -export default createPathsSelector; diff --git a/frontend/src/Components/Form/PathInput.tsx b/frontend/src/Components/Form/PathInput.tsx index 015b835e3..1bb7f4c12 100644 --- a/frontend/src/Components/Form/PathInput.tsx +++ b/frontend/src/Components/Form/PathInput.tsx @@ -10,15 +10,11 @@ import { ChangeEvent, SuggestionsFetchRequestedParams, } from 'react-autosuggest'; -import { useDispatch, useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; -import AppState from 'App/State/AppState'; -import { Path } from 'App/State/PathsAppState'; import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; import Icon from 'Components/Icon'; import usePrevious from 'Helpers/Hooks/usePrevious'; import { icons } from 'Helpers/Props'; -import { clearPaths, fetchPaths } from 'Store/Actions/pathActions'; +import usePaths, { Path } from 'Path/usePaths'; import { InputChanged } from 'typings/inputs'; import AutoSuggestInput from './AutoSuggestInput'; import FormInputButton from './FormInputButton'; @@ -46,43 +42,27 @@ function handleSuggestionsClearRequested() { // because we don't want to reset the paths after a path is selected. } -function createPathsSelector() { - return createSelector( - (state: AppState) => state.paths, - (paths) => { - const { currentPath, directories, files } = paths; - - const filteredPaths = [...directories, ...files].filter(({ path }) => { - return path.toLowerCase().startsWith(currentPath.toLowerCase()); - }); - - return filteredPaths; - } - ); -} - function PathInput(props: PathInputProps) { - const { includeFiles } = props; + const { includeFiles, value = '' } = props; + const [currentPath, setCurrentPath] = useState(value); - const dispatch = useDispatch(); + const { data } = usePaths({ + path: currentPath, + includeFiles, + }); - const paths = useSelector(createPathsSelector()); - - const handleFetchPaths = useCallback( - (path: string) => { - dispatch(fetchPaths({ path, includeFiles })); - }, - [includeFiles, dispatch] - ); + const handleFetchPaths = useCallback((path: string) => { + setCurrentPath(path); + }, []); const handleClearPaths = useCallback(() => { - dispatch(clearPaths); - }, [dispatch]); + // No-op for React Query implementation as we don't need to clear + }, []); return ( @@ -91,32 +71,22 @@ function PathInput(props: PathInputProps) { export default PathInput; -export function PathInputInternal(props: PathInputInternalProps) { - const { - className = styles.inputWrapper, - name, - value: inputValue = '', - paths, - includeFiles, - hasButton, - hasFileBrowser = true, - onChange, - onFetchPaths, - onClearPaths, - ...otherProps - } = props; - +export function PathInputInternal({ + className = styles.inputWrapper, + name, + value: inputValue = '', + paths, + includeFiles, + hasButton, + hasFileBrowser = true, + onChange, + onFetchPaths, + onClearPaths, + ...otherProps +}: PathInputInternalProps) { const [value, setValue] = useState(inputValue); const [isFileBrowserModalOpen, setIsFileBrowserModalOpen] = useState(false); const previousInputValue = usePrevious(inputValue); - const dispatch = useDispatch(); - - const handleFetchPaths = useCallback( - (path: string) => { - dispatch(fetchPaths({ path, includeFiles })); - }, - [includeFiles, dispatch] - ); const handleInputChange = useCallback( (_event: SyntheticEvent, { newValue }: ChangeEvent) => { @@ -138,12 +108,12 @@ export function PathInputInternal(props: PathInputInternalProps) { }); if (path.type !== 'file') { - handleFetchPaths(path.path); + onFetchPaths(path.path); } } } }, - [name, paths, handleFetchPaths, onChange] + [name, paths, onFetchPaths, onChange] ); const handleInputBlur = useCallback(() => { onChange({ @@ -156,16 +126,16 @@ export function PathInputInternal(props: PathInputInternalProps) { const handleSuggestionSelected = useCallback( (_event: SyntheticEvent, { suggestion }: { suggestion: Path }) => { - handleFetchPaths(suggestion.path); + onFetchPaths(suggestion.path); }, - [handleFetchPaths] + [onFetchPaths] ); const handleSuggestionsFetchRequested = useCallback( ({ value: newValue }: SuggestionsFetchRequestedParams) => { - handleFetchPaths(newValue); + onFetchPaths(newValue); }, - [handleFetchPaths] + [onFetchPaths] ); const handleFileBrowserOpenPress = useCallback(() => { diff --git a/frontend/src/Path/usePaths.ts b/frontend/src/Path/usePaths.ts new file mode 100644 index 000000000..72bef1cb6 --- /dev/null +++ b/frontend/src/Path/usePaths.ts @@ -0,0 +1,80 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import useApiQuery from 'Helpers/Hooks/useApiQuery'; + +interface BasePath { + name: string; + path: string; + size: number; + lastModified: string; +} + +interface File extends BasePath { + type: 'file'; +} + +interface Folder extends BasePath { + type: 'folder'; +} + +export type PathType = 'file' | 'folder' | 'drive' | 'computer' | 'parent'; +export type Path = File | Folder; + +interface PathsResponse { + parent: string | null; + directories: Folder[]; + files: File[]; + paths: Path[]; +} + +const DEFAULT: PathsResponse = { + parent: null, + directories: [], + files: [], + paths: [], +}; + +const usePaths = ({ + path, + allowFoldersWithoutTrailingSlashes = false, + includeFiles = false, +}: { + path: string; + allowFoldersWithoutTrailingSlashes?: boolean; + includeFiles?: boolean; +}) => { + const { data: responseData, ...result } = useApiQuery({ + path: '/filesystem', + queryParams: { path, allowFoldersWithoutTrailingSlashes, includeFiles }, + queryOptions: { + enabled: path.trim().length > 0, + placeholderData: keepPreviousData, + }, + }); + + const data = useMemo(() => { + if (!responseData) { + return DEFAULT; + } + + const { directories, files, parent } = responseData; + + const filteredPaths = [...directories, ...files].filter((item) => { + return item.path.toLowerCase().startsWith(path.toLowerCase()); + }); + + return { + directories, + files, + parent, + paths: filteredPaths, + }; + }, [path, responseData]); + + return { + ...result, + data, + }; +}; + +export default usePaths; diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 4d1db0d50..a37a4e665 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -9,7 +9,6 @@ import * as importSeries from './importSeriesActions'; import * as interactiveImportActions from './interactiveImportActions'; import * as oAuth from './oAuthActions'; import * as organizePreview from './organizePreviewActions'; -import * as paths from './pathActions'; import * as providerOptions from './providerOptionActions'; import * as series from './seriesActions'; import * as seriesHistory from './seriesHistoryActions'; @@ -28,7 +27,6 @@ export default [ interactiveImportActions, oAuth, organizePreview, - paths, providerOptions, series, seriesHistory, diff --git a/frontend/src/Store/Actions/pathActions.js b/frontend/src/Store/Actions/pathActions.js deleted file mode 100644 index 1cb7a15a2..000000000 --- a/frontend/src/Store/Actions/pathActions.js +++ /dev/null @@ -1,112 +0,0 @@ -import { createAction } from 'redux-actions'; -import { createThunk, handleThunks } from 'Store/thunks'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; -import { set } from './baseActions'; -import createHandleActions from './Creators/createHandleActions'; - -// -// Variables - -export const section = 'paths'; - -// -// State - -export const defaultState = { - currentPath: '', - isPopulated: false, - isFetching: false, - error: null, - directories: [], - files: [], - parent: null -}; - -// -// Actions Types - -export const FETCH_PATHS = 'paths/fetchPaths'; -export const UPDATE_PATHS = 'paths/updatePaths'; -export const CLEAR_PATHS = 'paths/clearPaths'; - -// -// Action Creators - -export const fetchPaths = createThunk(FETCH_PATHS); -export const updatePaths = createAction(UPDATE_PATHS); -export const clearPaths = createAction(CLEAR_PATHS); - -// -// Action Handlers - -export const actionHandlers = handleThunks({ - - [FETCH_PATHS]: function(getState, payload, dispatch) { - dispatch(set({ section, isFetching: true })); - - const { - path, - allowFoldersWithoutTrailingSlashes = false, - includeFiles = false - } = payload; - - const promise = createAjaxRequest({ - url: '/filesystem', - data: { - path, - allowFoldersWithoutTrailingSlashes, - includeFiles - } - }).request; - - promise.done((data) => { - dispatch(updatePaths({ path, ...data })); - - dispatch(set({ - section, - isFetching: false, - isPopulated: true, - error: null - })); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isFetching: false, - isPopulated: false, - error: xhr - })); - }); - } - -}); - -// -// Reducers - -export const reducers = createHandleActions({ - - [UPDATE_PATHS]: (state, { payload }) => { - const newState = Object.assign({}, state); - - newState.currentPath = payload.path; - newState.directories = payload.directories; - newState.files = payload.files; - newState.parent = payload.parent; - - return newState; - }, - - [CLEAR_PATHS]: (state, { payload }) => { - const newState = Object.assign({}, state); - - newState.path = ''; - newState.directories = []; - newState.files = []; - newState.parent = ''; - - return newState; - } - -}, defaultState, section);