feat(widgets): unify data table behavior
This commit is contained in:
11 files changed
+176
-286
No files matched your search
@@ -23,7 +23,7 @@ Select a system row to open the complete statistics view, including system, file
|
||||
|
||||
### Interactions
|
||||
|
||||
- **Reorder columns** by dragging their headers
|
||||
- **Reorder columns** by hovering a header to reveal its grab handle, then dragging it
|
||||
- **Resize columns** by dragging a header's right edge
|
||||
- **Sort systems** by selecting a sortable column header
|
||||
- **Open system statistics** by selecting a row
|
||||
|
||||
@@ -40,7 +40,7 @@ import dockerTable from './img/table.png';
|
||||
|
||||
### Interactions
|
||||
|
||||
Drag column headers to reorder them and drag a header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
|
||||
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
|
||||
|
||||
Each container row has an action menu, accessible via the three-dot icon or by right-clicking the row, with the following options:
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ 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** — choose which columns to display via the widget options panel
|
||||
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
|
||||
- **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
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
|
||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
||||
import { DataTable } from "mantine-datatable";
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
@@ -28,6 +25,7 @@ import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||
import { usePersistedTableLayout } from "../common/use-persisted-table-layout";
|
||||
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
||||
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||
@@ -179,7 +177,7 @@ export default function BeszelSystemTableWidget({
|
||||
),
|
||||
sortable: true,
|
||||
render: (record) => (
|
||||
<Group gap={8} wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
<Group gap={8} wrap="nowrap" style={{ overflow: "hidden", paddingInlineStart: 4 }}>
|
||||
<Indicator color={statusColorMap[record.status]} size={7} />
|
||||
<Text size={size.fontSize} fw={500} truncate>
|
||||
{record.name}
|
||||
@@ -387,15 +385,9 @@ export default function BeszelSystemTableWidget({
|
||||
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
</div>
|
||||
<DataTable
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
withTableBorder={false}
|
||||
borderRadius={0}
|
||||
highlightOnHover
|
||||
striped="odd"
|
||||
stripedColor={{ dark: "dark.7", light: "gray.0" }}
|
||||
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
|
||||
verticalAlign="center"
|
||||
<HomarrDataTable
|
||||
isEditMode={isEditMode}
|
||||
cellPadding={`${size.cellPadding}px 8px`}
|
||||
fz={size.fontSize}
|
||||
records={sortedSystems}
|
||||
columns={effectiveColumns}
|
||||
@@ -403,17 +395,7 @@ export default function BeszelSystemTableWidget({
|
||||
onSortStatusChange={setSortStatus}
|
||||
noRecordsText={t("noRecords")}
|
||||
idAccessor="_key"
|
||||
height="100%"
|
||||
className="beszel-table"
|
||||
storeColumnsKey={storeKey}
|
||||
textSelectionDisabled
|
||||
defaultColumnProps={{
|
||||
noWrap: true,
|
||||
draggable: true,
|
||||
resizable: true,
|
||||
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
|
||||
}}
|
||||
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
|
||||
onRowClick={isEditMode ? undefined : handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/* Make the DataTable transparent so the board card's opacity-controlled
|
||||
background shows through. The mantine-datatable defaults to
|
||||
var(--mantine-color-body) which is opaque. */
|
||||
.beszel-table,
|
||||
.beszel-table .mantine-datatable-table,
|
||||
.beszel-table .mantine-datatable-table thead,
|
||||
.beszel-table .mantine-datatable-table tbody,
|
||||
.beszel-table .mantine-datatable-table tfoot,
|
||||
.beszel-table th,
|
||||
.beszel-table td {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.beszel-table .mantine-datatable-table tr {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.beszel-table .mantine-datatable-table tbody tr:hover {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||
}
|
||||
|
||||
.beszel-table th {
|
||||
white-space: nowrap;
|
||||
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.beszel-table td {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.beszel-table td .mantine-Text-root,
|
||||
.beszel-table td .mantine-Group-root {
|
||||
white-space: nowrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.beszel-table .mantine-datatable-header-cell-sortable-icon {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.beszel-table th:hover .mantine-datatable-header-cell-sortable-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.homarr-data-table,
|
||||
.homarr-data-table .mantine-datatable-table,
|
||||
.homarr-data-table .mantine-datatable-table thead,
|
||||
.homarr-data-table .mantine-datatable-table tbody,
|
||||
.homarr-data-table .mantine-datatable-table tfoot,
|
||||
.homarr-data-table th,
|
||||
.homarr-data-table td {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.homarr-data-table .mantine-datatable-table tr {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.homarr-data-table .mantine-datatable-table tbody tr:hover {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||
}
|
||||
|
||||
.homarr-data-table th {
|
||||
white-space: nowrap;
|
||||
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.homarr-data-table td {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.homarr-data-table td .mantine-Text-root,
|
||||
.homarr-data-table td .mantine-Group-root {
|
||||
white-space: nowrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.homarr-data-table .mantine-datatable-header-cell-sortable-icon,
|
||||
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
.homarr-data-table th:hover .mantine-datatable-header-cell-sortable-icon,
|
||||
.homarr-data-table th:hover .mantine-datatable-header-cell-draggable-action-icon,
|
||||
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon:focus-visible,
|
||||
.homarr-data-table .mantine-datatable-header-cell-drag-over .mantine-datatable-header-cell-draggable-action-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.homarr-data-table-row-cursor-default .mantine-datatable-context-menu-cursor {
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import "./homarr-data-table.css";
|
||||
|
||||
import type { DataTableProps } from "mantine-datatable";
|
||||
import { DataTable } from "mantine-datatable";
|
||||
|
||||
type HomarrDataTableProps<T> = DataTableProps<T> & {
|
||||
isEditMode: boolean;
|
||||
cellPadding: string;
|
||||
rowCursor?: "auto" | "default";
|
||||
};
|
||||
|
||||
export function HomarrDataTable<T>({
|
||||
isEditMode,
|
||||
cellPadding,
|
||||
rowCursor = "auto",
|
||||
className,
|
||||
defaultColumnProps,
|
||||
scrollAreaProps,
|
||||
style,
|
||||
...props
|
||||
}: HomarrDataTableProps<T>) {
|
||||
const mergedClassName = [
|
||||
"homarr-data-table",
|
||||
rowCursor === "default" ? "homarr-data-table-row-cursor-default" : undefined,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<DataTable<T>
|
||||
withTableBorder={false}
|
||||
borderRadius={0}
|
||||
highlightOnHover
|
||||
striped="odd"
|
||||
stripedColor={{ dark: "dark.7", light: "gray.0" }}
|
||||
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
|
||||
verticalAlign="center"
|
||||
loaderBackgroundBlur={2}
|
||||
textSelectionDisabled
|
||||
height="100%"
|
||||
{...props}
|
||||
className={mergedClassName}
|
||||
style={[{ pointerEvents: isEditMode ? "none" : undefined }, style]}
|
||||
defaultColumnProps={{
|
||||
...defaultColumnProps,
|
||||
noWrap: defaultColumnProps?.noWrap ?? true,
|
||||
draggable: !isEditMode,
|
||||
resizable: !isEditMode,
|
||||
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
|
||||
}}
|
||||
scrollAreaProps={{ type: "auto", scrollbarSize: 6, ...scrollAreaProps }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ActionIcon, Avatar, Badge, Box, Center, Group, Menu, Portal, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
@@ -17,7 +14,6 @@ import {
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
||||
import { DataTable } from "mantine-datatable";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -33,6 +29,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||
import { usePersistedTableLayout } from "../common/use-persisted-table-layout";
|
||||
|
||||
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
|
||||
@@ -51,6 +48,7 @@ interface ContainerActionHandlers {
|
||||
}
|
||||
|
||||
const columnAccessors = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
|
||||
const containerMenuWidth = 240;
|
||||
|
||||
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
|
||||
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
|
||||
@@ -196,7 +194,6 @@ export default function DockerWidget({
|
||||
}: WidgetComponentProps<"dockerContainers">) {
|
||||
const t = useScopedI18n("docker");
|
||||
const tWidget = useScopedI18n("widget.dockerContainers");
|
||||
const router = useRouter();
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const board = useOptionalBoard();
|
||||
const { data: session } = useSession();
|
||||
@@ -222,8 +219,8 @@ export default function DockerWidget({
|
||||
[removeContainer, restartContainer, startContainer, stopContainer],
|
||||
);
|
||||
const handleOpenLogs = useCallback(
|
||||
(container: DockerContainer) => router.push(createContainerLogsPath(container)),
|
||||
[router],
|
||||
(container: DockerContainer) => window.location.assign(createContainerLogsPath(container)),
|
||||
[],
|
||||
);
|
||||
const handleAddToHomarr = useCallback(
|
||||
(container: DockerContainer) => openModal({ selectedContainers: [container] }),
|
||||
@@ -317,35 +314,19 @@ export default function DockerWidget({
|
||||
return (
|
||||
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
<DataTable
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
withTableBorder={false}
|
||||
borderRadius={0}
|
||||
highlightOnHover
|
||||
striped="odd"
|
||||
stripedColor={{ dark: "dark.7", light: "gray.0" }}
|
||||
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
|
||||
verticalAlign="center"
|
||||
<HomarrDataTable
|
||||
isEditMode={isEditMode}
|
||||
cellPadding={width < 400 ? "2px 8px" : "4px 8px"}
|
||||
rowCursor="default"
|
||||
fetching={isFetching && containers.length === 0}
|
||||
loaderBackgroundBlur={2}
|
||||
fz={width < 400 ? "xs" : "sm"}
|
||||
records={sortedContainers}
|
||||
noRecordsText={tWidget("empty.noContainers")}
|
||||
columns={effectiveColumns}
|
||||
storeColumnsKey={storeKey}
|
||||
textSelectionDisabled
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={options.enableRowSorting && !isEditMode ? setSortStatus : undefined}
|
||||
idAccessor="id"
|
||||
height="100%"
|
||||
className="docker-containers-table"
|
||||
defaultColumnProps={{
|
||||
noWrap: true,
|
||||
draggable: true,
|
||||
resizable: true,
|
||||
cellsStyle: () => ({ padding: width < 400 ? "2px 8px" : "4px 8px" }),
|
||||
}}
|
||||
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
|
||||
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
|
||||
onScroll={() => {
|
||||
if (contextMenu) closeContextMenu();
|
||||
@@ -414,7 +395,7 @@ function ContainerMenuButton({
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Dropdown w={containerMenuWidth} miw={containerMenuWidth} maw={containerMenuWidth}>
|
||||
<ContainerActionItems container={container} handlers={handlers} onClose={close} />
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
@@ -442,7 +423,9 @@ function ContainerActionItems({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Label>{container.name}</Menu.Label>
|
||||
<Menu.Label title={container.name} style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{container.name}
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileText size={14} />}
|
||||
onClick={() => {
|
||||
@@ -521,7 +504,12 @@ function ContainerContextMenu({
|
||||
<Menu.Target>
|
||||
<Box pos="fixed" style={{ left: state.x, top: state.y, width: 1, height: 1 }} />
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown onClick={(event) => event.stopPropagation()}>
|
||||
<Menu.Dropdown
|
||||
w={containerMenuWidth}
|
||||
miw={containerMenuWidth}
|
||||
maw={containerMenuWidth}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ContainerActionItems container={state.container} handlers={handlers} onClose={onClose} />
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/* Keep the table transparent so the board card controls the surface. */
|
||||
.docker-containers-table,
|
||||
.docker-containers-table .mantine-datatable-table,
|
||||
.docker-containers-table .mantine-datatable-table thead,
|
||||
.docker-containers-table .mantine-datatable-table tbody,
|
||||
.docker-containers-table .mantine-datatable-table tfoot,
|
||||
.docker-containers-table th,
|
||||
.docker-containers-table td {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.docker-containers-table .mantine-datatable-table tr {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.docker-containers-table .mantine-datatable-table tbody tr:hover {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||
}
|
||||
|
||||
.docker-containers-table th {
|
||||
white-space: nowrap;
|
||||
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.docker-containers-table td {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.docker-containers-table td .mantine-Text-root,
|
||||
.docker-containers-table td .mantine-Group-root {
|
||||
white-space: nowrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.docker-containers-table .mantine-datatable-header-cell-sortable-icon {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.docker-containers-table th:hover .mantine-datatable-header-cell-sortable-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import "../widgets-common.css";
|
||||
import "./styles.css";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
@@ -41,10 +41,11 @@ import {
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
||||
import { DataTable, useDataTableColumns } from "mantine-datatable";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||
import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
|
||||
@@ -52,6 +53,8 @@ import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||
import { usePersistedTableLayout } from "../common/use-persisted-table-layout";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@@ -162,14 +165,23 @@ function progressColor(state: DownloadState, progress: number): string {
|
||||
return "blue";
|
||||
}
|
||||
|
||||
function parseJsonOption<T>(value: string | undefined, fallback: T): T {
|
||||
if (!value) return fallback;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
const columnAccessors = [
|
||||
"name",
|
||||
"progress",
|
||||
"size",
|
||||
"downSpeed",
|
||||
"upSpeed",
|
||||
"time",
|
||||
"state",
|
||||
"added",
|
||||
"ratio",
|
||||
"received",
|
||||
"sent",
|
||||
"category",
|
||||
"integration",
|
||||
"index",
|
||||
"type",
|
||||
] as const;
|
||||
|
||||
function toCategoryArray(category: string | string[]): string[] {
|
||||
if (Array.isArray(category)) return category;
|
||||
@@ -288,6 +300,9 @@ export default function DownloadClientsWidget({
|
||||
width,
|
||||
}: WidgetComponentProps<"downloads">) {
|
||||
const allInteractAccess = useIntegrationsWithInteractAccess();
|
||||
const board = useOptionalBoard();
|
||||
const { data: session } = useSession();
|
||||
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
|
||||
const integrationInteractSet = useMemo(
|
||||
() => new Set(allInteractAccess.filter(({ id }) => integrationIds.includes(id)).map(({ id }) => id)),
|
||||
[allInteractAccess, integrationIds],
|
||||
@@ -324,17 +339,11 @@ export default function DownloadClientsWidget({
|
||||
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
|
||||
});
|
||||
const persistOption = useCallback(
|
||||
(newOptions: Partial<Record<string, string>>) => {
|
||||
(newOptions: Partial<Pick<typeof options, "columnOrder" | "columnWidths">>) => {
|
||||
setOptions({ newOptions });
|
||||
if (boardId && itemId) saveItemOptions({ boardId, itemId, newOptions });
|
||||
if (hasChangeAccess && boardId && itemId) saveItemOptions({ boardId, itemId, newOptions });
|
||||
},
|
||||
[setOptions, boardId, itemId, saveItemOptions],
|
||||
);
|
||||
|
||||
const savedOrder = useMemo(() => parseJsonOption(options.columnOrder, [] as string[]), [options.columnOrder]);
|
||||
const savedWidths = useMemo(
|
||||
() => parseJsonOption(options.columnWidths, {} as Record<string, number>),
|
||||
[options.columnWidths],
|
||||
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
|
||||
);
|
||||
|
||||
let defaultSortDirection: DataTableSortStatus<ExtendedDownloadClientItem>["direction"] = "asc";
|
||||
@@ -659,54 +668,15 @@ export default function DownloadClientsWidget({
|
||||
return cols.filter(Boolean) as DataTableColumn<ExtendedDownloadClientItem>[];
|
||||
}, [columnContext, t, size, progressColumnWidth]);
|
||||
|
||||
const storeKey = `downloads-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`;
|
||||
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
|
||||
useDataTableColumns<ExtendedDownloadClientItem>({
|
||||
key: storeKey,
|
||||
columns,
|
||||
});
|
||||
|
||||
const lastStoreKey = useRef(storeKey);
|
||||
const hydrated = useRef(false);
|
||||
useEffect(() => {
|
||||
if (lastStoreKey.current !== storeKey) {
|
||||
lastStoreKey.current = storeKey;
|
||||
hydrated.current = false;
|
||||
}
|
||||
if (hydrated.current) return;
|
||||
if (savedOrder.length > 0) setColumnsOrder(savedOrder);
|
||||
if (Object.keys(savedWidths).length > 0) {
|
||||
setMultipleColumnWidths(Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })));
|
||||
}
|
||||
// ponytail: defer hydrated flag so the persist effect skips the restore echo
|
||||
requestAnimationFrame(() => {
|
||||
hydrated.current = true;
|
||||
});
|
||||
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
|
||||
|
||||
const prevOrder = useRef(columnsOrder);
|
||||
const prevWidths = useRef(columnsWidth);
|
||||
useEffect(() => {
|
||||
if (!hydrated.current) return;
|
||||
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
|
||||
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
|
||||
prevOrder.current = columnsOrder;
|
||||
prevWidths.current = columnsWidth;
|
||||
|
||||
if (!orderChanged && !widthsChanged) return;
|
||||
if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) });
|
||||
if (widthsChanged) {
|
||||
const widthMap: Record<string, number> = {};
|
||||
for (const entry of columnsWidth) {
|
||||
const key = Object.keys(entry)[0];
|
||||
if (!key) continue;
|
||||
const w = entry[key as keyof typeof entry];
|
||||
if (typeof w === "number") widthMap[key] = w;
|
||||
else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10);
|
||||
}
|
||||
persistOption({ columnWidths: JSON.stringify(widthMap) });
|
||||
}
|
||||
}, [columnsOrder, columnsWidth, persistOption]);
|
||||
const { effectiveColumns, storeKey } = usePersistedTableLayout({
|
||||
columns,
|
||||
columnAccessors,
|
||||
columnOrder: options.columnOrder,
|
||||
columnWidths: options.columnWidths,
|
||||
itemId,
|
||||
storeKeyPrefix: "downloads",
|
||||
onLayoutChange: persistOption,
|
||||
});
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
({ record, event }: { record: ExtendedDownloadClientItem; event: React.MouseEvent }) => {
|
||||
@@ -761,9 +731,6 @@ export default function DownloadClientsWidget({
|
||||
);
|
||||
}
|
||||
|
||||
let tablePointerEvents: React.CSSProperties["pointerEvents"];
|
||||
if (isEditMode) tablePointerEvents = "none";
|
||||
|
||||
let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu;
|
||||
if (isEditMode) rowContextMenuHandler = undefined;
|
||||
|
||||
@@ -781,35 +748,19 @@ export default function DownloadClientsWidget({
|
||||
)}
|
||||
|
||||
<Box style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
||||
<DataTable
|
||||
style={{ pointerEvents: tablePointerEvents }}
|
||||
withTableBorder={false}
|
||||
borderRadius={0}
|
||||
highlightOnHover
|
||||
striped="odd"
|
||||
stripedColor={{ dark: "dark.7", light: "gray.0" }}
|
||||
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
|
||||
verticalAlign="center"
|
||||
<HomarrDataTable
|
||||
isEditMode={isEditMode}
|
||||
cellPadding={`${size.cellPadding}px 8px`}
|
||||
fetching={isFetching && currentItems.length === 0}
|
||||
loaderBackgroundBlur={2}
|
||||
fz={size.fontSize}
|
||||
records={sortedData}
|
||||
columns={effectiveColumns}
|
||||
storeColumnsKey={storeKey}
|
||||
textSelectionDisabled
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
noRecordsText={t("errors.noItems")}
|
||||
idAccessor={(record) => `${record.integration.id}:${record.id}`}
|
||||
height="100%"
|
||||
className="downloads-table"
|
||||
defaultColumnProps={{
|
||||
noWrap: true,
|
||||
draggable: true,
|
||||
resizable: true,
|
||||
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
|
||||
}}
|
||||
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
|
||||
customRowAttributes={(_, index) => ({ "data-row-index": index })}
|
||||
onRowContextMenu={rowContextMenuHandler}
|
||||
rowBackgroundColor={(record) => rowBgAlpha[record.state]}
|
||||
|
||||
@@ -1,51 +1,3 @@
|
||||
/* Make the DataTable transparent so the board card's opacity-controlled
|
||||
background shows through. The mantine-datatable defaults to
|
||||
var(--mantine-color-body) which is opaque. */
|
||||
.downloads-table,
|
||||
.downloads-table .mantine-datatable-table,
|
||||
.downloads-table .mantine-datatable-table thead,
|
||||
.downloads-table .mantine-datatable-table tbody,
|
||||
.downloads-table .mantine-datatable-table tfoot,
|
||||
.downloads-table th,
|
||||
.downloads-table td {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.downloads-table .mantine-datatable-table tr {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.downloads-table .mantine-datatable-table tbody tr:hover {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||
}
|
||||
|
||||
.downloads-table th {
|
||||
white-space: nowrap;
|
||||
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.downloads-table td {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.downloads-table td .mantine-Text-root,
|
||||
.downloads-table td .mantine-Group-root {
|
||||
white-space: nowrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.downloads-table .mantine-datatable-header-cell-sortable-icon {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.downloads-table th:hover .mantine-datatable-header-cell-sortable-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Row expansion styling */
|
||||
.downloads-table .mantine-datatable-row-expansion-cell {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;
|
||||
|
||||
Reference in new issue
Block a user