feat: add filter by filesystem location + bonus tooltip on overflowed filter names (#518)

This commit is contained in:
FinalDoom
2025-01-14 12:54:32 -07:00
committed by GitHub
parent f550b54399
commit 77e6f3aae5
12 changed files with 311 additions and 26 deletions

View File

@@ -0,0 +1,62 @@
import {FC, KeyboardEvent, MouseEvent, ReactNode, TouchEvent} from 'react';
import {observer} from 'mobx-react';
import {useLingui} from '@lingui/react';
import {LocationTreeNode} from '@shared/types/Taxonomy';
import SidebarFilter from './SidebarFilter';
import TorrentFilterStore from '../../stores/TorrentFilterStore';
const buildLocationFilterTree = (location: LocationTreeNode): ReactNode => {
if (location.children.length === 1 && location.containedCount === location.children[0].containedCount) {
const onlyChild = location.children[0];
const separator = onlyChild.fullPath.includes('/') ? '/' : '\\';
return buildLocationFilterTree({
...onlyChild,
directoryName: location.directoryName + separator + onlyChild.directoryName,
});
}
const children = location.children.map(buildLocationFilterTree);
return (
<SidebarFilter
handleClick={(filter: string | '', event: KeyboardEvent | MouseEvent | TouchEvent) =>
TorrentFilterStore.setLocationFilters(filter, event)
}
count={location.containedCount}
key={location.fullPath}
isActive={
(location.fullPath === '' && !TorrentFilterStore.locationFilter.length) ||
TorrentFilterStore.locationFilter.includes(location.fullPath)
}
name={location.directoryName}
slug={location.fullPath}
size={location.containedSize}
>
{(children.length && children) || undefined}
</SidebarFilter>
);
};
const LocationFilters: FC = observer(() => {
const {i18n} = useLingui();
if (TorrentFilterStore.taxonomy.locationTree.containedCount === 0) {
return null;
}
const filterElements = buildLocationFilterTree(TorrentFilterStore.taxonomy.locationTree);
const title = i18n._('filter.location.title');
return (
<ul aria-label={title} className="sidebar-filter sidebar__item" role="menu">
<li className="sidebar-filter__item sidebar-filter__item--heading" role="none">
{title}
</li>
{filterElements}
</ul>
);
});
export default LocationFilters;

View File

@@ -4,6 +4,7 @@ import {OverlayScrollbarsComponent} from 'overlayscrollbars-react';
import DiskUsage from './DiskUsage';
import FeedsButton from './FeedsButton';
import LogoutButton from './LogoutButton';
import LocationFilters from './LocationFilters';
import NotificationsButton from './NotificationsButton';
import SearchBox from './SearchBox';
import SettingsButton from './SettingsButton';
@@ -44,6 +45,7 @@ const Sidebar: FC = () => (
<StatusFilters />
<TagFilters />
<TrackerFilters />
<LocationFilters />
<DiskUsage />
<div style={{flexGrow: 1}} />
<SidebarActions>

View File

@@ -1,11 +1,26 @@
import classnames from 'classnames';
import {FC, ReactNode, KeyboardEvent, MouseEvent, TouchEvent} from 'react';
import {createRef, FC, ReactNode, KeyboardEvent, MouseEvent, RefObject, TouchEvent, useEffect, useState} from 'react';
import {useLingui} from '@lingui/react';
import {Start} from '@client/ui/icons';
import Badge from '../general/Badge';
import Size from '../general/Size';
const useRefTextOverflowed = (ref: RefObject<HTMLElement>) => {
const [overflowed, setOverflowed] = useState(false);
useEffect(() => {
if (ref.current) {
const {current} = ref;
setOverflowed(current.scrollWidth > current.clientWidth);
}
}, [ref, ref?.current?.scrollWidth, ref?.current?.clientWidth]);
return overflowed;
};
interface SidebarFilterProps {
children?: ReactNode[];
name: string;
icon?: ReactNode;
isActive: boolean;
@@ -16,6 +31,7 @@ interface SidebarFilterProps {
}
const SidebarFilter: FC<SidebarFilterProps> = ({
children,
name: _name,
icon,
isActive,
@@ -24,11 +40,32 @@ const SidebarFilter: FC<SidebarFilterProps> = ({
size,
handleClick,
}: SidebarFilterProps) => {
const nameSpanRef = createRef<HTMLSpanElement>();
const overflowed = useRefTextOverflowed(nameSpanRef);
const {i18n} = useLingui();
const [expanded, setExpanded] = useState(false);
const classNames = classnames('sidebar-filter__item', {
'is-active': isActive,
});
const expanderClassNames = classnames('sidebar-filter__expander', {
'is-active': isActive,
expanded: expanded,
});
const flexCss = children
? {
display: 'flex',
}
: {};
const focusCss = {
':focus': {
outline: 'none',
WebkitTapHighlightColor: 'transparent',
},
};
let name = _name;
if (name === '') {
@@ -48,23 +85,35 @@ const SidebarFilter: FC<SidebarFilterProps> = ({
return (
<li>
<button
className={classNames}
css={{
':focus': {
outline: 'none',
WebkitTapHighlightColor: 'transparent',
},
}}
type="button"
onClick={(event) => handleClick(slug, event)}
role="menuitem"
>
{icon}
<span className="name">{name}</span>
<Badge>{count}</Badge>
{size != null && <Size value={size} className="size" />}
</button>
<div css={flexCss}>
{children && (
<button
className={expanderClassNames}
css={focusCss}
type="button"
onClick={() => setExpanded(!expanded)}
role="switch"
aria-checked={expanded}
>
<Start />
</button>
)}
<button
className={classNames}
css={focusCss}
type="button"
onClick={(event) => handleClick(slug, event)}
role="menuitem"
>
{icon}
<span className="name" ref={nameSpanRef} title={overflowed ? name || '' : undefined}>
{name}
</span>
<Badge>{count}</Badge>
{size != null && <Size value={size} className="size" />}
</button>
</div>
{children && expanded && <ul className="sidebar-filter__nested">{children}</ul>}
</li>
);
};

View File

@@ -139,6 +139,7 @@
"filter.status.seeding": "Seeding",
"filter.status.stopped": "Stopped",
"filter.status.title": "Filter by Status",
"filter.location.title": "Filter by Location",
"filter.tag.title": "Filter by Tag",
"filter.tracker.title": "Filter by Tracker",
"filter.untagged": "Untagged",

View File

@@ -6,6 +6,7 @@ import type {Taxonomy} from '@shared/types/Taxonomy';
import torrentStatusMap, {TorrentStatus} from '@shared/constants/torrentStatusMap';
class TorrentFilterStore {
locationFilter: Array<string> = [];
searchFilter = '';
statusFilter: Array<TorrentStatus> = [];
tagFilter: Array<string> = [];
@@ -14,6 +15,7 @@ class TorrentFilterStore {
filterTrigger = false;
taxonomy: Taxonomy = {
locationTree: {directoryName: '', fullPath: '', children: [], containedCount: 0, containedSize: 0},
statusCounts: {},
tagCounts: {},
tagSizes: {},
@@ -22,7 +24,13 @@ class TorrentFilterStore {
};
@computed get isFilterActive() {
return this.searchFilter !== '' || this.statusFilter.length || this.tagFilter.length || this.trackerFilter.length;
return (
this.locationFilter.length ||
this.searchFilter !== '' ||
this.statusFilter.length ||
this.tagFilter.length ||
this.trackerFilter.length
);
}
constructor() {
@@ -30,6 +38,7 @@ class TorrentFilterStore {
}
clearAllFilters() {
this.locationFilter = [];
this.searchFilter = '';
this.statusFilter = [];
this.tagFilter = [];
@@ -50,6 +59,12 @@ class TorrentFilterStore {
this.filterTrigger = !this.filterTrigger;
}
setLocationFilters(filter: string | '', event: KeyboardEvent | MouseEvent | TouchEvent) {
// keys: [] to disable shift-clicking as it doesn't make sense in a tree
this.computeFilters([], this.locationFilter, filter, event);
this.filterTrigger = !this.filterTrigger;
}
setStatusFilters(filter: TorrentStatus | '', event: KeyboardEvent | MouseEvent | TouchEvent) {
this.computeFilters(torrentStatusMap, this.statusFilter, filter, event);
this.filterTrigger = !this.filterTrigger;
@@ -85,7 +100,7 @@ class TorrentFilterStore {
) {
if (newFilter === ('' as T)) {
currentFilters.splice(0);
} else if (event.shiftKey) {
} else if (event.shiftKey && keys.length) {
if (currentFilters.length) {
const lastKey = currentFilters[currentFilters.length - 1];
const lastKeyIndex = keys.indexOf(lastKey);

View File

@@ -24,10 +24,17 @@ class TorrentStore {
}
@computed get filteredTorrents(): Array<TorrentProperties> {
const {searchFilter, statusFilter, tagFilter, trackerFilter} = TorrentFilterStore;
const {locationFilter, searchFilter, statusFilter, tagFilter, trackerFilter} = TorrentFilterStore;
let filteredTorrents = Object.assign([], this.sortedTorrents) as Array<TorrentProperties>;
if (locationFilter.length) {
filteredTorrents = filterTorrents(filteredTorrents, {
type: 'location',
filter: locationFilter,
});
}
if (searchFilter !== '') {
filteredTorrents = termMatch(filteredTorrents, (properties) => properties.name, searchFilter);
}

View File

@@ -1,6 +1,11 @@
import type {TorrentProperties} from '@shared/types/Torrent';
import type {TorrentStatus} from '@shared/constants/torrentStatusMap';
interface LocationFilter {
type: 'location';
filter: string[];
}
interface StatusFilter {
type: 'status';
filter: TorrentStatus[];
@@ -18,9 +23,13 @@ interface TagFilter {
function filterTorrents(
torrentList: TorrentProperties[],
opts: StatusFilter | TrackerFilter | TagFilter,
opts: LocationFilter | StatusFilter | TrackerFilter | TagFilter,
): TorrentProperties[] {
if (opts.filter.length) {
if (opts.type === 'location') {
return torrentList.filter((torrent) => opts.filter.some((directory) => torrent.directory.startsWith(directory)));
}
if (opts.type === 'status') {
return torrentList.filter((torrent) => torrent.status.some((status) => opts.filter.includes(status)));
}

View File

@@ -8,6 +8,7 @@
padding-top: 0;
}
&__expander,
&__item {
@include themes.theme('color', 'sidebar-filter--foreground');
cursor: pointer;
@@ -75,9 +76,24 @@
.size {
margin-left: auto;
white-space: nowrap;
}
}
&__expander {
display: block;
width: 14px;
padding: 0 0 0 20px;
&.expanded svg {
transform: rotate(90deg);
}
}
&__nested {
margin-left: 8px;
}
.badge {
@include themes.theme('background', 'sidebar-filter--count--background');
@include themes.theme('color', 'sidebar-filter--count--foreground');

View File

@@ -30,7 +30,9 @@ process.argv.push('--test');
process.argv.push('--assets', 'false');
afterAll((done) => {
process.kill(Number(fs.readFileSync(`${temporaryRuntimeDirectory}/rtorrent.pid`).toString()));
if (fs.existsSync(`${temporaryRuntimeDirectory}/rtorrent.pid`)) {
process.kill(Number(fs.readFileSync(`${temporaryRuntimeDirectory}/rtorrent.pid`).toString()));
}
if (process.env.CI !== 'true') {
// TODO: This leads to test flakiness caused by ENOENT error
// NeDB provides no method to close database connection

View File

@@ -0,0 +1,73 @@
import {UserInDatabase} from '@shared/schema/Auth';
import {LocationTreeNode} from '@shared/types/Taxonomy';
import TaxonomyService from '../../server/services/taxonomyService';
type LocationRecord = {[key: string]: LocationRecord | null};
const toTreeNodes = (locations: LocationRecord, separator = '/', basePath = '') =>
Object.keys(locations).reduce((parentNodes, locationKey) => {
const fullPath = locationKey !== '' ? basePath + separator + locationKey : locationKey;
const subLocations = locations[locationKey];
if (subLocations) {
const parent = {
directoryName: locationKey,
fullPath: fullPath,
children: toTreeNodes(subLocations, separator, fullPath),
containedCount: 0,
containedSize: 0,
};
for (const child of parent.children) {
parent.containedCount += child.containedCount;
parent.containedSize += child.containedSize;
}
parentNodes.push(parent);
} else {
parentNodes.push({
directoryName: locationKey,
fullPath: fullPath,
children: [],
containedCount: '' !== locationKey ? 1 : 0,
containedSize: '' !== locationKey ? 10 : 0,
});
}
return parentNodes;
}, [] as LocationTreeNode[]);
describe('taxonomyService', () => {
describe('incrementLocationCountsAndSizes() - locationTree', () => {
for (const locationsAndExpected of [
// No torrents
{locations: [] as string[], expected: toTreeNodes({'': null})[0]},
// Single root
{
locations: ['/mnt/dir1/file1', '/mnt/dir1/file2', '/mnt/dir2/file3', '/mnt/file4'],
expected: toTreeNodes({
'': {
mnt: {dir1: {file1: null, file2: null}, dir2: {file3: null}, file4: null},
},
})[0],
},
// Multiple roots including overlapping case
{
locations: ['/mnt/file1', '/mnt/file2', '/mount/directory1/file3', '/Mount/directory2/file4'],
expected: toTreeNodes({
'': {
mnt: {file1: null, file2: null},
mount: {directory1: {file3: null}},
Mount: {directory2: {file4: null}},
},
})[0],
},
]) {
const {locations, expected} = locationsAndExpected;
it(`builds case-sensitive location tree correctly from ${locations}`, () => {
const taxonomyService = new TaxonomyService({} as UserInDatabase);
for (const location of locations) taxonomyService.incrementLocationCountsAndSizes(location, 10);
expect(taxonomyService.taxonomy.locationTree).toMatchObject(expected);
});
}
});
});

View File

@@ -1,9 +1,9 @@
import type {TorrentStatus} from '@shared/constants/torrentStatusMap';
import type {LocationTreeNode, Taxonomy} from '@shared/types/Taxonomy';
import type {TorrentList, TorrentProperties} from '@shared/types/Torrent';
import jsonpatch, {Operation} from 'fast-json-patch';
import type {TorrentStatus} from '../../shared/constants/torrentStatusMap';
import torrentStatusMap from '../../shared/constants/torrentStatusMap';
import type {Taxonomy} from '../../shared/types/Taxonomy';
import type {TorrentList, TorrentProperties} from '../../shared/types/Torrent';
import BaseService from './BaseService';
type TaxonomyServiceEvents = {
@@ -12,6 +12,7 @@ type TaxonomyServiceEvents = {
class TaxonomyService extends BaseService<TaxonomyServiceEvents> {
taxonomy: Taxonomy = {
locationTree: {directoryName: '', fullPath: '', children: [], containedCount: 0, containedSize: 0},
statusCounts: {'': 0},
tagCounts: {'': 0, untagged: 0},
tagSizes: {},
@@ -60,6 +61,7 @@ class TaxonomyService extends BaseService<TaxonomyServiceEvents> {
handleProcessTorrentListStart = () => {
this.lastTaxonomy = {
locationTree: {...this.taxonomy.locationTree},
statusCounts: {...this.taxonomy.statusCounts},
tagCounts: {...this.taxonomy.tagCounts},
tagSizes: {...this.taxonomy.tagSizes},
@@ -71,6 +73,7 @@ class TaxonomyService extends BaseService<TaxonomyServiceEvents> {
this.taxonomy.statusCounts[status] = 0;
});
this.taxonomy.locationTree = {directoryName: '', fullPath: '', children: [], containedCount: 0, containedSize: 0};
this.taxonomy.statusCounts[''] = 0;
this.taxonomy.tagCounts = {'': 0, untagged: 0};
this.taxonomy.tagSizes = {};
@@ -96,6 +99,7 @@ class TaxonomyService extends BaseService<TaxonomyServiceEvents> {
};
handleProcessTorrent = (torrentProperties: TorrentProperties) => {
this.incrementLocationCountsAndSizes(torrentProperties.directory, torrentProperties.sizeBytes);
this.incrementStatusCounts(torrentProperties.status);
this.incrementTagCounts(torrentProperties.tags);
this.incrementTagSizes(torrentProperties.tags, torrentProperties.sizeBytes);
@@ -103,6 +107,42 @@ class TaxonomyService extends BaseService<TaxonomyServiceEvents> {
this.incrementTrackerSizes(torrentProperties.trackerURIs, torrentProperties.sizeBytes);
};
incrementLocationCountsAndSizes(
directory: TorrentProperties['directory'],
sizeBytes: TorrentProperties['sizeBytes'],
) {
const separator = directory.includes('/') ? '/' : '\\';
const countSizeAndBytesForHierarchy = (parent: LocationTreeNode, pathSplit: string[]) => {
const [nodeName, ...restOfPath] = pathSplit;
let nodeRoot = parent.children.find((treeNode) => treeNode.directoryName === nodeName);
if (!nodeRoot) {
nodeRoot = {
directoryName: nodeName,
fullPath: parent.fullPath + separator + nodeName,
children: [],
containedCount: 0,
containedSize: 0,
};
parent.children.push(nodeRoot);
}
nodeRoot.containedCount += 1;
nodeRoot.containedSize += sizeBytes;
if (restOfPath.length) {
countSizeAndBytesForHierarchy(nodeRoot, restOfPath);
}
};
const pathSplit = directory.startsWith(separator)
? directory.split(separator).slice(1)
: directory.split(separator);
countSizeAndBytesForHierarchy(this.taxonomy.locationTree, pathSplit);
this.taxonomy.locationTree.containedCount += 1;
this.taxonomy.locationTree.containedSize += sizeBytes;
}
incrementStatusCounts(statuses: Array<TorrentStatus>) {
statuses.forEach((status) => {
this.taxonomy.statusCounts[status] += 1;

View File

@@ -1,4 +1,13 @@
export interface LocationTreeNode {
directoryName: string;
fullPath: string;
children: LocationTreeNode[];
containedCount: number;
containedSize: number;
}
export interface Taxonomy {
locationTree: LocationTreeNode;
statusCounts: Record<string, number>;
tagCounts: Record<string, number>;
tagSizes: Record<string, number>;