Skip to content

Commit

Permalink
Allow changing column visibility (#8392)
Browse files Browse the repository at this point in the history
  • Loading branch information
egbertbouman authored Jan 16, 2025
2 parents 9388f4a + 586ae28 commit bb7aa54
Show file tree
Hide file tree
Showing 16 changed files with 187 additions and 104 deletions.
27 changes: 0 additions & 27 deletions src/tribler/ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion src/tribler/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"i18next": "^23.11.4",
"javascript-time-ago": "^2.5.10",
"js-cookie": "^3.0.5",
"jszip": "^3.10.1",
"lucide-react": "^0.292.0",
Expand Down
4 changes: 2 additions & 2 deletions src/tribler/ui/src/components/swarm-health.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import toast from 'react-hot-toast';
import { Torrent } from "@/models/torrent.model";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import { formatTimeAgo } from "@/lib/utils";
import { formatTimeRelative } from "@/lib/utils";
import { triblerService } from "@/services/tribler.service";
import { isErrorDict } from "@/services/reporting";

Expand Down Expand Up @@ -55,7 +55,7 @@ export function SwarmHealth({ torrent }: { torrent: Torrent }) {
</TooltipTrigger>
<TooltipContent>
<span>
{torrent.last_tracker_check === 0 ? 'Not checked' : `Checked ${formatTimeAgo(torrent.last_tracker_check)}`}
{torrent.last_tracker_check === 0 ? 'Not checked' : `Checked ${formatTimeRelative(torrent.last_tracker_check)}`}
</span>
</TooltipContent>
</Tooltip>
Expand Down
139 changes: 115 additions & 24 deletions src/tribler/ui/src/components/ui/simple-table.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { SetStateAction, useEffect, useRef, useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { getCoreRowModel, useReactTable, flexRender, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getSortedRowModel } from '@tanstack/react-table';
import type { ColumnDef, Row, PaginationState, RowSelectionState, ColumnFiltersState, ExpandedState, ColumnDefTemplate, HeaderContext, SortingState } from '@tanstack/react-table';
import type { ColumnDef, Row, PaginationState, RowSelectionState, ColumnFiltersState, ExpandedState, ColumnDefTemplate, HeaderContext, SortingState, VisibilityState, Header, Column } from '@tanstack/react-table';
import { cn } from '@/lib/utils';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel } from './select';
import { Button } from './button';
import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons';
import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, DotsHorizontalIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons';
import * as SelectPrimitive from "@radix-ui/react-select"
import type { Table as ReactTable } from '@tanstack/react-table';
import { useTranslation } from 'react-i18next';
import { useResizeObserver } from '@/hooks/useResizeObserver';
import useKeyboardShortcut from 'use-keyboard-shortcut';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from './dropdown-menu';
import { triblerService } from '@/services/tribler.service';


declare module '@tanstack/table-core/build/lib/types' {
export interface ColumnMeta<TData extends RowData, TValue> {
hide_by_default: boolean;
}
}


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

function getStoredSortingState(key?: string) {
if (key) {
let sortingString = localStorage.getItem(key);
if (sortingString) {
return JSON.parse(sortingString);
}
function getState(type: "columns" | "sorting", name?: string) {
let stateString = triblerService.guiSettings[type];
if (stateString && name) {
return JSON.parse(stateString)[name];
}
}

function setState(type: "columns" | "sorting", name: string, state: SortingState | VisibilityState) {
let stateString = triblerService.guiSettings[type];
let stateSettings = stateString ? JSON.parse(stateString) : {};
stateSettings[name] = state;

triblerService.guiSettings[type] = JSON.stringify(stateSettings);
triblerService.setSettings({ ui: triblerService.guiSettings });
}

interface ReactTableProps<T extends object> {
data: T[];
columns: ColumnDef<T>[];
Expand All @@ -65,6 +81,7 @@ interface ReactTableProps<T extends object> {
allowSelect?: boolean;
allowSelectCheckbox?: boolean;
allowMultiSelect?: boolean;
allowColumnToggle?: string;
filters?: { id: string, value: string }[];
maxHeight?: string | number;
expandable?: boolean;
Expand All @@ -85,6 +102,7 @@ function SimpleTable<T extends object>({
allowSelect,
allowSelectCheckbox,
allowMultiSelect,
allowColumnToggle,
filters,
maxHeight,
expandable,
Expand All @@ -98,21 +116,47 @@ function SimpleTable<T extends object>({
const [rowSelection, setRowSelection] = useState<RowSelectionState>(initialRowSelection || {});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(filters || [])
const [expanded, setExpanded] = useState<ExpandedState>({});
const [sorting, setSorting] = useState<SortingState>(getStoredSortingState(storeSortingState) || []);
const [sorting, setSorting] = useState<SortingState>(getState("sorting", storeSortingState) || []);

useKeyboardShortcut(
["Control", "A"],
keys => {
if (allowMultiSelect) {
table.toggleAllRowsSelected(true);
}
},
{
overrideSystem: true,
ignoreInputFields: true,
repeatOnHold: false
//Get stored column visibility and add missing visibilities with their defaults.
const visibilityState = getState("columns", allowColumnToggle) || {};
let col: any;
for (col of columns) {
if (col.accessorKey && col.accessorKey in visibilityState === false) {
visibilityState[col.accessorKey] = col.meta?.hide_by_default !== true;
}
}
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(visibilityState);

useKeyboardShortcut(["Control", "A"], () => {
if (allowMultiSelect) {
table.toggleAllRowsSelected(true);
}
);
}, { overrideSystem: true, repeatOnHold: false });
useKeyboardShortcut(["ArrowUp"], () => {
let ids = Object.keys(rowSelection);
let rows = table.getSortedRowModel().rows;
let index = rows.findIndex((row) => ids.includes(row.id));
let next = rows[index - 1] || rows[0];

let selection: any = {};
selection[next.id.toString()] = true;
table.setRowSelection(selection);

document.querySelector("[data-state='selected']")?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
});
useKeyboardShortcut(["ArrowDown"], () => {
let ids = Object.keys(rowSelection);
let rows = table.getSortedRowModel().rows;
let index = rows.findLastIndex((row) => ids.includes(row.id));
let next = rows[index + 1] || rows[rows.length - 1];

let selection: any = {};
selection[next.id.toString()] = true;
table.setRowSelection(selection);

document.querySelector("[data-state='selected']")?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
});

const table = useReactTable({
data,
Expand All @@ -127,11 +171,13 @@ function SimpleTable<T extends object>({
pagination,
rowSelection,
columnFilters,
columnVisibility,
expanded,
sorting
},
getFilteredRowModel: getFilteredRowModel(),
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onRowSelectionChange: (arg: SetStateAction<RowSelectionState>) => {
if (allowSelect || allowSelectCheckbox || allowMultiSelect) setRowSelection(arg);
Expand Down Expand Up @@ -173,10 +219,16 @@ function SimpleTable<T extends object>({

useEffect(() => {
if (storeSortingState) {
localStorage.setItem(storeSortingState, JSON.stringify(sorting));
setState("sorting", storeSortingState, sorting);
}
}, [sorting]);

useEffect(() => {
if (allowColumnToggle) {
setState("columns", allowColumnToggle, columnVisibility);
}
}, [columnVisibility]);

// For some reason the ScrollArea scrollbar is only shown when it's set to a specific height.
// So, we wrap it in a parent div, monitor its size, and set the height of the table accordingly.
const parentRef = useRef<HTMLTableElement>(null);
Expand All @@ -186,12 +238,16 @@ function SimpleTable<T extends object>({
<>
<div ref={parentRef} className='flex-grow flex'>
<Table maxHeight={maxHeight ?? (parentRect?.height ?? 200)}>
<TableHeader>
<TableHeader className='z-10'>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-neutral-100 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-900">
{headerGroup.headers.map((header, index) => {
return (
<TableHead key={header.id} className={cn({ 'pl-4': index === 0, 'pr-4': index + 1 === headerGroup.headers.length, })}>
<TableHead key={header.id} className={cn({
'pl-4': index === 0,
'pr-4': !allowColumnToggle && index + 1 === headerGroup.headers.length,
'pr-0': !!allowColumnToggle
})}>
{header.isPlaceholder
? null
: flexRender(
Expand All @@ -201,6 +257,41 @@ function SimpleTable<T extends object>({
</TableHead>
)
})}
{allowColumnToggle && <TableHead key="toggleColumns" className="w-2 pl-1 pr-3 cursor-pointer hover:text-black dark:hover:text-white">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<DotsHorizontalIcon className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t('Toggle columns')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{table.getAllLeafColumns().map(column => {
const fakeColumn = {
...column,
toggleSorting: () => { },
getIsSorted: () => { },
} as Column<any, unknown>;
return (
<DropdownMenuItem key={`toggleColumns-${column.id}`}>
<label onClick={(evt) => evt.stopPropagation()} className='flex space-x-1'>
<input
{...{
type: 'checkbox',
checked: column.getIsVisible(),
onChange: column.getToggleVisibilityHandler(),
}}
/>{flexRender(column.columnDef.header, {
table,
column: fakeColumn,
header: { column: fakeColumn } as Header<any, unknown>,
})}
</label>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</TableHead>}
</TableRow>
))}
</TableHeader>
Expand Down
59 changes: 27 additions & 32 deletions src/tribler/ui/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,10 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import { category } from "@/models/torrent.model";
import TimeAgo from 'javascript-time-ago'
import en from 'javascript-time-ago/locale/en'
import es from 'javascript-time-ago/locale/es'
import pt from 'javascript-time-ago/locale/pt'
import ru from 'javascript-time-ago/locale/ru'
import zh from 'javascript-time-ago/locale/zh'
import { useTranslation } from "react-i18next";
import { triblerService } from "@/services/tribler.service";
import { FileLink, FileTreeItem } from "@/models/file.model";
import { CheckedState } from "@radix-ui/react-checkbox";
import JSZip from "jszip";

TimeAgo.setDefaultLocale(en.locale)
TimeAgo.addLocale(en)
TimeAgo.addLocale(es)
TimeAgo.addLocale(pt)
TimeAgo.addLocale(ru)
TimeAgo.addLocale(zh)

import { triblerService } from "@/services/tribler.service";

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

export function formatTimeAgo(ts: number) {
export function formatDateTime(ts: number) {
const dtf = new Intl.DateTimeFormat(undefined, {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', hourCycle: "h24", minute: '2-digit', second: '2-digit'
});
return dtf.format(new Date(ts * 1000));
}

export function formatTimeRelative(ts: number, epochTime: boolean = true) {
// Returns passed/future time as human readable text
if (ts === 0) { return '-'; }
if (epochTime) { ts = ts - (Date.now() / 1000); }
const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity];
const units: Intl.RelativeTimeFormatUnit[] = ["second", "minute", "hour", "day", "week", "month", "year"];
const index = cutoffs.findIndex(cutoff => cutoff > Math.abs(ts));
const divisor = index ? cutoffs[index - 1] : 1;
let locale = triblerService.guiSettings.lang ?? 'en_US';
const timeAg = new TimeAgo(locale.slice(0, 2));
return timeAg.format(ts * 1000);
const rtf = new Intl.RelativeTimeFormat(locale.replace("_", "-"), { numeric: "auto" });
return divisor === Infinity ? "-" : rtf.format(Math.round(ts / divisor), units[index]);
}

export function formatTimeRelativeISO(ts: number) {
// Returns passed time as HH:mm:ss
if (ts === 0) { return '-'; }
const date = new Date(0);
date.setSeconds((Date.now() / 1000) - ts);
return date.toISOString().substr(11, 8);
}

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

export function formatTimeDiff(time: number) {
if (time === 0) { return '-'; }
const now = Date.now() / 1000;
return formatTime(now - time);
}

export function formatTime(time: number) {
if (time === 0) { return '-'; }
const date = new Date(0);
date.setSeconds(time);
return date.toISOString().substr(11, 8);
}

export function formatFlags(flags: number[]) {
const flagToString: Record<number, string> = {
1: 'RELAY',
Expand Down
Loading

0 comments on commit bb7aa54

Please sign in to comment.