Skip to content

Commit 77e6f3a

Browse files
authored
feat: add filter by filesystem location + bonus tooltip on overflowed filter names (#518)
1 parent f550b54 commit 77e6f3a

File tree

12 files changed

+311
-26
lines changed

12 files changed

+311
-26
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {FC, KeyboardEvent, MouseEvent, ReactNode, TouchEvent} from 'react';
2+
import {observer} from 'mobx-react';
3+
import {useLingui} from '@lingui/react';
4+
import {LocationTreeNode} from '@shared/types/Taxonomy';
5+
6+
import SidebarFilter from './SidebarFilter';
7+
import TorrentFilterStore from '../../stores/TorrentFilterStore';
8+
9+
const buildLocationFilterTree = (location: LocationTreeNode): ReactNode => {
10+
if (location.children.length === 1 && location.containedCount === location.children[0].containedCount) {
11+
const onlyChild = location.children[0];
12+
const separator = onlyChild.fullPath.includes('/') ? '/' : '\\';
13+
return buildLocationFilterTree({
14+
...onlyChild,
15+
directoryName: location.directoryName + separator + onlyChild.directoryName,
16+
});
17+
}
18+
19+
const children = location.children.map(buildLocationFilterTree);
20+
21+
return (
22+
<SidebarFilter
23+
handleClick={(filter: string | '', event: KeyboardEvent | MouseEvent | TouchEvent) =>
24+
TorrentFilterStore.setLocationFilters(filter, event)
25+
}
26+
count={location.containedCount}
27+
key={location.fullPath}
28+
isActive={
29+
(location.fullPath === '' && !TorrentFilterStore.locationFilter.length) ||
30+
TorrentFilterStore.locationFilter.includes(location.fullPath)
31+
}
32+
name={location.directoryName}
33+
slug={location.fullPath}
34+
size={location.containedSize}
35+
>
36+
{(children.length && children) || undefined}
37+
</SidebarFilter>
38+
);
39+
};
40+
41+
const LocationFilters: FC = observer(() => {
42+
const {i18n} = useLingui();
43+
44+
if (TorrentFilterStore.taxonomy.locationTree.containedCount === 0) {
45+
return null;
46+
}
47+
48+
const filterElements = buildLocationFilterTree(TorrentFilterStore.taxonomy.locationTree);
49+
50+
const title = i18n._('filter.location.title');
51+
52+
return (
53+
<ul aria-label={title} className="sidebar-filter sidebar__item" role="menu">
54+
<li className="sidebar-filter__item sidebar-filter__item--heading" role="none">
55+
{title}
56+
</li>
57+
{filterElements}
58+
</ul>
59+
);
60+
});
61+
62+
export default LocationFilters;

client/src/javascript/components/sidebar/Sidebar.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {OverlayScrollbarsComponent} from 'overlayscrollbars-react';
44
import DiskUsage from './DiskUsage';
55
import FeedsButton from './FeedsButton';
66
import LogoutButton from './LogoutButton';
7+
import LocationFilters from './LocationFilters';
78
import NotificationsButton from './NotificationsButton';
89
import SearchBox from './SearchBox';
910
import SettingsButton from './SettingsButton';
@@ -44,6 +45,7 @@ const Sidebar: FC = () => (
4445
<StatusFilters />
4546
<TagFilters />
4647
<TrackerFilters />
48+
<LocationFilters />
4749
<DiskUsage />
4850
<div style={{flexGrow: 1}} />
4951
<SidebarActions>

client/src/javascript/components/sidebar/SidebarFilter.tsx

+67-18
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
11
import classnames from 'classnames';
2-
import {FC, ReactNode, KeyboardEvent, MouseEvent, TouchEvent} from 'react';
2+
import {createRef, FC, ReactNode, KeyboardEvent, MouseEvent, RefObject, TouchEvent, useEffect, useState} from 'react';
33
import {useLingui} from '@lingui/react';
4+
import {Start} from '@client/ui/icons';
45

56
import Badge from '../general/Badge';
67
import Size from '../general/Size';
78

9+
const useRefTextOverflowed = (ref: RefObject<HTMLElement>) => {
10+
const [overflowed, setOverflowed] = useState(false);
11+
12+
useEffect(() => {
13+
if (ref.current) {
14+
const {current} = ref;
15+
setOverflowed(current.scrollWidth > current.clientWidth);
16+
}
17+
}, [ref, ref?.current?.scrollWidth, ref?.current?.clientWidth]);
18+
19+
return overflowed;
20+
};
21+
822
interface SidebarFilterProps {
23+
children?: ReactNode[];
924
name: string;
1025
icon?: ReactNode;
1126
isActive: boolean;
@@ -16,6 +31,7 @@ interface SidebarFilterProps {
1631
}
1732

1833
const SidebarFilter: FC<SidebarFilterProps> = ({
34+
children,
1935
name: _name,
2036
icon,
2137
isActive,
@@ -24,11 +40,32 @@ const SidebarFilter: FC<SidebarFilterProps> = ({
2440
size,
2541
handleClick,
2642
}: SidebarFilterProps) => {
43+
const nameSpanRef = createRef<HTMLSpanElement>();
44+
const overflowed = useRefTextOverflowed(nameSpanRef);
45+
2746
const {i18n} = useLingui();
2847

48+
const [expanded, setExpanded] = useState(false);
49+
2950
const classNames = classnames('sidebar-filter__item', {
3051
'is-active': isActive,
3152
});
53+
const expanderClassNames = classnames('sidebar-filter__expander', {
54+
'is-active': isActive,
55+
expanded: expanded,
56+
});
57+
58+
const flexCss = children
59+
? {
60+
display: 'flex',
61+
}
62+
: {};
63+
const focusCss = {
64+
':focus': {
65+
outline: 'none',
66+
WebkitTapHighlightColor: 'transparent',
67+
},
68+
};
3269

3370
let name = _name;
3471
if (name === '') {
@@ -48,23 +85,35 @@ const SidebarFilter: FC<SidebarFilterProps> = ({
4885

4986
return (
5087
<li>
51-
<button
52-
className={classNames}
53-
css={{
54-
':focus': {
55-
outline: 'none',
56-
WebkitTapHighlightColor: 'transparent',
57-
},
58-
}}
59-
type="button"
60-
onClick={(event) => handleClick(slug, event)}
61-
role="menuitem"
62-
>
63-
{icon}
64-
<span className="name">{name}</span>
65-
<Badge>{count}</Badge>
66-
{size != null && <Size value={size} className="size" />}
67-
</button>
88+
<div css={flexCss}>
89+
{children && (
90+
<button
91+
className={expanderClassNames}
92+
css={focusCss}
93+
type="button"
94+
onClick={() => setExpanded(!expanded)}
95+
role="switch"
96+
aria-checked={expanded}
97+
>
98+
<Start />
99+
</button>
100+
)}
101+
<button
102+
className={classNames}
103+
css={focusCss}
104+
type="button"
105+
onClick={(event) => handleClick(slug, event)}
106+
role="menuitem"
107+
>
108+
{icon}
109+
<span className="name" ref={nameSpanRef} title={overflowed ? name || '' : undefined}>
110+
{name}
111+
</span>
112+
<Badge>{count}</Badge>
113+
{size != null && <Size value={size} className="size" />}
114+
</button>
115+
</div>
116+
{children && expanded && <ul className="sidebar-filter__nested">{children}</ul>}
68117
</li>
69118
);
70119
};

client/src/javascript/i18n/strings/en.json

+1
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@
139139
"filter.status.seeding": "Seeding",
140140
"filter.status.stopped": "Stopped",
141141
"filter.status.title": "Filter by Status",
142+
"filter.location.title": "Filter by Location",
142143
"filter.tag.title": "Filter by Tag",
143144
"filter.tracker.title": "Filter by Tracker",
144145
"filter.untagged": "Untagged",

client/src/javascript/stores/TorrentFilterStore.ts

+17-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {Taxonomy} from '@shared/types/Taxonomy';
66
import torrentStatusMap, {TorrentStatus} from '@shared/constants/torrentStatusMap';
77

88
class TorrentFilterStore {
9+
locationFilter: Array<string> = [];
910
searchFilter = '';
1011
statusFilter: Array<TorrentStatus> = [];
1112
tagFilter: Array<string> = [];
@@ -14,6 +15,7 @@ class TorrentFilterStore {
1415
filterTrigger = false;
1516

1617
taxonomy: Taxonomy = {
18+
locationTree: {directoryName: '', fullPath: '', children: [], containedCount: 0, containedSize: 0},
1719
statusCounts: {},
1820
tagCounts: {},
1921
tagSizes: {},
@@ -22,14 +24,21 @@ class TorrentFilterStore {
2224
};
2325

2426
@computed get isFilterActive() {
25-
return this.searchFilter !== '' || this.statusFilter.length || this.tagFilter.length || this.trackerFilter.length;
27+
return (
28+
this.locationFilter.length ||
29+
this.searchFilter !== '' ||
30+
this.statusFilter.length ||
31+
this.tagFilter.length ||
32+
this.trackerFilter.length
33+
);
2634
}
2735

2836
constructor() {
2937
makeAutoObservable(this);
3038
}
3139

3240
clearAllFilters() {
41+
this.locationFilter = [];
3342
this.searchFilter = '';
3443
this.statusFilter = [];
3544
this.tagFilter = [];
@@ -50,6 +59,12 @@ class TorrentFilterStore {
5059
this.filterTrigger = !this.filterTrigger;
5160
}
5261

62+
setLocationFilters(filter: string | '', event: KeyboardEvent | MouseEvent | TouchEvent) {
63+
// keys: [] to disable shift-clicking as it doesn't make sense in a tree
64+
this.computeFilters([], this.locationFilter, filter, event);
65+
this.filterTrigger = !this.filterTrigger;
66+
}
67+
5368
setStatusFilters(filter: TorrentStatus | '', event: KeyboardEvent | MouseEvent | TouchEvent) {
5469
this.computeFilters(torrentStatusMap, this.statusFilter, filter, event);
5570
this.filterTrigger = !this.filterTrigger;
@@ -85,7 +100,7 @@ class TorrentFilterStore {
85100
) {
86101
if (newFilter === ('' as T)) {
87102
currentFilters.splice(0);
88-
} else if (event.shiftKey) {
103+
} else if (event.shiftKey && keys.length) {
89104
if (currentFilters.length) {
90105
const lastKey = currentFilters[currentFilters.length - 1];
91106
const lastKeyIndex = keys.indexOf(lastKey);

client/src/javascript/stores/TorrentStore.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,17 @@ class TorrentStore {
2424
}
2525

2626
@computed get filteredTorrents(): Array<TorrentProperties> {
27-
const {searchFilter, statusFilter, tagFilter, trackerFilter} = TorrentFilterStore;
27+
const {locationFilter, searchFilter, statusFilter, tagFilter, trackerFilter} = TorrentFilterStore;
2828

2929
let filteredTorrents = Object.assign([], this.sortedTorrents) as Array<TorrentProperties>;
3030

31+
if (locationFilter.length) {
32+
filteredTorrents = filterTorrents(filteredTorrents, {
33+
type: 'location',
34+
filter: locationFilter,
35+
});
36+
}
37+
3138
if (searchFilter !== '') {
3239
filteredTorrents = termMatch(filteredTorrents, (properties) => properties.name, searchFilter);
3340
}

client/src/javascript/util/filterTorrents.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type {TorrentProperties} from '@shared/types/Torrent';
22
import type {TorrentStatus} from '@shared/constants/torrentStatusMap';
33

4+
interface LocationFilter {
5+
type: 'location';
6+
filter: string[];
7+
}
8+
49
interface StatusFilter {
510
type: 'status';
611
filter: TorrentStatus[];
@@ -18,9 +23,13 @@ interface TagFilter {
1823

1924
function filterTorrents(
2025
torrentList: TorrentProperties[],
21-
opts: StatusFilter | TrackerFilter | TagFilter,
26+
opts: LocationFilter | StatusFilter | TrackerFilter | TagFilter,
2227
): TorrentProperties[] {
2328
if (opts.filter.length) {
29+
if (opts.type === 'location') {
30+
return torrentList.filter((torrent) => opts.filter.some((directory) => torrent.directory.startsWith(directory)));
31+
}
32+
2433
if (opts.type === 'status') {
2534
return torrentList.filter((torrent) => torrent.status.some((status) => opts.filter.includes(status)));
2635
}

client/src/sass/components/_sidebar-filter.scss

+16
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
padding-top: 0;
99
}
1010

11+
&__expander,
1112
&__item {
1213
@include themes.theme('color', 'sidebar-filter--foreground');
1314
cursor: pointer;
@@ -75,9 +76,24 @@
7576

7677
.size {
7778
margin-left: auto;
79+
white-space: nowrap;
7880
}
7981
}
8082

83+
&__expander {
84+
display: block;
85+
width: 14px;
86+
padding: 0 0 0 20px;
87+
88+
&.expanded svg {
89+
transform: rotate(90deg);
90+
}
91+
}
92+
93+
&__nested {
94+
margin-left: 8px;
95+
}
96+
8197
.badge {
8298
@include themes.theme('background', 'sidebar-filter--count--background');
8399
@include themes.theme('color', 'sidebar-filter--count--foreground');

server/.jest/rtorrent.setup.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ process.argv.push('--test');
3030
process.argv.push('--assets', 'false');
3131

3232
afterAll((done) => {
33-
process.kill(Number(fs.readFileSync(`${temporaryRuntimeDirectory}/rtorrent.pid`).toString()));
33+
if (fs.existsSync(`${temporaryRuntimeDirectory}/rtorrent.pid`)) {
34+
process.kill(Number(fs.readFileSync(`${temporaryRuntimeDirectory}/rtorrent.pid`).toString()));
35+
}
3436
if (process.env.CI !== 'true') {
3537
// TODO: This leads to test flakiness caused by ENOENT error
3638
// NeDB provides no method to close database connection

0 commit comments

Comments
 (0)