Skip to content

Commit bb7aa54

Browse files
authored
Allow changing column visibility (#8392)
2 parents 9388f4a + 586ae28 commit bb7aa54

File tree

16 files changed

+187
-104
lines changed

16 files changed

+187
-104
lines changed

src/tribler/ui/package-lock.json

-27
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/tribler/ui/package.json

-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
"class-variance-authority": "^0.7.0",
3131
"clsx": "^2.0.0",
3232
"i18next": "^23.11.4",
33-
"javascript-time-ago": "^2.5.10",
3433
"js-cookie": "^3.0.5",
3534
"jszip": "^3.10.1",
3635
"lucide-react": "^0.292.0",

src/tribler/ui/src/components/swarm-health.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
33
import toast from 'react-hot-toast';
44
import { Torrent } from "@/models/torrent.model";
55
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
6-
import { formatTimeAgo } from "@/lib/utils";
6+
import { formatTimeRelative } from "@/lib/utils";
77
import { triblerService } from "@/services/tribler.service";
88
import { isErrorDict } from "@/services/reporting";
99

@@ -55,7 +55,7 @@ export function SwarmHealth({ torrent }: { torrent: Torrent }) {
5555
</TooltipTrigger>
5656
<TooltipContent>
5757
<span>
58-
{torrent.last_tracker_check === 0 ? 'Not checked' : `Checked ${formatTimeAgo(torrent.last_tracker_check)}`}
58+
{torrent.last_tracker_check === 0 ? 'Not checked' : `Checked ${formatTimeRelative(torrent.last_tracker_check)}`}
5959
</span>
6060
</TooltipContent>
6161
</Tooltip>

src/tribler/ui/src/components/ui/simple-table.tsx

+115-24
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
import { SetStateAction, useEffect, useRef, useState } from 'react';
22
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
33
import { getCoreRowModel, useReactTable, flexRender, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getSortedRowModel } from '@tanstack/react-table';
4-
import type { ColumnDef, Row, PaginationState, RowSelectionState, ColumnFiltersState, ExpandedState, ColumnDefTemplate, HeaderContext, SortingState } from '@tanstack/react-table';
4+
import type { ColumnDef, Row, PaginationState, RowSelectionState, ColumnFiltersState, ExpandedState, ColumnDefTemplate, HeaderContext, SortingState, VisibilityState, Header, Column } from '@tanstack/react-table';
55
import { cn } from '@/lib/utils';
66
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel } from './select';
77
import { Button } from './button';
8-
import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons';
8+
import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, DotsHorizontalIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons';
99
import * as SelectPrimitive from "@radix-ui/react-select"
1010
import type { Table as ReactTable } from '@tanstack/react-table';
1111
import { useTranslation } from 'react-i18next';
1212
import { useResizeObserver } from '@/hooks/useResizeObserver';
1313
import useKeyboardShortcut from 'use-keyboard-shortcut';
14+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from './dropdown-menu';
15+
import { triblerService } from '@/services/tribler.service';
16+
17+
18+
declare module '@tanstack/table-core/build/lib/types' {
19+
export interface ColumnMeta<TData extends RowData, TValue> {
20+
hide_by_default: boolean;
21+
}
22+
}
1423

1524

1625
export function getHeader<T>(name: string, translate: boolean = true, addSorting: boolean = true): ColumnDefTemplate<HeaderContext<T, unknown>> | undefined {
@@ -42,15 +51,22 @@ export function getHeader<T>(name: string, translate: boolean = true, addSorting
4251
}
4352
}
4453

45-
function getStoredSortingState(key?: string) {
46-
if (key) {
47-
let sortingString = localStorage.getItem(key);
48-
if (sortingString) {
49-
return JSON.parse(sortingString);
50-
}
54+
function getState(type: "columns" | "sorting", name?: string) {
55+
let stateString = triblerService.guiSettings[type];
56+
if (stateString && name) {
57+
return JSON.parse(stateString)[name];
5158
}
5259
}
5360

61+
function setState(type: "columns" | "sorting", name: string, state: SortingState | VisibilityState) {
62+
let stateString = triblerService.guiSettings[type];
63+
let stateSettings = stateString ? JSON.parse(stateString) : {};
64+
stateSettings[name] = state;
65+
66+
triblerService.guiSettings[type] = JSON.stringify(stateSettings);
67+
triblerService.setSettings({ ui: triblerService.guiSettings });
68+
}
69+
5470
interface ReactTableProps<T extends object> {
5571
data: T[];
5672
columns: ColumnDef<T>[];
@@ -65,6 +81,7 @@ interface ReactTableProps<T extends object> {
6581
allowSelect?: boolean;
6682
allowSelectCheckbox?: boolean;
6783
allowMultiSelect?: boolean;
84+
allowColumnToggle?: string;
6885
filters?: { id: string, value: string }[];
6986
maxHeight?: string | number;
7087
expandable?: boolean;
@@ -85,6 +102,7 @@ function SimpleTable<T extends object>({
85102
allowSelect,
86103
allowSelectCheckbox,
87104
allowMultiSelect,
105+
allowColumnToggle,
88106
filters,
89107
maxHeight,
90108
expandable,
@@ -98,21 +116,47 @@ function SimpleTable<T extends object>({
98116
const [rowSelection, setRowSelection] = useState<RowSelectionState>(initialRowSelection || {});
99117
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(filters || [])
100118
const [expanded, setExpanded] = useState<ExpandedState>({});
101-
const [sorting, setSorting] = useState<SortingState>(getStoredSortingState(storeSortingState) || []);
119+
const [sorting, setSorting] = useState<SortingState>(getState("sorting", storeSortingState) || []);
102120

103-
useKeyboardShortcut(
104-
["Control", "A"],
105-
keys => {
106-
if (allowMultiSelect) {
107-
table.toggleAllRowsSelected(true);
108-
}
109-
},
110-
{
111-
overrideSystem: true,
112-
ignoreInputFields: true,
113-
repeatOnHold: false
121+
//Get stored column visibility and add missing visibilities with their defaults.
122+
const visibilityState = getState("columns", allowColumnToggle) || {};
123+
let col: any;
124+
for (col of columns) {
125+
if (col.accessorKey && col.accessorKey in visibilityState === false) {
126+
visibilityState[col.accessorKey] = col.meta?.hide_by_default !== true;
127+
}
128+
}
129+
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(visibilityState);
130+
131+
useKeyboardShortcut(["Control", "A"], () => {
132+
if (allowMultiSelect) {
133+
table.toggleAllRowsSelected(true);
114134
}
115-
);
135+
}, { overrideSystem: true, repeatOnHold: false });
136+
useKeyboardShortcut(["ArrowUp"], () => {
137+
let ids = Object.keys(rowSelection);
138+
let rows = table.getSortedRowModel().rows;
139+
let index = rows.findIndex((row) => ids.includes(row.id));
140+
let next = rows[index - 1] || rows[0];
141+
142+
let selection: any = {};
143+
selection[next.id.toString()] = true;
144+
table.setRowSelection(selection);
145+
146+
document.querySelector("[data-state='selected']")?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
147+
});
148+
useKeyboardShortcut(["ArrowDown"], () => {
149+
let ids = Object.keys(rowSelection);
150+
let rows = table.getSortedRowModel().rows;
151+
let index = rows.findLastIndex((row) => ids.includes(row.id));
152+
let next = rows[index + 1] || rows[rows.length - 1];
153+
154+
let selection: any = {};
155+
selection[next.id.toString()] = true;
156+
table.setRowSelection(selection);
157+
158+
document.querySelector("[data-state='selected']")?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
159+
});
116160

117161
const table = useReactTable({
118162
data,
@@ -127,11 +171,13 @@ function SimpleTable<T extends object>({
127171
pagination,
128172
rowSelection,
129173
columnFilters,
174+
columnVisibility,
130175
expanded,
131176
sorting
132177
},
133178
getFilteredRowModel: getFilteredRowModel(),
134179
onColumnFiltersChange: setColumnFilters,
180+
onColumnVisibilityChange: setColumnVisibility,
135181
onPaginationChange: setPagination,
136182
onRowSelectionChange: (arg: SetStateAction<RowSelectionState>) => {
137183
if (allowSelect || allowSelectCheckbox || allowMultiSelect) setRowSelection(arg);
@@ -173,10 +219,16 @@ function SimpleTable<T extends object>({
173219

174220
useEffect(() => {
175221
if (storeSortingState) {
176-
localStorage.setItem(storeSortingState, JSON.stringify(sorting));
222+
setState("sorting", storeSortingState, sorting);
177223
}
178224
}, [sorting]);
179225

226+
useEffect(() => {
227+
if (allowColumnToggle) {
228+
setState("columns", allowColumnToggle, columnVisibility);
229+
}
230+
}, [columnVisibility]);
231+
180232
// For some reason the ScrollArea scrollbar is only shown when it's set to a specific height.
181233
// So, we wrap it in a parent div, monitor its size, and set the height of the table accordingly.
182234
const parentRef = useRef<HTMLTableElement>(null);
@@ -186,12 +238,16 @@ function SimpleTable<T extends object>({
186238
<>
187239
<div ref={parentRef} className='flex-grow flex'>
188240
<Table maxHeight={maxHeight ?? (parentRect?.height ?? 200)}>
189-
<TableHeader>
241+
<TableHeader className='z-10'>
190242
{table.getHeaderGroups().map((headerGroup) => (
191243
<TableRow key={headerGroup.id} className="bg-neutral-100 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-900">
192244
{headerGroup.headers.map((header, index) => {
193245
return (
194-
<TableHead key={header.id} className={cn({ 'pl-4': index === 0, 'pr-4': index + 1 === headerGroup.headers.length, })}>
246+
<TableHead key={header.id} className={cn({
247+
'pl-4': index === 0,
248+
'pr-4': !allowColumnToggle && index + 1 === headerGroup.headers.length,
249+
'pr-0': !!allowColumnToggle
250+
})}>
195251
{header.isPlaceholder
196252
? null
197253
: flexRender(
@@ -201,6 +257,41 @@ function SimpleTable<T extends object>({
201257
</TableHead>
202258
)
203259
})}
260+
{allowColumnToggle && <TableHead key="toggleColumns" className="w-2 pl-1 pr-3 cursor-pointer hover:text-black dark:hover:text-white">
261+
<DropdownMenu>
262+
<DropdownMenuTrigger asChild>
263+
<DotsHorizontalIcon className="h-4 w-4" />
264+
</DropdownMenuTrigger>
265+
<DropdownMenuContent align="end">
266+
<DropdownMenuLabel>{t('Toggle columns')}</DropdownMenuLabel>
267+
<DropdownMenuSeparator />
268+
{table.getAllLeafColumns().map(column => {
269+
const fakeColumn = {
270+
...column,
271+
toggleSorting: () => { },
272+
getIsSorted: () => { },
273+
} as Column<any, unknown>;
274+
return (
275+
<DropdownMenuItem key={`toggleColumns-${column.id}`}>
276+
<label onClick={(evt) => evt.stopPropagation()} className='flex space-x-1'>
277+
<input
278+
{...{
279+
type: 'checkbox',
280+
checked: column.getIsVisible(),
281+
onChange: column.getToggleVisibilityHandler(),
282+
}}
283+
/>{flexRender(column.columnDef.header, {
284+
table,
285+
column: fakeColumn,
286+
header: { column: fakeColumn } as Header<any, unknown>,
287+
})}
288+
</label>
289+
</DropdownMenuItem>
290+
)
291+
})}
292+
</DropdownMenuContent>
293+
</DropdownMenu>
294+
</TableHead>}
204295
</TableRow>
205296
))}
206297
</TableHeader>

src/tribler/ui/src/lib/utils.ts

+27-32
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,10 @@
11
import { type ClassValue, clsx } from "clsx"
22
import { twMerge } from "tailwind-merge"
33
import { category } from "@/models/torrent.model";
4-
import TimeAgo from 'javascript-time-ago'
5-
import en from 'javascript-time-ago/locale/en'
6-
import es from 'javascript-time-ago/locale/es'
7-
import pt from 'javascript-time-ago/locale/pt'
8-
import ru from 'javascript-time-ago/locale/ru'
9-
import zh from 'javascript-time-ago/locale/zh'
10-
import { useTranslation } from "react-i18next";
11-
import { triblerService } from "@/services/tribler.service";
124
import { FileLink, FileTreeItem } from "@/models/file.model";
135
import { CheckedState } from "@radix-ui/react-checkbox";
146
import JSZip from "jszip";
15-
16-
TimeAgo.setDefaultLocale(en.locale)
17-
TimeAgo.addLocale(en)
18-
TimeAgo.addLocale(es)
19-
TimeAgo.addLocale(pt)
20-
TimeAgo.addLocale(ru)
21-
TimeAgo.addLocale(zh)
22-
7+
import { triblerService } from "@/services/tribler.service";
238

249
export function cn(...inputs: ClassValue[]) {
2510
return twMerge(clsx(inputs))
@@ -72,10 +57,33 @@ export function categoryIcon(name: category): string {
7257
return categoryEmojis[name] || '';
7358
}
7459

75-
export function formatTimeAgo(ts: number) {
60+
export function formatDateTime(ts: number) {
61+
const dtf = new Intl.DateTimeFormat(undefined, {
62+
day: '2-digit', month: '2-digit', year: 'numeric',
63+
hour: '2-digit', hourCycle: "h24", minute: '2-digit', second: '2-digit'
64+
});
65+
return dtf.format(new Date(ts * 1000));
66+
}
67+
68+
export function formatTimeRelative(ts: number, epochTime: boolean = true) {
69+
// Returns passed/future time as human readable text
70+
if (ts === 0) { return '-'; }
71+
if (epochTime) { ts = ts - (Date.now() / 1000); }
72+
const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity];
73+
const units: Intl.RelativeTimeFormatUnit[] = ["second", "minute", "hour", "day", "week", "month", "year"];
74+
const index = cutoffs.findIndex(cutoff => cutoff > Math.abs(ts));
75+
const divisor = index ? cutoffs[index - 1] : 1;
7676
let locale = triblerService.guiSettings.lang ?? 'en_US';
77-
const timeAg = new TimeAgo(locale.slice(0, 2));
78-
return timeAg.format(ts * 1000);
77+
const rtf = new Intl.RelativeTimeFormat(locale.replace("_", "-"), { numeric: "auto" });
78+
return divisor === Infinity ? "-" : rtf.format(Math.round(ts / divisor), units[index]);
79+
}
80+
81+
export function formatTimeRelativeISO(ts: number) {
82+
// Returns passed time as HH:mm:ss
83+
if (ts === 0) { return '-'; }
84+
const date = new Date(0);
85+
date.setSeconds((Date.now() / 1000) - ts);
86+
return date.toISOString().substr(11, 8);
7987
}
8088

8189
export function formatBytes(bytes: number) {
@@ -84,19 +92,6 @@ export function formatBytes(bytes: number) {
8492
return (bytes / Math.pow(1024, e)).toFixed(2) + ' ' + ' KMGTP'.charAt(e) + 'B';
8593
}
8694

87-
export function formatTimeDiff(time: number) {
88-
if (time === 0) { return '-'; }
89-
const now = Date.now() / 1000;
90-
return formatTime(now - time);
91-
}
92-
93-
export function formatTime(time: number) {
94-
if (time === 0) { return '-'; }
95-
const date = new Date(0);
96-
date.setSeconds(time);
97-
return date.toISOString().substr(11, 8);
98-
}
99-
10095
export function formatFlags(flags: number[]) {
10196
const flagToString: Record<number, string> = {
10297
1: 'RELAY',

0 commit comments

Comments
 (0)