mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
File Browser: add filter input and collapse-all button
Add a client-side filter above the File Browser table. Typing reduces the visible rows to files/directories whose name matches, plus their parent folders. A folder whose own name matches keeps its entire subtree, and opening a matching folder shows its contents unfiltered. * 'f' focuses the filter input, like the search inputs on other pages. * An attached X button clears the filter; disabled while empty. * A collapse-all (angle double up) button closes all open folders, clearing the ?folders= query param and deselecting hidden children. * The filter row is right-aligned and double-width on desktop, and fills a single row on mobile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2162396212
commit
8044fd1240
3 changed files with 396 additions and 3 deletions
|
|
@ -454,10 +454,33 @@ tr.drag-deselecting td {
|
|||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Filter input and collapse-all button: right-aligned on desktop, full width on mobile (below). */
|
||||
.file-browser-filter-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* Double the default input width on desktop. */
|
||||
.ui.input.file-browser-filter {
|
||||
width: 34em;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 700px) {
|
||||
.hide-on-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.file-browser-filter-container {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Fill the row, but stay on one line with the collapse-all button. */
|
||||
.ui.input.file-browser-filter {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure step icons are always visible, even when disabled */
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
} from "./Common";
|
||||
import React, {useContext, useEffect, useState} from "react";
|
||||
import {useDropzone} from "react-dropzone";
|
||||
import {useHotkeys} from "react-hotkeys-hook";
|
||||
import {deleteFile, ignoreDirectory, makeDirectory, movePaths, renamePath, unignoreDirectory} from "../api";
|
||||
import _ from 'lodash';
|
||||
import {SortableTable} from "./SortableTable";
|
||||
|
|
@ -67,6 +68,32 @@ export function splitPathParentAndName(path) {
|
|||
return [path.substring(0, slashIndex), path.substring(slashIndex + 1, path.length)];
|
||||
}
|
||||
|
||||
export function filterBrowseFiles(browseFiles, filterStr) {
|
||||
// Reduce the file tree to the rows whose name matches `filterStr`, keeping the ancestor
|
||||
// folders of every match. A folder whose own name matches keeps its entire subtree.
|
||||
if (!filterStr) {
|
||||
return browseFiles;
|
||||
}
|
||||
const needle = filterStr.toLowerCase();
|
||||
const filterList = (files) => {
|
||||
// `files` may be a dict keyed by name (from the API) or an array.
|
||||
const fileList = Array.isArray(files) ? files : Object.values(files);
|
||||
const matches = [];
|
||||
for (const file of fileList) {
|
||||
if (pathName(file.path).toLowerCase().includes(needle)) {
|
||||
matches.push(file);
|
||||
} else if (file.children) {
|
||||
const children = filterList(file.children);
|
||||
if (children.length > 0) {
|
||||
matches.push({...file, children});
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
return filterList(browseFiles);
|
||||
}
|
||||
|
||||
function Folder({folder, onFolderClick, sortData, onFileClick, disabled, loadingFolders}) {
|
||||
const {handleDragStart, handleDragMove, getDragState, shouldAllowClick, selectedPaths, onSelect} = useContext(DragSelectionContext);
|
||||
// Creates a single table row for a folder, or a row for itself and indented rows for its children.
|
||||
|
|
@ -219,6 +246,17 @@ export function FileBrowser() {
|
|||
const pendingAutoSelectRef = React.useRef(null);
|
||||
// Tagged file groups that would be deleted - shown in confirmation modal
|
||||
const [taggedFileGroups, setTaggedFileGroups] = React.useState(null);
|
||||
// Reduces the visible rows to those matching, and their parent folders.
|
||||
const [filterStr, setFilterStr] = React.useState('');
|
||||
const filterInputRef = React.useRef();
|
||||
|
||||
// 'f' focuses the filter input, like the search inputs on other pages.
|
||||
useHotkeys('f', (e) => {
|
||||
e.preventDefault();
|
||||
if (filterInputRef.current) {
|
||||
filterInputRef.current.focus();
|
||||
}
|
||||
}, {enableOnFormTags: false});
|
||||
|
||||
const {settings, fetchSettings} = React.useContext(SettingsContext);
|
||||
const {inverted} = React.useContext(ThemeContext);
|
||||
|
|
@ -457,6 +495,17 @@ export function FileBrowser() {
|
|||
/>
|
||||
</div>;
|
||||
|
||||
const onCollapseAll = () => {
|
||||
if (!openFolders) return;
|
||||
// Deselect any paths that will no longer be visible (descendants of the open folders).
|
||||
const newSelectedPaths = selectedPaths.filter(p =>
|
||||
!openFolders.some(folder => p.startsWith(folder) && p !== folder));
|
||||
if (newSelectedPaths.length !== selectedPaths.length) {
|
||||
setSelectedPaths(newSelectedPaths);
|
||||
}
|
||||
setOpenFolders(null);
|
||||
}
|
||||
|
||||
const onFolderClick = async (folder) => {
|
||||
let newFolders;
|
||||
if (openFolders.indexOf(folder) >= 0) {
|
||||
|
|
@ -481,6 +530,11 @@ export function FileBrowser() {
|
|||
setOpenFolders(newFolders);
|
||||
}
|
||||
|
||||
const filteredFiles = React.useMemo(
|
||||
() => browseFiles ? filterBrowseFiles(browseFiles, filterStr) : browseFiles,
|
||||
[browseFiles, filterStr],
|
||||
);
|
||||
|
||||
if (browseFiles === null) {
|
||||
return <Placeholder>
|
||||
<PlaceholderLine/>
|
||||
|
|
@ -493,11 +547,40 @@ export function FileBrowser() {
|
|||
return <DragSelectionProvider
|
||||
selectedPaths={selectedPaths}
|
||||
setSelectedPaths={setSelectedPaths}
|
||||
browseFiles={browseFiles}
|
||||
browseFiles={filteredFiles}
|
||||
openFolders={openFolders}
|
||||
>
|
||||
<div className='file-browser-filter-container'>
|
||||
<Input
|
||||
icon='filter'
|
||||
iconPosition='left'
|
||||
placeholder='Filter files...'
|
||||
value={filterStr}
|
||||
onChange={(e, {value}) => setFilterStr(value)}
|
||||
aria-label='Filter files'
|
||||
className='file-browser-filter'
|
||||
ref={filterInputRef}
|
||||
action={<Button
|
||||
icon='close'
|
||||
type='button'
|
||||
onClick={() => setFilterStr('')}
|
||||
disabled={!filterStr}
|
||||
className='search-clear'
|
||||
aria-label='Clear filter'
|
||||
/>}
|
||||
/>
|
||||
<Button
|
||||
icon='angle double up'
|
||||
type='button'
|
||||
onClick={onCollapseAll}
|
||||
disabled={!openFolders || openFolders.length === 0}
|
||||
title='Close all folders'
|
||||
aria-label='Close all folders'
|
||||
style={{marginLeft: '0.5em'}}
|
||||
/>
|
||||
</div>
|
||||
<FileBrowserContent
|
||||
browseFiles={browseFiles}
|
||||
browseFiles={filteredFiles}
|
||||
headers={headers}
|
||||
onFolderClick={onFolderClick}
|
||||
setPreviewFile={setPreviewFile}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import {act, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
import {FileBrowser} from './FileBrowser';
|
||||
import {FileBrowser, filterBrowseFiles} from './FileBrowser';
|
||||
import {renderWithProviders} from '../test-utils';
|
||||
import {SettingsContext} from '../contexts/contexts';
|
||||
import {FilePreviewContext} from './FilePreview';
|
||||
|
|
@ -478,6 +478,293 @@ describe('FileBrowser', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Filter', () => {
|
||||
// foo/ and bar/ are open, their children are loaded.
|
||||
const nestedBrowseFiles = [
|
||||
{
|
||||
path: 'foo/', is_empty: false, children: [
|
||||
{path: 'foo/foo1.txt', size: 100},
|
||||
{path: 'foo/foo2.txt', size: 200},
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'bar/', is_empty: false, children: [
|
||||
{path: 'bar/bar.1.txt', size: 300},
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
describe('filterBrowseFiles', () => {
|
||||
it('returns files unchanged when filter is empty', () => {
|
||||
expect(filterBrowseFiles(nestedBrowseFiles, '')).toBe(nestedBrowseFiles);
|
||||
});
|
||||
|
||||
it('keeps a matching folder and its entire subtree', () => {
|
||||
const result = filterBrowseFiles(nestedBrowseFiles, 'bar');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe('bar/');
|
||||
expect(result[0].children).toHaveLength(1);
|
||||
expect(result[0].children[0].path).toBe('bar/bar.1.txt');
|
||||
});
|
||||
|
||||
it('keeps the parent folders of a matching file', () => {
|
||||
const result = filterBrowseFiles(nestedBrowseFiles, 'foo1');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe('foo/');
|
||||
expect(result[0].children).toHaveLength(1);
|
||||
expect(result[0].children[0].path).toBe('foo/foo1.txt');
|
||||
});
|
||||
|
||||
it('matches case-insensitively', () => {
|
||||
const result = filterBrowseFiles(nestedBrowseFiles, 'FOO1');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].children[0].path).toBe('foo/foo1.txt');
|
||||
});
|
||||
|
||||
it('handles the dict format returned by the API', () => {
|
||||
const dictFiles = {
|
||||
'foo/': {
|
||||
path: 'foo/', is_empty: false, children: {
|
||||
'foo1.txt': {path: 'foo/foo1.txt', size: 100},
|
||||
}
|
||||
},
|
||||
'bar.txt': {path: 'bar.txt', size: 300},
|
||||
};
|
||||
const result = filterBrowseFiles(dictFiles, 'bar');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe('bar.txt');
|
||||
});
|
||||
|
||||
it('returns no rows when nothing matches', () => {
|
||||
expect(filterBrowseFiles(nestedBrowseFiles, 'does-not-exist')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('reduces the rows to matches and their tree', async () => {
|
||||
useBrowseFiles.mockReturnValue({
|
||||
browseFiles: nestedBrowseFiles,
|
||||
openFolders: ['foo/', 'bar/'],
|
||||
setOpenFolders: jest.fn(),
|
||||
fetchFiles: jest.fn(),
|
||||
});
|
||||
|
||||
renderFileBrowser(<FileBrowser/>);
|
||||
|
||||
// All rows are visible before filtering.
|
||||
expect(screen.getByText('foo1.txt')).toBeInTheDocument();
|
||||
expect(screen.getByText('bar.1.txt')).toBeInTheDocument();
|
||||
|
||||
const filterInput = screen.getByPlaceholderText('Filter files...');
|
||||
await act(async () => {
|
||||
fireEvent.change(filterInput, {target: {value: 'bar'}});
|
||||
});
|
||||
|
||||
// Only bar/ and its child remain.
|
||||
expect(screen.getByText('bar/')).toBeInTheDocument();
|
||||
expect(screen.getByText('bar.1.txt')).toBeInTheDocument();
|
||||
expect(screen.queryByText('foo/')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('foo1.txt')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('foo2.txt')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('restores all rows when the filter is cleared', async () => {
|
||||
useBrowseFiles.mockReturnValue({
|
||||
browseFiles: nestedBrowseFiles,
|
||||
openFolders: ['foo/', 'bar/'],
|
||||
setOpenFolders: jest.fn(),
|
||||
fetchFiles: jest.fn(),
|
||||
});
|
||||
|
||||
renderFileBrowser(<FileBrowser/>);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText('Filter files...');
|
||||
await act(async () => {
|
||||
fireEvent.change(filterInput, {target: {value: 'bar'}});
|
||||
});
|
||||
expect(screen.queryByText('foo1.txt')).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(filterInput, {target: {value: ''}});
|
||||
});
|
||||
expect(screen.getByText('foo1.txt')).toBeInTheDocument();
|
||||
expect(screen.getByText('bar.1.txt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not filter the contents of an opened folder that matches the filter', async () => {
|
||||
const mockSetOpenFolders = jest.fn();
|
||||
|
||||
// ebooks/ is closed, no children loaded yet.
|
||||
useBrowseFiles.mockReturnValue({
|
||||
browseFiles: [
|
||||
{path: 'ebooks/', children: null, is_empty: false},
|
||||
{path: 'other.txt', size: 100},
|
||||
],
|
||||
openFolders: [],
|
||||
setOpenFolders: mockSetOpenFolders,
|
||||
fetchFiles: jest.fn(),
|
||||
});
|
||||
|
||||
const {rerender} = renderFileBrowser(<FileBrowser/>);
|
||||
|
||||
// Filter for the folder's name.
|
||||
const filterInput = screen.getByPlaceholderText('Filter files...');
|
||||
await act(async () => {
|
||||
fireEvent.change(filterInput, {target: {value: 'ebooks'}});
|
||||
});
|
||||
expect(screen.queryByText('other.txt')).not.toBeInTheDocument();
|
||||
|
||||
// Open the matching folder.
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('ebooks/'));
|
||||
});
|
||||
expect(mockSetOpenFolders).toHaveBeenCalledWith(['ebooks/']);
|
||||
|
||||
// Simulate the folder's children loading; their names do NOT match the filter.
|
||||
useBrowseFiles.mockReturnValue({
|
||||
browseFiles: [
|
||||
{path: 'ebooks/', children: [
|
||||
{path: 'ebooks/novel.epub', size: 100},
|
||||
{path: 'ebooks/cover.jpg', size: 200},
|
||||
], is_empty: false},
|
||||
{path: 'other.txt', size: 100},
|
||||
],
|
||||
openFolders: ['ebooks/'],
|
||||
setOpenFolders: mockSetOpenFolders,
|
||||
fetchFiles: jest.fn(),
|
||||
});
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<AllContextsWrapper>
|
||||
<FileBrowser/>
|
||||
</AllContextsWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
// The matching folder's contents are shown unfiltered.
|
||||
expect(screen.getByText('ebooks/')).toBeInTheDocument();
|
||||
expect(screen.getByText('novel.epub')).toBeInTheDocument();
|
||||
expect(screen.getByText('cover.jpg')).toBeInTheDocument();
|
||||
// Non-matching rows outside the folder stay hidden.
|
||||
expect(screen.queryByText('other.txt')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('focuses the filter input when f is pressed', async () => {
|
||||
useBrowseFiles.mockReturnValue({
|
||||
browseFiles: nestedBrowseFiles,
|
||||
openFolders: ['foo/', 'bar/'],
|
||||
setOpenFolders: jest.fn(),
|
||||
fetchFiles: jest.fn(),
|
||||
});
|
||||
|
||||
renderFileBrowser(<FileBrowser/>);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText('Filter files...');
|
||||
expect(filterInput).not.toHaveFocus();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document.body, {key: 'f', code: 'KeyF'});
|
||||
});
|
||||
|
||||
expect(filterInput).toHaveFocus();
|
||||
});
|
||||
|
||||
it('clear button clears the filter and shows all rows again', async () => {
|
||||
useBrowseFiles.mockReturnValue({
|
||||
browseFiles: nestedBrowseFiles,
|
||||
openFolders: ['foo/', 'bar/'],
|
||||
setOpenFolders: jest.fn(),
|
||||
fetchFiles: jest.fn(),
|
||||
});
|
||||
|
||||
renderFileBrowser(<FileBrowser/>);
|
||||
|
||||
const clearButton = screen.getByLabelText('Clear filter');
|
||||
expect(clearButton).toBeDisabled();
|
||||
|
||||
const filterInput = screen.getByPlaceholderText('Filter files...');
|
||||
await act(async () => {
|
||||
fireEvent.change(filterInput, {target: {value: 'bar'}});
|
||||
});
|
||||
expect(screen.queryByText('foo1.txt')).not.toBeInTheDocument();
|
||||
expect(clearButton).not.toBeDisabled();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(clearButton);
|
||||
});
|
||||
|
||||
expect(filterInput).toHaveValue('');
|
||||
expect(screen.getByText('foo1.txt')).toBeInTheDocument();
|
||||
expect(screen.getByText('foo2.txt')).toBeInTheDocument();
|
||||
expect(screen.getByText('bar.1.txt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapse-all button is disabled when no folders are open', async () => {
|
||||
useBrowseFiles.mockReturnValue({
|
||||
browseFiles: nestedBrowseFiles,
|
||||
openFolders: [],
|
||||
setOpenFolders: jest.fn(),
|
||||
fetchFiles: jest.fn(),
|
||||
});
|
||||
|
||||
renderFileBrowser(<FileBrowser/>);
|
||||
|
||||
expect(screen.getByLabelText('Close all folders')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('collapse-all button closes all folders and deselects their hidden contents', async () => {
|
||||
const mockSetOpenFolders = jest.fn();
|
||||
useBrowseFiles.mockReturnValue({
|
||||
browseFiles: nestedBrowseFiles,
|
||||
openFolders: ['foo/', 'bar/'],
|
||||
setOpenFolders: mockSetOpenFolders,
|
||||
fetchFiles: jest.fn(),
|
||||
});
|
||||
|
||||
renderFileBrowser(<FileBrowser/>);
|
||||
|
||||
// Select the foo/ folder and a file inside it.
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await act(async () => {
|
||||
fireEvent.click(checkboxes[0]); // foo/
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(checkboxes[1]); // foo/foo1.txt
|
||||
});
|
||||
expect(checkboxes[0]).toBeChecked();
|
||||
expect(checkboxes[1]).toBeChecked();
|
||||
|
||||
const collapseButton = screen.getByLabelText('Close all folders');
|
||||
expect(collapseButton).not.toBeDisabled();
|
||||
await act(async () => {
|
||||
fireEvent.click(collapseButton);
|
||||
});
|
||||
|
||||
// All folders are closed (clears the ?folders= query param).
|
||||
expect(mockSetOpenFolders).toHaveBeenCalledWith(null);
|
||||
// The folder stays selected, but its now-hidden child does not.
|
||||
expect(checkboxes[0]).toBeChecked();
|
||||
expect(checkboxes[1]).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('shows no results when nothing matches', async () => {
|
||||
useBrowseFiles.mockReturnValue({
|
||||
browseFiles: nestedBrowseFiles,
|
||||
openFolders: ['foo/', 'bar/'],
|
||||
setOpenFolders: jest.fn(),
|
||||
fetchFiles: jest.fn(),
|
||||
});
|
||||
|
||||
renderFileBrowser(<FileBrowser/>);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText('Filter files...');
|
||||
await act(async () => {
|
||||
fireEvent.change(filterInput, {target: {value: 'does-not-exist'}});
|
||||
});
|
||||
|
||||
expect(screen.getByText('No results')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drag Selection', () => {
|
||||
const mockBrowseFilesMultiple = [
|
||||
{path: 'file1.txt', size: 100},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue