Use react-query for path input and file browser

This commit is contained in:
Mark McDowall 2025-11-26 21:18:25 -08:00
parent 2f119fefd1
commit 91b242902d
9 changed files with 138 additions and 306 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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 ? (

View file

@ -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) {

View file

@ -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;

View file

@ -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(() => {

View 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;

View file

@ -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,

View file

@ -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);