diff --git a/src/components/DeleteDialog.jsx b/src/components/DeleteDialog.jsx index ee435297..421b2f15 100644 --- a/src/components/DeleteDialog.jsx +++ b/src/components/DeleteDialog.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { useDispatch, useSelector } from 'react-redux'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { moveToTrashBatch } from '../store/actions/files'; import { scopes } from '../store/actions/loading'; @@ -31,7 +31,7 @@ function DeleteDialog({ uid }) { const loading = useSelector((state) => getLoading(state, scopes.movingToTrash)); const fileIds = dialogProps.fileIds ?? []; - const files = useSelector((state) => getFilesByIds(state, fileIds)); + const files = useSelector((state) => getFilesByIds(state, { ids: fileIds }), shallowEqual); const onDelete = () => { dispatch(moveToTrashBatch(files.map((file) => file.path))); diff --git a/src/components/DeleteImmediatelyDialog.jsx b/src/components/DeleteImmediatelyDialog.jsx index 5a8a9269..32960485 100644 --- a/src/components/DeleteImmediatelyDialog.jsx +++ b/src/components/DeleteImmediatelyDialog.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { useDispatch, useSelector } from 'react-redux'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { deleteImmediatelyBatch } from '../store/actions/files'; import { scopes } from '../store/actions/loading'; @@ -31,7 +31,7 @@ function DeleteDialog({ uid }) { const loading = useSelector((state) => getLoading(state, scopes.deletingFileImmediately)); const fileIds = dialogProps.fileIds ?? []; - const files = useSelector((state) => getFilesByIds(state, fileIds)); + const files = useSelector((state) => getFilesByIds(state, { ids: fileIds }), shallowEqual); const onDelete = () => { dispatch(deleteImmediatelyBatch(files.map((file) => file.path))); diff --git a/src/components/FileTableView.jsx b/src/components/FileTableView.jsx index 9866e9f3..cea5caed 100644 --- a/src/components/FileTableView.jsx +++ b/src/components/FileTableView.jsx @@ -63,7 +63,8 @@ function Table({
{(items.length || loading) ? ( (event) => { onPathChange(routes.makePathFromUrl(route)); }; -const FolderPicker = React.memo(({ - items, loading, path, listFolder, onPathChange, -}) => { +const FolderPicker = ({ excludeIds, path, onPathChange }) => { + const dispatch = useDispatch(); + + let items = useSelector((state) => getFolderIdsByPath(state, { path })); + const loading = useSelector((state) => getLoading(state, scopes.listingFolder)); + React.useEffect(() => { - listFolder(path); - }, [path, listFolder]); + if (items.length === 0) { + dispatch(listFolder(path)); + } + }, [items.length, path, dispatch]); + + const idsToExclude = new Set(excludeIds); + items = items.filter((id) => !idsToExclude.has(id)); + + const data = { + items, + onClick: onPathChange, + }; return ( <> @@ -61,13 +81,10 @@ const FolderPicker = React.memo(({ ( -
- -
- )} + itemRender={FolderPickerItem} /> ) : (
@@ -85,17 +102,14 @@ const FolderPicker = React.memo(({ ); -}); +}; FolderPicker.propTypes = { - items: PropTypes.arrayOf(PropTypes.string).isRequired, - loading: PropTypes.bool, + excludeIds: PropTypes.arrayOf( + PropTypes.string.isRequired, + ).isRequired, path: PropTypes.string.isRequired, onPathChange: PropTypes.func.isRequired, }; -FolderPicker.defaultProps = { - loading: true, -}; - export default FolderPicker; diff --git a/src/components/FolderPickerItem.jsx b/src/components/FolderPickerItem.jsx index 5f456bfa..e0f2a490 100644 --- a/src/components/FolderPickerItem.jsx +++ b/src/components/FolderPickerItem.jsx @@ -1,46 +1,46 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { MediaType } from '../constants'; +import { useSelector } from 'react-redux'; + +import { getFileById } from '../store/reducers/files'; import FileIcon from './FileIcon'; -function FolderPickerItem({ className, item, onClick }) { +function FolderPickerItem({ data, index, style }) { + const itemId = data.items[index]; + const item = useSelector((state) => getFileById(state, itemId)); + const primaryText = (item.hidden) ? 'text-gray-500' : 'text-gray-800'; - const isFolder = item.mediatype === MediaType.FOLDER; - const cursor = (isFolder) ? '' : 'cursor-default'; return ( -
- + +
); } FolderPickerItem.propTypes = { - className: PropTypes.string, - item: PropTypes.shape({ - name: PropTypes.string.isRequired, - path: PropTypes.string.isRequired, - hidden: PropTypes.bool.isRequired, - mediatype: PropTypes.string.isRequired, + data: PropTypes.shape({ + items: PropTypes.arrayOf( + PropTypes.string.isRequired, + ).isRequired, + onClick: PropTypes.func.isRequired, }).isRequired, + index: PropTypes.number.isRequired, }; -FolderPickerItem.defaultProps = { - className: '', -}; - -export default FolderPickerItem; +export default React.memo(FolderPickerItem); diff --git a/src/components/MoveDialog.jsx b/src/components/MoveDialog.jsx index 3d030569..706a65f2 100644 --- a/src/components/MoveDialog.jsx +++ b/src/components/MoveDialog.jsx @@ -1,7 +1,7 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; -import { useDispatch, useSelector } from 'react-redux'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { moveFileBatch } from '../store/actions/files'; import { scopes } from '../store/actions/loading'; @@ -14,10 +14,10 @@ import { getFileDialogProps, getFileDialogVisible } from '../store/reducers/ui'; import pluralize from '../pluralize'; import * as routes from '../routes'; -import FolderPicker from '../containers/FolderPicker'; - import Dialog from './ui/Dialog'; +import FolderPicker from './FolderPicker'; + const styles = { height: '40vh', }; @@ -32,7 +32,7 @@ function MoveDialog({ uid }) { const loading = useSelector((state) => getLoading(state, scopes.movingFile)); const fileIds = dialogProps.fileIds ?? []; - const files = useSelector((state) => getFilesByIds(state, fileIds)); + const files = useSelector((state) => getFilesByIds(state, { ids: fileIds }), shallowEqual); const onMove = () => { dispatch(moveFileBatch(files.map((file) => ({ @@ -46,6 +46,11 @@ function MoveDialog({ uid }) { dispatch(closeDialog(uid)); }; + const onPathChange = useCallback( + (path) => setToPath(path), + [setToPath], + ); + return ( setToPath(path)} + onPathChange={onPathChange} excludeIds={fileIds} /> diff --git a/src/components/Uploader/UploadList.jsx b/src/components/Uploader/UploadList.jsx index 4378f482..71a8b90b 100644 --- a/src/components/Uploader/UploadList.jsx +++ b/src/components/Uploader/UploadList.jsx @@ -11,7 +11,8 @@ function UploadList({ uploads, virtual, itemRender }) { if (virtual) { return ( ); diff --git a/src/components/ui/VList.jsx b/src/components/ui/VList.jsx index 1387b4eb..e7030a83 100644 --- a/src/components/ui/VList.jsx +++ b/src/components/ui/VList.jsx @@ -10,8 +10,9 @@ function VList({ className, heightOffset, initialScrollOffset, + itemCount, + itemData, itemHeight, - items, loading, scrollKey, trackScrolling, @@ -35,8 +36,8 @@ function VList({ ({ - items: getFilesByPath(state, ownProps.path), + items: getFileIdsByPath(state, ownProps.path), loading: getLoading(state, scopes.listingFolder), }), )(FileTableView); diff --git a/src/containers/FolderPicker.js b/src/containers/FolderPicker.js deleted file mode 100644 index bf7307a7..00000000 --- a/src/containers/FolderPicker.js +++ /dev/null @@ -1,33 +0,0 @@ -import { connect } from 'react-redux'; - -import { listFolder } from '../store/actions/files'; -import { scopes } from '../store/actions/loading'; - -import { getFilesByPath } from '../store/reducers/files'; -import { getLoading } from '../store/reducers/loading'; - -import FolderPicker from '../components/FolderPicker'; - -function getFilesByPathExclude(state, props) { - const { path } = props; - const excludeIds = new Set(props.excludeIds); - const files = getFilesByPath(state, path); - if (excludeIds == null) { - return files; - } - const nextFiles = files.filter((fileId) => !excludeIds.has(fileId)); - if (files.length === nextFiles.length) { - return files; // return object from state to prevent re-renders - } - return nextFiles; -} - -export default connect( - (state, ownProps) => ({ - items: getFilesByPathExclude(state, ownProps), - loading: getLoading(state, scopes.listingFolder), - }), - { - listFolder, - }, -)(FolderPicker); diff --git a/src/containers/FolderPickerItem.js b/src/containers/FolderPickerItem.js deleted file mode 100644 index 74748f7c..00000000 --- a/src/containers/FolderPickerItem.js +++ /dev/null @@ -1,10 +0,0 @@ -import { connect } from 'react-redux'; -import FolderPickerItem from '../components/FolderPickerItem'; - -import { getFileById } from '../store/reducers/files'; - -export default connect( - (state, ownProps) => ({ - item: getFileById(state, ownProps.item), - }), -)(FolderPickerItem); diff --git a/src/store/reducers/files.js b/src/store/reducers/files.js index 7ec7318c..7a7a5061 100644 --- a/src/store/reducers/files.js +++ b/src/store/reducers/files.js @@ -1,6 +1,8 @@ +import { shallowEqual } from 'react-redux'; import { combineReducers } from 'redux'; import { createSelector } from 'reselect'; +import { MediaType } from '../../constants'; import * as routes from '../../routes'; import { difference } from '../../set'; @@ -37,10 +39,21 @@ function filesById(state = {}, action) { }; } case types.LIST_FOLDER_SUCCESS: { - return { - ...state, - ...normalize(action.payload.items), - }; + if (action.payload.items.length === 0) { + return state; + } + const items = normalize(action.payload.items); + const nextState = { ...state }; + Object.keys(items).forEach((key) => { + if (nextState[key] == null) { + nextState[key] = items[key]; + } + if (!shallowEqual(nextState[key], items[key])) { + nextState[key] = items[key]; + } + }); + + return nextState; } case types.MOVE_TO_TRASH_SUCCESS: case types.MOVE_FILE_SUCCESS: { @@ -113,10 +126,17 @@ function filesByPath(state = {}, action) { } case types.LIST_FOLDER_SUCCESS: { const { path, items } = action.payload; - return { - ...state, - [path]: items.map((file) => file.id), - }; + if (items.length === 0) { + return state; + } + const ids = items.map((file) => file.id); + if (!shallowEqual(ids, state[path])) { + return { + ...state, + [path]: ids, + }; + } + return state; } case types.UPDATE_FOLDER_BY_PATH: { const { path, ids } = action.payload; @@ -172,13 +192,22 @@ export default combineReducers({ const FILES_EMPTY = []; export const getFileById = (state, id) => state.files.byId[id]; -export const getFilesByIds = (state, ids) => ids.map((id) => getFileById(state, id)); -export const getFilesByPath = (state, path) => state.files.byPath[path] || FILES_EMPTY; -export const getFilesCountByPath = (state, path) => getFilesByPath(state, path).length; +export const getFileIdsByPath = (state, path) => state.files.byPath[path] ?? FILES_EMPTY; +export const getFilesCountByPath = (state, path) => getFileIdsByPath(state, path).length; export const getIsFileSelected = (state, id) => state.files.selectedIds.has(id); -export const getSelectedFileIds = (state) => [...state.files.selectedIds]; -export const getSelectedFiles = (state) => ( - getSelectedFileIds(state).map((id) => getFileById(state, id)) +export const getSelectedFileIds = (state) => state.files.selectedIds; +export const getSelectedFiles = createSelector( + [ + (state) => state.files.byId, + getSelectedFileIds, + ], + (byId, fileIds) => { + const files = []; + fileIds.forEach((id) => { + files.push(byId[id]); + }); + return files; + }, ); export const getHasSelectedFiles = (state) => state.files.selectedIds.size !== 0; export const getCountSelectedFiles = (state) => state.files.selectedIds.size; @@ -186,11 +215,45 @@ export const getThumbnailById = (state, id) => state.files.thumbnailsById[id]; export const getDownloads = (state) => state.files.downloads; +function createPropsSelector(selector) { + return (_, props) => selector(props); +} + +const getPathProp = createPropsSelector((props) => props.path); + +export const getFolderIdsByPath = createSelector( + [ + (state) => state.files.byId, + (state) => state.files.byPath, + getPathProp, + ], + (byId, byPath, path) => ( + (byPath[path] ?? FILES_EMPTY) + .map((id) => byId[id]) + .filter((item) => item.mediatype === MediaType.FOLDER) + .map((item) => item.id) + ), +); + +const getIdsProps = createPropsSelector((props) => props.ids); + +export const getFilesByIds = ( + createSelector( + [ + (state) => state.files.byId, + getIdsProps, + ], + (byId, ids) => ( + ids.map((id) => byId[id]) + ), + ) +); + export const makeGetPreview = () => ( createSelector( [ (state) => state.files.byId, - (state, props) => getFilesByPath(state, props.dirPath), + (state, props) => getFileIdsByPath(state, props.dirPath), (_state, props) => props.name, ], (byId, files, name) => { diff --git a/src/store/sagas/fileWatchers.js b/src/store/sagas/fileWatchers.js index 2a647c10..70b31a84 100644 --- a/src/store/sagas/fileWatchers.js +++ b/src/store/sagas/fileWatchers.js @@ -15,7 +15,7 @@ import * as fileActions from '../actions/files'; import * as taskActions from '../actions/tasks'; import * as uploadActions from '../actions/uploads'; -import { getFileById, getFilesByPath, getSelectedFileIds } from '../reducers/files'; +import { getFileById, getFileIdsByPath, getSelectedFileIds } from '../reducers/files'; import { getCurrentPath } from '../reducers/ui'; /** @@ -82,7 +82,7 @@ function* handleCreateFolder(action) { const { folder } = action.payload; const currPath = yield select(getCurrentPath); - const ids = new Set(yield select(getFilesByPath, currPath)); + const ids = new Set(yield select(getFileIdsByPath, currPath)); if (!ids.has(folder.id)) { const nextFiles = [...ids]; const idx = yield findNextIdx(nextFiles, folder, compareFiles); @@ -97,7 +97,7 @@ function* handleListFolder({ payload }) { if (path === currentPath) { const fileIds = new Set(items.map((item) => item.id)); - const selectedFiles = new Set(yield select(getSelectedFileIds)); + const selectedFiles = yield select(getSelectedFileIds); const fileIdsToDeselect = difference(selectedFiles, fileIds); yield put(fileActions.bulkDeselectFiles(fileIdsToDeselect)); } @@ -108,7 +108,7 @@ function* handleMoveFile(action) { const currPath = yield select(getCurrentPath); const parentPath = routes.parent(file.path); - const ids = yield select(getFilesByPath, currPath); + const ids = yield select(getFileIdsByPath, currPath); const nextFiles = [...ids.filter((id) => id !== file.id)]; if (parentPath === currPath) { const idx = yield findNextIdx(nextFiles, file, compareFiles); @@ -135,7 +135,7 @@ function* handleUpload(action) { } } if (target) { - const ids = new Set(yield select(getFilesByPath, currPath)); + const ids = new Set(yield select(getFileIdsByPath, currPath)); if (!ids.has(target.id)) { const nextFiles = [...ids]; const idx = yield findNextIdx(nextFiles, target, compareFiles);