feat: add useDataTableColumns with toggleable, draggable, resizable, pinnable columns
This commit is contained in:
4 files changed
+181
-40
No files matched your search
@@ -1,5 +1,5 @@
|
||||
---
|
||||
iteration: 1
|
||||
iteration: 2
|
||||
max_iterations: 10
|
||||
status: active
|
||||
goal: "Maximize the downloads widget by leveraging every mantine-datatable feature, adding hover tooltips with stats, click-to-expand rich detail rows, toggleable global stats, and comprehensive customization options"
|
||||
@@ -19,9 +19,37 @@ engine: cursor
|
||||
|
||||
## Progress log
|
||||
|
||||
### Iteration 1
|
||||
- Added hover tooltips on name column showing size, state, speeds, ETA, ratio, category
|
||||
- Added toggleable global stats bar with overall progress, total size, speeds, ratio, status counts
|
||||
- Enhanced row expansion with RingProgress, SimpleGrid layout, collapse on click
|
||||
- Added striped rows, highlightOnHover colors, rowBackgroundColor for failed/paused
|
||||
- Added defaultColumnProps with noWrap and dynamic cellsStyle
|
||||
- Added collapseProps for smooth expansion animation
|
||||
- Added onScroll to auto-close context menu
|
||||
- Added new translations for stats bar
|
||||
- TypeCheck: PASS
|
||||
|
||||
### Iteration 2
|
||||
- Migrated to `useDataTableColumns` hook with toggleable, draggable, resizable, pinnable columns
|
||||
- Added `storeColumnsKey` for localStorage column preference persistence
|
||||
- Added `pinFirstColumn`, `pinLastColumn`, `textSelectionDisabled`, `withColumnBorders`
|
||||
- Added `scrollAreaProps`, `customRowAttributes`, enhanced scroll tracking (`onScroll`, `onScrollToTop`, `onScrollToBottom`)
|
||||
- Added column settings menu in footer with reset visibility/order/width/pinning
|
||||
- Added scroll position indicator badge when scrolled
|
||||
- Explicit `rowExpansion.trigger: "click"`
|
||||
- Updated docs and translations for column controls
|
||||
- TypeCheck: PASS
|
||||
|
||||
## Blockers
|
||||
None
|
||||
|
||||
## Verification
|
||||
- pnpm turbo typecheck --filter=@homarr/widgets: PASS (exit code 0)
|
||||
- Context menu: preserved via onRowContextMenu
|
||||
- Sorting: preserved via sortStatus/onSortStatusChange
|
||||
- Filtering: preserved in WidgetFooter
|
||||
- Column toggling/reordering/resizing/pinning: via useDataTableColumns + header UI
|
||||
|
||||
## Task
|
||||
|
||||
|
||||
@@ -44,8 +44,11 @@ import contextMenuImg from './img/context-menu.png';
|
||||
|
||||
- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes
|
||||
- **Sortable columns** — click any column header to sort ascending/descending
|
||||
- **Column customization** — toggle, reorder, resize, and pin columns via the header menu; preferences persist in local storage
|
||||
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
|
||||
- **Row expansion** — click any row to see full details inline without opening a separate modal
|
||||
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category
|
||||
- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts
|
||||
- **Client filtering** — filter downloads by integration or status using the footer filter panel
|
||||
- **Global controls** — pause/resume all downloads across all clients from the footer bar
|
||||
- **Status indicators** — color-coded badges show download state at a glance
|
||||
@@ -80,6 +83,9 @@ import contextMenuImg from './img/context-menu.png';
|
||||
|
||||
- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
|
||||
- **Click** any row to expand it and see full download details inline
|
||||
- **Hover** a download name for a quick stats tooltip
|
||||
- **Column header menu** — show/hide, reorder, resize, or pin columns (reset via the columns button in the footer)
|
||||
- **Stats toggle** — show or hide the global stats bar from the footer
|
||||
- **Pause / Resume** all downloads using the footer controls
|
||||
- **Filter** by integration or status using the filter button in the footer
|
||||
- **Sort** by clicking column headers (with persistent sort direction)
|
||||
|
||||
@@ -3189,6 +3189,14 @@
|
||||
"overallProgress": "Overall queue progress",
|
||||
"totalSize": "Total queue size",
|
||||
"clients": "{count, plural, one {# client} other {# clients}}"
|
||||
},
|
||||
"columns": {
|
||||
"settings": "Column settings",
|
||||
"resetVisibility": "Reset visibility",
|
||||
"resetOrder": "Reset order",
|
||||
"resetWidth": "Reset widths",
|
||||
"resetPinning": "Reset pinning",
|
||||
"scrolled": "Scrolled {y}px from top"
|
||||
}
|
||||
},
|
||||
"mediaReleases": {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconColumns,
|
||||
IconCopy,
|
||||
IconDatabase,
|
||||
IconDownload,
|
||||
@@ -41,7 +42,7 @@ import {
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
||||
import { DataTable } from "mantine-datatable";
|
||||
import { DataTable, useDataTableColumns } from "mantine-datatable";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||
@@ -86,6 +87,8 @@ const stateColorMap: Record<ExtendedDownloadClientItem["state"], string> = {
|
||||
unknown: "gray",
|
||||
};
|
||||
|
||||
const DOWNLOADS_TABLE_STORE_KEY = "homarr-downloads-datatable";
|
||||
|
||||
const progressColor = (state: ExtendedDownloadClientItem["state"], progress: number): string => {
|
||||
if (state === "paused") return "yellow";
|
||||
if (progress >= 1) return "green";
|
||||
@@ -132,6 +135,8 @@ export default function DownloadClientsWidget({
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [showStats, { toggle: toggleStats }] = useDisclosure(false);
|
||||
const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 });
|
||||
const [scrollAtTop, setScrollAtTop] = useState(true);
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() };
|
||||
@@ -244,45 +249,60 @@ export default function DownloadClientsWidget({
|
||||
const hasMultipleTypes = new Set(data.map(({ type }) => type)).size > 1;
|
||||
|
||||
const size = getSizeConfig(width);
|
||||
const optionsColumnSet = useMemo(() => new Set<string>(options.columns), [options.columns]);
|
||||
|
||||
const visibleColumns = useMemo(() => {
|
||||
const set = new Set(options.columns);
|
||||
return {
|
||||
name: set.has("name"),
|
||||
progress: set.has("progress"),
|
||||
size: set.has("size"),
|
||||
downSpeed: set.has("downSpeed") && size.showSpeedColumns,
|
||||
upSpeed: set.has("upSpeed") && hasTorrents && size.showSpeedColumns,
|
||||
time: set.has("time") && size.showTimeColumn,
|
||||
state: set.has("state") && size.showStateColumn,
|
||||
added: set.has("added"),
|
||||
ratio: set.has("ratio") && hasTorrents,
|
||||
received: set.has("received"),
|
||||
sent: set.has("sent") && hasTorrents,
|
||||
category: set.has("category"),
|
||||
integration: set.has("integration") && hasMultipleClients,
|
||||
index: set.has("index"),
|
||||
type: set.has("type") && hasMultipleTypes,
|
||||
};
|
||||
}, [options.columns, size, hasTorrents, hasMultipleClients, hasMultipleTypes]);
|
||||
const shouldIncludeColumn = useCallback(
|
||||
(accessor: string): boolean => {
|
||||
if (!optionsColumnSet.has(accessor)) return false;
|
||||
if ((accessor === "upSpeed" || accessor === "ratio" || accessor === "sent") && !hasTorrents) return false;
|
||||
if (accessor === "integration" && !hasMultipleClients) return false;
|
||||
if (accessor === "type" && !hasMultipleTypes) return false;
|
||||
return true;
|
||||
},
|
||||
[optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes],
|
||||
);
|
||||
|
||||
const isResponsiveVisible = useCallback(
|
||||
(accessor: string): boolean => {
|
||||
if ((accessor === "downSpeed" || accessor === "upSpeed") && !size.showSpeedColumns) return false;
|
||||
if (accessor === "time" && !size.showTimeColumn) return false;
|
||||
if (accessor === "state" && !size.showStateColumn) return false;
|
||||
return true;
|
||||
},
|
||||
[size],
|
||||
);
|
||||
|
||||
const getColumnMeta = useCallback(
|
||||
(accessor: string) => ({
|
||||
toggleable: true as const,
|
||||
draggable: true as const,
|
||||
resizable: accessor !== "integration" && accessor !== "index",
|
||||
pinnable: true as const,
|
||||
defaultToggle: isResponsiveVisible(accessor),
|
||||
...(accessor === "name" ? { pinned: "left" as const } : {}),
|
||||
}),
|
||||
[isResponsiveVisible],
|
||||
);
|
||||
|
||||
const columns = useMemo((): DataTableColumn<ExtendedDownloadClientItem>[] => {
|
||||
const cols: (DataTableColumn<ExtendedDownloadClientItem> | false)[] = [
|
||||
visibleColumns.integration && {
|
||||
shouldIncludeColumn("integration") && {
|
||||
accessor: "integration",
|
||||
title: "",
|
||||
width: 36,
|
||||
...getColumnMeta("integration"),
|
||||
render: (record) => (
|
||||
<Tooltip label={record.integration.name} withArrow>
|
||||
<Avatar size={20} radius={0} src={getIconUrl(record.integration.kind)} />
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
visibleColumns.name && {
|
||||
shouldIncludeColumn("name") && {
|
||||
accessor: "name",
|
||||
title: t("items.name.columnTitle"),
|
||||
sortable: true,
|
||||
ellipsis: true,
|
||||
...getColumnMeta("name"),
|
||||
render: (record) => (
|
||||
<Tooltip
|
||||
label={buildHoverTooltip(record, t)}
|
||||
@@ -300,11 +320,12 @@ export default function DownloadClientsWidget({
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
visibleColumns.progress && {
|
||||
shouldIncludeColumn("progress") && {
|
||||
accessor: "progress",
|
||||
title: t("items.progress.columnTitle"),
|
||||
sortable: true,
|
||||
width: width < 400 ? 80 : 120,
|
||||
...getColumnMeta("progress"),
|
||||
render: (record) => (
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Text size="xs" fw={500} w={36} ta="right" style={{ flexShrink: 0 }}>
|
||||
@@ -319,20 +340,22 @@ export default function DownloadClientsWidget({
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
visibleColumns.size && {
|
||||
shouldIncludeColumn("size") && {
|
||||
accessor: "size",
|
||||
title: t("items.size.columnTitle"),
|
||||
sortable: true,
|
||||
width: 80,
|
||||
noWrap: true,
|
||||
...getColumnMeta("size"),
|
||||
render: (record) => <Text size={size.fontSize}>{formatBytes(record.size)}</Text>,
|
||||
},
|
||||
visibleColumns.downSpeed && {
|
||||
shouldIncludeColumn("downSpeed") && {
|
||||
accessor: "downSpeed",
|
||||
title: t("items.downSpeed.columnTitle"),
|
||||
sortable: true,
|
||||
width: 90,
|
||||
noWrap: true,
|
||||
...getColumnMeta("downSpeed"),
|
||||
render: (record) => record.downSpeed ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconDownload size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
@@ -340,12 +363,13 @@ export default function DownloadClientsWidget({
|
||||
</Group>
|
||||
) : null,
|
||||
},
|
||||
visibleColumns.upSpeed && {
|
||||
shouldIncludeColumn("upSpeed") && {
|
||||
accessor: "upSpeed",
|
||||
title: t("items.upSpeed.columnTitle"),
|
||||
sortable: true,
|
||||
width: 90,
|
||||
noWrap: true,
|
||||
...getColumnMeta("upSpeed"),
|
||||
render: (record) => record.upSpeed ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconUpload size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
@@ -353,88 +377,108 @@ export default function DownloadClientsWidget({
|
||||
</Group>
|
||||
) : null,
|
||||
},
|
||||
visibleColumns.time && {
|
||||
shouldIncludeColumn("time") && {
|
||||
accessor: "time",
|
||||
title: t("items.time.columnTitle"),
|
||||
sortable: true,
|
||||
width: 100,
|
||||
noWrap: true,
|
||||
...getColumnMeta("time"),
|
||||
render: (record) => {
|
||||
if (record.time === 0) return <Text size={size.fontSize} c="dimmed">∞</Text>;
|
||||
return <Text size={size.fontSize}>{dayjs().add(record.time).fromNow(true)}</Text>;
|
||||
},
|
||||
},
|
||||
visibleColumns.state && {
|
||||
shouldIncludeColumn("state") && {
|
||||
accessor: "state",
|
||||
title: t("items.state.columnTitle"),
|
||||
width: 90,
|
||||
...getColumnMeta("state"),
|
||||
render: (record) => (
|
||||
<Badge size="xs" variant="light" color={stateColorMap[record.state]}>
|
||||
{t(`states.${record.state}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
visibleColumns.added && {
|
||||
shouldIncludeColumn("added") && {
|
||||
accessor: "added",
|
||||
title: t("items.added.columnTitle"),
|
||||
sortable: true,
|
||||
width: 90,
|
||||
noWrap: true,
|
||||
...getColumnMeta("added"),
|
||||
render: (record) => <Text size={size.fontSize}>{record.added ? dayjs(record.added).fromNow() : "—"}</Text>,
|
||||
},
|
||||
visibleColumns.ratio && {
|
||||
shouldIncludeColumn("ratio") && {
|
||||
accessor: "ratio",
|
||||
title: t("items.ratio.columnTitle"),
|
||||
sortable: true,
|
||||
width: 60,
|
||||
...getColumnMeta("ratio"),
|
||||
render: (record) => record.ratio !== undefined ? (
|
||||
<Text size={size.fontSize}>{record.ratio.toFixed(record.ratio >= 100 ? 0 : record.ratio >= 10 ? 1 : 2)}</Text>
|
||||
) : null,
|
||||
},
|
||||
visibleColumns.received && {
|
||||
shouldIncludeColumn("received") && {
|
||||
accessor: "received",
|
||||
title: t("items.received.columnTitle"),
|
||||
sortable: true,
|
||||
width: 80,
|
||||
noWrap: true,
|
||||
...getColumnMeta("received"),
|
||||
render: (record) => <Text size={size.fontSize}>{formatBytes(record.received)}</Text>,
|
||||
},
|
||||
visibleColumns.sent && {
|
||||
shouldIncludeColumn("sent") && {
|
||||
accessor: "sent",
|
||||
title: t("items.sent.columnTitle"),
|
||||
sortable: true,
|
||||
width: 80,
|
||||
noWrap: true,
|
||||
...getColumnMeta("sent"),
|
||||
render: (record) => record.sent ? <Text size={size.fontSize}>{formatBytes(record.sent)}</Text> : null,
|
||||
},
|
||||
visibleColumns.category && {
|
||||
shouldIncludeColumn("category") && {
|
||||
accessor: "category",
|
||||
title: t("items.category.columnTitle"),
|
||||
width: 80,
|
||||
ellipsis: true,
|
||||
...getColumnMeta("category"),
|
||||
render: (record) => record.category ? (
|
||||
<Text size={size.fontSize} truncate>
|
||||
{Array.isArray(record.category) ? record.category.join(", ") : record.category}
|
||||
</Text>
|
||||
) : null,
|
||||
},
|
||||
visibleColumns.index && {
|
||||
shouldIncludeColumn("index") && {
|
||||
accessor: "index",
|
||||
title: t("items.index.columnTitle"),
|
||||
sortable: true,
|
||||
width: 40,
|
||||
...getColumnMeta("index"),
|
||||
render: (record) => <Text size={size.fontSize}>{record.index}</Text>,
|
||||
},
|
||||
visibleColumns.type && {
|
||||
shouldIncludeColumn("type") && {
|
||||
accessor: "type",
|
||||
title: t("items.type.columnTitle"),
|
||||
sortable: true,
|
||||
width: 70,
|
||||
...getColumnMeta("type"),
|
||||
render: (record) => <Text size={size.fontSize} tt="capitalize">{record.type}</Text>,
|
||||
},
|
||||
];
|
||||
return cols.filter(Boolean) as DataTableColumn<ExtendedDownloadClientItem>[];
|
||||
}, [visibleColumns, t, size, width]);
|
||||
}, [shouldIncludeColumn, getColumnMeta, t, size, width]);
|
||||
|
||||
const {
|
||||
effectiveColumns,
|
||||
resetColumnsToggle,
|
||||
resetColumnsOrder,
|
||||
resetColumnsWidth,
|
||||
resetColumnsPinning,
|
||||
} = useDataTableColumns<ExtendedDownloadClientItem>({
|
||||
key: DOWNLOADS_TABLE_STORE_KEY,
|
||||
columns,
|
||||
});
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
({ record, event }: { record: ExtendedDownloadClientItem; event: React.MouseEvent }) => {
|
||||
@@ -499,6 +543,7 @@ export default function DownloadClientsWidget({
|
||||
<DataTable
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
withTableBorder={false}
|
||||
withColumnBorders
|
||||
borderRadius={0}
|
||||
highlightOnHover
|
||||
striped="odd"
|
||||
@@ -507,7 +552,11 @@ export default function DownloadClientsWidget({
|
||||
verticalAlign="center"
|
||||
fz={size.fontSize}
|
||||
records={sortedData}
|
||||
columns={columns}
|
||||
columns={effectiveColumns}
|
||||
storeColumnsKey={DOWNLOADS_TABLE_STORE_KEY}
|
||||
pinFirstColumn
|
||||
pinLastColumn
|
||||
textSelectionDisabled
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
noRecordsText={t("errors.noColumns")}
|
||||
@@ -518,9 +567,12 @@ export default function DownloadClientsWidget({
|
||||
noWrap: true,
|
||||
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
|
||||
}}
|
||||
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
|
||||
customRowAttributes={(_, index) => ({ "data-row-index": index })}
|
||||
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
|
||||
rowBackgroundColor={(record) => rowBgAlpha[record.state] as string | undefined}
|
||||
rowExpansion={{
|
||||
trigger: "click",
|
||||
allowMultiple: true,
|
||||
collapseProps: {
|
||||
transitionDuration: 200,
|
||||
@@ -529,9 +581,13 @@ export default function DownloadClientsWidget({
|
||||
},
|
||||
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
|
||||
}}
|
||||
onScroll={() => {
|
||||
onScroll={(position) => {
|
||||
setScrollPosition(position);
|
||||
setScrollAtTop(position.y <= 0);
|
||||
if (contextMenu) closeContextMenu();
|
||||
}}
|
||||
onScrollToTop={() => setScrollAtTop(true)}
|
||||
onScrollToBottom={() => setScrollAtTop(false)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -551,6 +607,12 @@ export default function DownloadClientsWidget({
|
||||
itemCount={sortedData.length}
|
||||
showStats={showStats}
|
||||
toggleStats={toggleStats}
|
||||
scrollAtTop={scrollAtTop}
|
||||
scrollPosition={scrollPosition}
|
||||
resetColumnsToggle={resetColumnsToggle}
|
||||
resetColumnsOrder={resetColumnsOrder}
|
||||
resetColumnsWidth={resetColumnsWidth}
|
||||
resetColumnsPinning={resetColumnsPinning}
|
||||
/>
|
||||
|
||||
{contextMenu && (
|
||||
@@ -853,6 +915,12 @@ interface WidgetFooterProps {
|
||||
itemCount: number;
|
||||
showStats: boolean;
|
||||
toggleStats: () => void;
|
||||
scrollAtTop: boolean;
|
||||
scrollPosition: { x: number; y: number };
|
||||
resetColumnsToggle: () => void;
|
||||
resetColumnsOrder: () => void;
|
||||
resetColumnsWidth: () => void;
|
||||
resetColumnsPinning: () => void;
|
||||
}
|
||||
|
||||
function WidgetFooter({
|
||||
@@ -871,6 +939,12 @@ function WidgetFooter({
|
||||
itemCount,
|
||||
showStats,
|
||||
toggleStats,
|
||||
scrollAtTop,
|
||||
scrollPosition,
|
||||
resetColumnsToggle,
|
||||
resetColumnsOrder,
|
||||
resetColumnsWidth,
|
||||
resetColumnsPinning,
|
||||
}: WidgetFooterProps) {
|
||||
const t = useScopedI18n("widget.downloads");
|
||||
const [filterOpen, { toggle: toggleFilter }] = useDisclosure(false);
|
||||
@@ -969,6 +1043,31 @@ function WidgetFooter({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Menu position="top-start" withinPortal shadow="md">
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("columns.settings")}>
|
||||
<ActionIcon size="xs" variant="subtle">
|
||||
<IconColumns size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t("columns.settings")}</Menu.Label>
|
||||
<Menu.Item onClick={resetColumnsToggle}>{t("columns.resetVisibility")}</Menu.Item>
|
||||
<Menu.Item onClick={resetColumnsOrder}>{t("columns.resetOrder")}</Menu.Item>
|
||||
<Menu.Item onClick={resetColumnsWidth}>{t("columns.resetWidth")}</Menu.Item>
|
||||
<Menu.Item onClick={resetColumnsPinning}>{t("columns.resetPinning")}</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
{!scrollAtTop && (
|
||||
<Tooltip label={t("columns.scrolled", { y: Math.round(scrollPosition.y) })}>
|
||||
<Badge size="xs" variant="dot" color="gray" style={{ cursor: "default" }}>
|
||||
↕
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Group gap={2}>
|
||||
{clients.map(({ integration }) => (
|
||||
<ClientIndicator key={integration.id} integration={integration} />
|
||||
|
||||
Reference in new issue
Block a user