Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow changing column visibility #8392

Merged
merged 5 commits into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading