mirror of
https://github.com/Sonarr/Sonarr
synced 2025-12-06 08:28:37 +01:00
Use react-query for path input and file browser
This commit is contained in:
parent
2f119fefd1
commit
91b242902d
9 changed files with 138 additions and 306 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('FileBrowser')}</ModalHeader>
|
||||
|
|
@ -172,7 +135,7 @@ function FileBrowserModalContent(props: FileBrowserModalContentProps) {
|
|||
>
|
||||
{error ? <div>{translate('ErrorLoadingContents')}</div> : null}
|
||||
|
||||
{isPopulated && !error ? (
|
||||
{isFetched && !error ? (
|
||||
<Table horizontalScroll={false} columns={columns}>
|
||||
<TableBody>
|
||||
{emptyParent ? (
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 (
|
||||
<PathInputInternal
|
||||
{...props}
|
||||
paths={paths}
|
||||
paths={data.paths}
|
||||
onFetchPaths={handleFetchPaths}
|
||||
onClearPaths={handleClearPaths}
|
||||
/>
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
80
frontend/src/Path/usePaths.ts
Normal file
80
frontend/src/Path/usePaths.ts
Normal file
|
|
@ -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<PathsResponse>({
|
||||
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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
Loading…
Reference in a new issue