Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86478adf7a | ||
|
|
dafe1fdb75 |
No files matched your search
@@ -1,622 +0,0 @@
|
|||||||
# Migrating Tables from mantine-react-table to mantine-datatable
|
|
||||||
|
|
||||||
This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables.
|
|
||||||
|
|
||||||
## Migration Candidates
|
|
||||||
|
|
||||||
Current `mantine-react-table` consumers that could be migrated:
|
|
||||||
|
|
||||||
**Widgets (in `packages/widgets/`):**
|
|
||||||
- `docker/component.tsx` — Docker container table
|
|
||||||
- `media-server/component.tsx` — Media server sessions
|
|
||||||
|
|
||||||
**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):**
|
|
||||||
- `tools/docker/docker-table.tsx`
|
|
||||||
- `tools/tasks/_components/tasks-table.tsx`
|
|
||||||
- `tools/api/components/api-keys.tsx`
|
|
||||||
- `users/_components/user-list.tsx`
|
|
||||||
- `users/invites/_components/invite-list.tsx`
|
|
||||||
- `tools/kubernetes/` — 8 Kubernetes resource tables
|
|
||||||
|
|
||||||
**Modals:**
|
|
||||||
- `packages/modals-collection/src/search-engines/request-media-modal.tsx`
|
|
||||||
|
|
||||||
**Shared hook to remove once all migrated:**
|
|
||||||
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Core Migration: mantine-react-table → mantine-datatable
|
|
||||||
|
|
||||||
### Import changes
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// BEFORE
|
|
||||||
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
|
||||||
|
|
||||||
// AFTER
|
|
||||||
import { DataTable, useDataTableColumns } from "mantine-datatable";
|
|
||||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
|
||||||
```
|
|
||||||
|
|
||||||
### Column definition shape
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// BEFORE (MRT)
|
|
||||||
const columns: MRT_ColumnDef<MyRow>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "name",
|
|
||||||
header: "Name",
|
|
||||||
size: 200,
|
|
||||||
enableSorting: true,
|
|
||||||
Cell: ({ row }) => <Text>{row.original.name}</Text>,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// AFTER (mantine-datatable)
|
|
||||||
const columns: DataTableColumn<MyRow>[] = [
|
|
||||||
{
|
|
||||||
accessor: "name", // not accessorKey
|
|
||||||
title: "Name", // not header
|
|
||||||
width: 200, // not size
|
|
||||||
sortable: true, // not enableSorting
|
|
||||||
render: (record) => <Text>{record.name}</Text>, // not Cell
|
|
||||||
},
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
Key differences:
|
|
||||||
- `accessorKey` → `accessor`
|
|
||||||
- `header` → `title`
|
|
||||||
- `size` → `width`
|
|
||||||
- `Cell: ({ row }) =>` → `render: (record) =>`
|
|
||||||
- `enableSorting` → `sortable`
|
|
||||||
- `enableColumnActions` → does not exist (no column action menus)
|
|
||||||
- `mantineTableBodyCellProps` → `cellsStyle` in `defaultColumnProps`
|
|
||||||
|
|
||||||
### Table component
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// BEFORE
|
|
||||||
const table = useTranslatedMantineReactTable({ columns, data, ... });
|
|
||||||
return <MantineReactTable table={table} />;
|
|
||||||
|
|
||||||
// AFTER
|
|
||||||
return (
|
|
||||||
<DataTable
|
|
||||||
records={data}
|
|
||||||
columns={columns}
|
|
||||||
sortStatus={sortStatus}
|
|
||||||
onSortStatusChange={setSortStatus}
|
|
||||||
...
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sorting is manual
|
|
||||||
|
|
||||||
mantine-datatable does NOT sort for you. You must sort the data yourself:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<MyRow>>({
|
|
||||||
columnAccessor: "name",
|
|
||||||
direction: "asc",
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedData = useMemo(() => {
|
|
||||||
const mult = sortStatus.direction === "desc" ? -1 : 1;
|
|
||||||
return [...data].toSorted((a, b) => {
|
|
||||||
const aVal = a[sortStatus.columnAccessor];
|
|
||||||
const bVal = b[sortStatus.columnAccessor];
|
|
||||||
if (typeof aVal === "string") return aVal.localeCompare(bVal as string) * mult;
|
|
||||||
return ((aVal as number) - (bVal as number)) * mult;
|
|
||||||
});
|
|
||||||
}, [data, sortStatus]);
|
|
||||||
|
|
||||||
// Pass sortedData to records, not data
|
|
||||||
<DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} />
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Making Tables Transparent (Widget Context)
|
|
||||||
|
|
||||||
Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque.
|
|
||||||
|
|
||||||
Create a CSS file alongside the component:
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* styles.css */
|
|
||||||
.my-table,
|
|
||||||
.my-table .mantine-datatable-table,
|
|
||||||
.my-table .mantine-datatable-table thead,
|
|
||||||
.my-table .mantine-datatable-table tbody,
|
|
||||||
.my-table .mantine-datatable-table tfoot,
|
|
||||||
.my-table th,
|
|
||||||
.my-table td {
|
|
||||||
background-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-table .mantine-datatable-table tr {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover: translucent instead of opaque */
|
|
||||||
.my-table .mantine-datatable-table tbody tr:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Headers: frosted glass effect */
|
|
||||||
.my-table th {
|
|
||||||
white-space: nowrap;
|
|
||||||
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cell overflow */
|
|
||||||
.my-table td {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide sort icon until hover */
|
|
||||||
.my-table .mantine-datatable-header-cell-sortable-icon {
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 150ms;
|
|
||||||
}
|
|
||||||
.my-table th:hover .mantine-datatable-header-cell-sortable-icon {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Row expansion area */
|
|
||||||
.my-table .mantine-datatable-row-expansion-cell {
|
|
||||||
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Import it: `import "./styles.css";` and apply via `className="my-table"`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Responsive Column Visibility
|
|
||||||
|
|
||||||
Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface SizeConfig {
|
|
||||||
fontSize: "xs" | "sm";
|
|
||||||
iconSize: number;
|
|
||||||
cellPadding: number;
|
|
||||||
showSpeedColumns: boolean;
|
|
||||||
showTimeColumn: boolean;
|
|
||||||
showStateColumn: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [
|
|
||||||
{ maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } },
|
|
||||||
{ maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true };
|
|
||||||
|
|
||||||
function getSizeConfig(width: number): SizeConfig {
|
|
||||||
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
|
|
||||||
if (width < maxWidth) return config;
|
|
||||||
}
|
|
||||||
return DEFAULT_SIZE_CONFIG;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage: memoize it
|
|
||||||
const size = useMemo(() => getSizeConfig(width), [width]);
|
|
||||||
```
|
|
||||||
|
|
||||||
Then use a visibility check lookup table for columns:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
|
|
||||||
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
|
|
||||||
downSpeed: (ctx) => ctx.size.showSpeedColumns,
|
|
||||||
time: (ctx) => ctx.size.showTimeColumn,
|
|
||||||
state: (ctx) => ctx.size.showStateColumn,
|
|
||||||
};
|
|
||||||
|
|
||||||
function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
|
|
||||||
if (!ctx.optionsColumnSet.has(accessor)) return false;
|
|
||||||
const check = columnVisibilityChecks[accessor];
|
|
||||||
if (check && !check(ctx)) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Column Persistence (Drag/Resize → Server)
|
|
||||||
|
|
||||||
`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices.
|
|
||||||
|
|
||||||
### Hidden widget options
|
|
||||||
|
|
||||||
In `index.ts`, add hidden text options:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
columnOrder: factory.text({ defaultValue: "" }),
|
|
||||||
columnWidths: factory.text({ defaultValue: "" }),
|
|
||||||
```
|
|
||||||
|
|
||||||
With visibility override:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
columnOrder: { shouldHide: () => true },
|
|
||||||
columnWidths: { shouldHide: () => true },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Translation keys required
|
|
||||||
|
|
||||||
Even hidden options need translation keys or the translation test fails:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"widget.mywidget.option.columnOrder.label": "Column order",
|
|
||||||
"widget.mywidget.option.columnWidths.label": "Column widths"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Old-import mappings
|
|
||||||
|
|
||||||
Add to `packages/old-import/src/widgets/options.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
columnOrder: () => undefined,
|
|
||||||
columnWidths: () => undefined,
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dynamic storeKey
|
|
||||||
|
|
||||||
Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hydration from server → localStorage
|
|
||||||
|
|
||||||
On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
|
|
||||||
useDataTableColumns<MyRow>({ 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 })),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => { hydrated.current = true; });
|
|
||||||
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Persist localStorage → server
|
|
||||||
|
|
||||||
Watch for changes and write back:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
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]);
|
|
||||||
```
|
|
||||||
|
|
||||||
The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Context Menu (Right-Click)
|
|
||||||
|
|
||||||
Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ContextMenuState {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
item: MyRow;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
||||||
|
|
||||||
const handleContextMenu = useCallback(
|
|
||||||
({ record, event }: { record: MyRow; event: React.MouseEvent }) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setContextMenu({ x: event.clientX, y: event.clientY, item: record });
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// On DataTable:
|
|
||||||
<DataTable onRowContextMenu={handleContextMenu} />
|
|
||||||
|
|
||||||
// Render:
|
|
||||||
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} />}
|
|
||||||
```
|
|
||||||
|
|
||||||
The menu component uses `Portal` for correct z-index:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) {
|
|
||||||
return (
|
|
||||||
<Portal>
|
|
||||||
<Menu opened onClose={onClose} closeOnItemClick={false} position="right-start" offset={0}>
|
|
||||||
<Menu.Target>
|
|
||||||
<Box style={{ position: "fixed", left: state.x, top: state.y, width: 0, height: 0 }} />
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item onClick={() => { /* action */ onClose(); }}>Action</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Row Expansion
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
<DataTable
|
|
||||||
rowExpansion={{
|
|
||||||
trigger: "click",
|
|
||||||
allowMultiple: true,
|
|
||||||
collapseProps: {
|
|
||||||
transitionDuration: 200,
|
|
||||||
animateOpacity: true,
|
|
||||||
transitionTimingFunction: "ease-out",
|
|
||||||
},
|
|
||||||
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Common Pitfalls & Fixes
|
|
||||||
|
|
||||||
### Status indicator dots
|
|
||||||
|
|
||||||
Don't use `<Badge variant="dot" />` without children — it renders a pill with empty space. Use a plain `Box`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Box
|
|
||||||
w={8}
|
|
||||||
h={8}
|
|
||||||
style={{
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: `var(--mantine-color-${color}-filled)`,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tooltip overflow on long text columns
|
|
||||||
|
|
||||||
When a column has `ellipsis: true`, the tooltip target element can still extend beyond the cell bounds if inner elements don't clip. Fix:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Tooltip position="bottom-start" ...>
|
|
||||||
<Group style={{ overflow: "hidden" }}>
|
|
||||||
...
|
|
||||||
</Group>
|
|
||||||
</Tooltip>
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `position="bottom-start"` instead of `position="right"` so the tooltip never escapes the widget horizontally.
|
|
||||||
|
|
||||||
### Dark mode tooltips
|
|
||||||
|
|
||||||
Mantine tooltips default to the theme's tooltip color which may be white in light mode and stay white in dark mode. Always set `color="dark"` on informational tooltips inside widgets.
|
|
||||||
|
|
||||||
### Division by zero
|
|
||||||
|
|
||||||
Guard all ratio calculations:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (item.size > 0) ratio = item.sent / item.size;
|
|
||||||
if (totalDown > 0) ratio = totalUp / totalDown;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Composite idAccessor
|
|
||||||
|
|
||||||
When rows can come from multiple integrations, IDs may collide. Use a composite key:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
idAccessor={(record) => `${record.integration.id}:${record.id}`}
|
|
||||||
```
|
|
||||||
|
|
||||||
### JSON option parsing
|
|
||||||
|
|
||||||
Always wrap in try/catch:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function parseJsonOption<T>(value: string | undefined, fallback: T): T {
|
|
||||||
if (!value) return fallback;
|
|
||||||
try { return JSON.parse(value) as T; }
|
|
||||||
catch { return fallback; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Clipboard API
|
|
||||||
|
|
||||||
`navigator.clipboard.writeText()` can throw in non-HTTPS contexts. Always catch:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
void navigator.clipboard.writeText(text).catch(() => {});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sync sortStatus with widget options
|
|
||||||
|
|
||||||
If the widget has configurable default sort, sync it:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
useEffect(() => {
|
|
||||||
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
|
|
||||||
}, [options.defaultSort, defaultSortDirection]);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Accessibility
|
|
||||||
|
|
||||||
- All `ActionIcon` components need `aria-label`
|
|
||||||
- Filter toggles (client badges, status badges) need `aria-pressed={isActive}`
|
|
||||||
- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Error Handling
|
|
||||||
|
|
||||||
- Every mutation needs an `onError` callback with `showErrorNotification`
|
|
||||||
- The `saveItemOptions` mutation (column persistence) also needs `onError`
|
|
||||||
- Check `isError` on the main query and show a fallback UI
|
|
||||||
- Use a shared `mutationOptions` object to avoid repeating `onSettled`/`onError`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const mutationOptions = {
|
|
||||||
onSettled: () => void utils.myQuery.invalidate(),
|
|
||||||
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. i18n
|
|
||||||
|
|
||||||
- No hardcoded strings. Every user-visible string goes through `useScopedI18n`
|
|
||||||
- Column titles: `t("items.{accessor}.columnTitle")`
|
|
||||||
- Detail labels: `t("items.{accessor}.detailsTitle")`
|
|
||||||
- States: `t("states.{state}")`
|
|
||||||
- Actions: `t("actions.{action}")`
|
|
||||||
- Errors: `t("errors.{errorKey}")`
|
|
||||||
- Hidden options still need `.label` translation keys
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Edit Mode
|
|
||||||
|
|
||||||
Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
let tablePointerEvents: React.CSSProperties["pointerEvents"];
|
|
||||||
if (isEditMode) tablePointerEvents = "none";
|
|
||||||
|
|
||||||
let rowContextMenuHandler = handleContextMenu;
|
|
||||||
if (isEditMode) rowContextMenuHandler = undefined;
|
|
||||||
|
|
||||||
<DataTable style={{ pointerEvents: tablePointerEvents }} />
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. DataTable Props Reference (Commonly Used)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
<DataTable
|
|
||||||
records={sortedData}
|
|
||||||
columns={effectiveColumns}
|
|
||||||
storeColumnsKey={storeKey}
|
|
||||||
withTableBorder={false}
|
|
||||||
borderRadius={0}
|
|
||||||
highlightOnHover
|
|
||||||
striped="odd"
|
|
||||||
stripedColor={{ dark: "dark.7", light: "gray.0" }}
|
|
||||||
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
|
|
||||||
verticalAlign="center"
|
|
||||||
fetching={isFetching && data.length === 0}
|
|
||||||
loaderBackgroundBlur={2}
|
|
||||||
fz={size.fontSize}
|
|
||||||
textSelectionDisabled
|
|
||||||
sortStatus={sortStatus}
|
|
||||||
onSortStatusChange={setSortStatus}
|
|
||||||
noRecordsText={t("errors.noItems")}
|
|
||||||
idAccessor={(record) => record.uniqueKey}
|
|
||||||
height="100%"
|
|
||||||
className="my-table"
|
|
||||||
defaultColumnProps={{
|
|
||||||
noWrap: true,
|
|
||||||
draggable: true,
|
|
||||||
resizable: true,
|
|
||||||
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
|
|
||||||
}}
|
|
||||||
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
|
|
||||||
rowBackgroundColor={(record) => conditionalBgMap[record.state]}
|
|
||||||
onRowContextMenu={handleContextMenu}
|
|
||||||
rowExpansion={{ ... }}
|
|
||||||
onScroll={() => { if (contextMenu) closeContextMenu(); }}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Layout Stability
|
|
||||||
|
|
||||||
- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic
|
|
||||||
- Give non-name columns explicit `width` values so only the name column flexes
|
|
||||||
- Use `ellipsis: true` on the name column — it handles `text-overflow: ellipsis` internally
|
|
||||||
- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length
|
|
||||||
- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Checklist for Each Migration
|
|
||||||
|
|
||||||
1. Replace imports (`mantine-react-table` → `mantine-datatable`)
|
|
||||||
2. Convert column definitions (`accessorKey` → `accessor`, `header` → `title`, `Cell` → `render`, etc.)
|
|
||||||
3. Implement manual sorting with `useState` + `useMemo`
|
|
||||||
4. Add CSS file for transparent backgrounds (if widget context)
|
|
||||||
5. Add responsive column visibility (if widget context)
|
|
||||||
6. Add hidden options + hydration logic (if column persistence needed)
|
|
||||||
7. Add old-import mappings for new options
|
|
||||||
8. Add translation keys (including hidden option labels)
|
|
||||||
9. Add context menu if row actions exist
|
|
||||||
10. Add row expansion if detail view exists
|
|
||||||
11. Add error handling on all mutations
|
|
||||||
12. Add accessibility attributes
|
|
||||||
13. Test at multiple widget widths
|
|
||||||
14. Update docs in `apps/docs/docs/widgets/` if applicable
|
|
||||||
15. Run `pnpm turbo typecheck` — catches old-import mismatches
|
|
||||||
16. Run translation spec — catches missing keys
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
extends: ["config:recommended", ":dependencyDashboardApproval"],
|
extends: ["config:recommended"],
|
||||||
packageRules: [
|
packageRules: [
|
||||||
{
|
{
|
||||||
matchPackageNames: ["^@homarr/"],
|
matchPackageNames: ["^@homarr/"],
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
rangeStrategy: "bump",
|
rangeStrategy: "bump",
|
||||||
automerge: false,
|
automerge: false,
|
||||||
baseBranchPatterns: ["dev"],
|
baseBranchPatterns: ["dev"],
|
||||||
dependencyDashboard: true,
|
dependencyDashboard: false,
|
||||||
// prevent special characters in branch names (otherwise git errors occur on windows)
|
// prevent special characters in branch names (otherwise git errors occur on windows)
|
||||||
branchNameStrict: true,
|
branchNameStrict: true,
|
||||||
}
|
}
|
||||||
@@ -55,9 +55,9 @@ A random secret can be generated by using the following command: `openssl rand -
|
|||||||
|
|
||||||
| Environment Variable | Description | Possible values | Default |
|
| Environment Variable | Description | Possible values | Default |
|
||||||
| ------------------------ | ----------- | --------------- | ------- |
|
| ------------------------ | ----------- | --------------- | ------- |
|
||||||
| ``DOCKER_HOSTNAMES`` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
| ``DOCKER_HOSTNAMES`` | Comma seperated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
||||||
| ``DOCKER_PORTS`` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
|
| ``DOCKER_PORTS`` | Comma seperated list of ports to connect to | For example `2375,2376` | - |
|
||||||
| ``DOCKER_SOCKET_PATHS`` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
| ``DOCKER_SOCKET_PATHS`` | Comma seperated list of paths to sockets | For example `/var/run/docker.sock,/run/user/1000/podman/podman.sock` | - |
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
| Environment Variable | Description | Possible values | Default |
|
| Environment Variable | Description | Possible values | Default |
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
A Helm chart to deploy homarr for Kubernetes
|
A Helm chart to deploy homarr for Kubernetes
|
||||||
|
|
||||||
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
|||||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||||
| image.tag | string | `"v1.72.0"` | Overrides the image tag whose default is the chart appVersion |
|
| image.tag | string | `"v1.71.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||||
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||||
| ingress.enabled | bool | `false` | Enable ingress |
|
| ingress.enabled | bool | `false` | Enable ingress |
|
||||||
|
|||||||
@@ -55,74 +55,6 @@ services:
|
|||||||
With docker run, add this parameter: `-v /var/run/docker.sock:/var/run/docker.sock`.
|
With docker run, add this parameter: `-v /var/run/docker.sock:/var/run/docker.sock`.
|
||||||
In Windows, you'll need to adjust the slashes: `-v //var/run/docker.sock:/var/run/docker.sock`
|
In Windows, you'll need to adjust the slashes: `-v //var/run/docker.sock:/var/run/docker.sock`
|
||||||
|
|
||||||
### Using Podman
|
|
||||||
|
|
||||||
On Linux, Homarr can connect to Podman's [Docker-compatible API](https://docs.podman.io/en/latest/markdown/podman-system-service.1.html).
|
|
||||||
The Podman API socket must be running and mounted into the Homarr container.
|
|
||||||
The socket grants full access to Podman, including arbitrary code execution as the user running the API, so only mount it into trusted containers.
|
|
||||||
|
|
||||||
For rootless Podman, enable the socket for the user that runs Homarr:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl --user enable --now podman.socket
|
|
||||||
```
|
|
||||||
|
|
||||||
The rootless socket is located at `$XDG_RUNTIME_DIR/podman/podman.sock` (usually `/run/user/<uid>/podman/podman.sock`).
|
|
||||||
You can confirm the path with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
podman info --format '{{.Host.RemoteSocket.Path}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Mount that socket into the Homarr container and point `DOCKER_SOCKET_PATHS` to its path **inside** the container:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
homarr:
|
|
||||||
container_name: homarr
|
|
||||||
image: ghcr.io/homarr-labs/homarr:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
|
|
||||||
- ./homarr/appdata:/appdata
|
|
||||||
environment:
|
|
||||||
SECRET_ENCRYPTION_KEY: your_64_character_hex_string
|
|
||||||
DOCKER_SOCKET_PATHS: /var/run/podman.sock
|
|
||||||
ports:
|
|
||||||
- "7575:7575"
|
|
||||||
```
|
|
||||||
|
|
||||||
Start Homarr with `podman compose up -d`.
|
|
||||||
Run the compose project as the same user that owns the rootless Podman socket so `$XDG_RUNTIME_DIR` resolves to the correct path.
|
|
||||||
To keep the user socket available after logout and across reboots, enable lingering with `loginctl enable-linger <user>`.
|
|
||||||
|
|
||||||
For rootful Podman, enable the system socket with `sudo systemctl enable --now podman.socket`.
|
|
||||||
Its default path is `/run/podman/podman.sock`; mount that path instead and start Homarr with `sudo podman compose up -d`.
|
|
||||||
|
|
||||||
:::note SELinux
|
|
||||||
On systems with SELinux enabled, Podman's documentation recommends disabling label separation for a container that accesses the API socket.
|
|
||||||
Add the following to the Homarr service:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
security_opt:
|
|
||||||
- label=disable
|
|
||||||
```
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
You can connect Homarr to multiple Docker-compatible sockets by mounting each socket and listing their in-container paths, separated by commas:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
homarr:
|
|
||||||
image: ghcr.io/homarr-labs/homarr:latest
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
|
|
||||||
environment:
|
|
||||||
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
|
|
||||||
```
|
|
||||||
|
|
||||||
### Managing your Docker containers within Homarr
|
### Managing your Docker containers within Homarr
|
||||||
Homarr allows you to interact with Docker containers running on your system.
|
Homarr allows you to interact with Docker containers running on your system.
|
||||||
Each container row has an action menu with the following options:
|
Each container row has an action menu with the following options:
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 21 KiB |
@@ -4,7 +4,6 @@ description: 'Allows you to view and manage your Downloads from both Torrent and
|
|||||||
hide_title: true
|
hide_title: true
|
||||||
---
|
---
|
||||||
|
|
||||||
import { aria2Integration } from '@site/docs/integrations/aria2';
|
|
||||||
import { delugeIntegration } from '@site/docs/integrations/deluge';
|
import { delugeIntegration } from '@site/docs/integrations/deluge';
|
||||||
import { nzbgetIntegration } from '@site/docs/integrations/nzbget';
|
import { nzbgetIntegration } from '@site/docs/integrations/nzbget';
|
||||||
import { qBittorentIntegration } from '@site/docs/integrations/q-bittorent';
|
import { qBittorentIntegration } from '@site/docs/integrations/q-bittorent';
|
||||||
@@ -15,6 +14,9 @@ import { AddingWidget } from '@site/src/components/widgets/adding';
|
|||||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||||
|
import { IconAd } from "@tabler/icons-react";
|
||||||
|
import TabItem from '@theme/TabItem';
|
||||||
|
import Tabs from '@theme/Tabs';
|
||||||
import { downloadsWidget } from '.';
|
import { downloadsWidget } from '.';
|
||||||
|
|
||||||
|
|
||||||
@@ -23,37 +25,16 @@ import { downloadsWidget } from '.';
|
|||||||
categories={['Torrent client', 'Usenet client', 'Downloads']}
|
categories={['Torrent client', 'Usenet client', 'Downloads']}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. It supports 7 download client integrations and provides a rich, responsive table interface.
|
This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information.
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
import downloadsCard from './img/screenshot.png';
|
import downloadsCard from './img/screenshot.png';
|
||||||
import contextMenuImg from './img/context-menu.png';
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<img src={downloadsCard} alt="Downloads widget showing torrent and usenet items" width={128 * 4} />
|
<img src={downloadsCard} alt="downloads" width={128 * 4} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
#### Right-click context menu
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<img src={contextMenuImg} alt="Downloads widget context menu with pause, delete, and copy actions" width={128 * 4} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **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
|
|
||||||
- **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
|
|
||||||
- **Speed overview** — total download and upload speeds displayed in the footer
|
|
||||||
|
|
||||||
### Supported Integrations
|
### Supported Integrations
|
||||||
|
|
||||||
<WidgetIntegrations items={[{
|
<WidgetIntegrations items={[{
|
||||||
@@ -71,23 +52,16 @@ import contextMenuImg from './img/context-menu.png';
|
|||||||
}, {
|
}, {
|
||||||
integration: qBittorentIntegration,
|
integration: qBittorentIntegration,
|
||||||
note: "Torrent client"
|
note: "Torrent client"
|
||||||
}, {
|
|
||||||
integration: aria2Integration,
|
|
||||||
note: "Multi-protocol download client"
|
|
||||||
}, {
|
}, {
|
||||||
integration: slskdIntegration,
|
integration: slskdIntegration,
|
||||||
note: "Soulseek client (read-only)"
|
note: "Soulseek client"
|
||||||
}]} />
|
}]} />
|
||||||
|
|
||||||
### Interactions
|
### Interactions
|
||||||
|
|
||||||
- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
|
- Pause / Resume one download by clicking on the according icon.
|
||||||
- **Click** any row to expand it and see full download details inline
|
- Delete one download by clicking on the trash icon. (Can also delete the files)
|
||||||
- **Hover** a download name for a quick stats tooltip
|
- Pause / Resume all downloads by using the bottom button.
|
||||||
- **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)
|
|
||||||
|
|
||||||
### Adding the widget
|
### Adding the widget
|
||||||
|
|
||||||
|
|||||||
@@ -2,23 +2,26 @@ import { WidgetDefinition } from "@site/src/types";
|
|||||||
import { IconDownload } from "@tabler/icons-react";
|
import { IconDownload } from "@tabler/icons-react";
|
||||||
|
|
||||||
const columnsList = [
|
const columnsList = [
|
||||||
|
"id",
|
||||||
|
"actions",
|
||||||
|
"added",
|
||||||
|
"category",
|
||||||
|
"downSpeed",
|
||||||
|
"index",
|
||||||
|
"integration",
|
||||||
"name",
|
"name",
|
||||||
"progress",
|
"progress",
|
||||||
"size",
|
|
||||||
"downSpeed",
|
|
||||||
"upSpeed",
|
|
||||||
"time",
|
|
||||||
"state",
|
|
||||||
"added",
|
|
||||||
"ratio",
|
"ratio",
|
||||||
"received",
|
"received",
|
||||||
"sent",
|
"sent",
|
||||||
"category",
|
"size",
|
||||||
"integration",
|
"state",
|
||||||
"index",
|
"time",
|
||||||
"type",
|
"type",
|
||||||
|
"upSpeed",
|
||||||
];
|
];
|
||||||
const sortColumns = columnsList.filter((c) => !["state", "category", "integration"].includes(c));
|
const sortingExclusion = ["actions", "id", "state"];
|
||||||
|
const columnsSort = columnsList.filter((column) => !sortingExclusion.some((exclusion) => exclusion === column));
|
||||||
|
|
||||||
export const downloadsWidget: WidgetDefinition = {
|
export const downloadsWidget: WidgetDefinition = {
|
||||||
icon: IconDownload,
|
icon: IconDownload,
|
||||||
@@ -29,20 +32,25 @@ export const downloadsWidget: WidgetDefinition = {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
name: "Columns to show",
|
name: "Columns to show",
|
||||||
description:
|
description: "Select the columns you want to display in the widget.",
|
||||||
"Select the columns you want to display in the widget. Columns automatically hide/show based on widget width.",
|
|
||||||
values: `List of columns: ${columnsList.join(", ")}`,
|
values: `List of columns: ${columnsList.join(", ")}`,
|
||||||
defaultValue: ["name", "progress", "downSpeed", "time", "state"].join(", "),
|
defaultValue: ["integration", "name", "progress", "time", "actions"].join(", "),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Default sort column",
|
name: "Enable items sorting",
|
||||||
description: "The column used for initial sorting when the widget loads.",
|
description: "Enables the sorting in the table.",
|
||||||
defaultValue: "progress",
|
values: { type: "boolean" },
|
||||||
values: `List of columns: ${sortColumns.join(", ")}`,
|
defaultValue: "no",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Column used for sorting by default",
|
||||||
|
description: "By default it will sort by this column.",
|
||||||
|
defaultValue: "type",
|
||||||
|
values: `List of columns: ${columnsSort.join(", ")}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Invert sorting",
|
name: "Invert sorting",
|
||||||
description: "This will invert the default sorting order.",
|
description: "This will invert the sorting order of the table.",
|
||||||
values: { type: "boolean" },
|
values: { type: "boolean" },
|
||||||
defaultValue: "no",
|
defaultValue: "no",
|
||||||
},
|
},
|
||||||
@@ -68,13 +76,13 @@ export const downloadsWidget: WidgetDefinition = {
|
|||||||
name: "Hide completed torrent under this threshold (in kiB/s)",
|
name: "Hide completed torrent under this threshold (in kiB/s)",
|
||||||
description:
|
description:
|
||||||
"This will hide completed torrent entries that have a download speed below the specified threshold.",
|
"This will hide completed torrent entries that have a download speed below the specified threshold.",
|
||||||
values: "Any number above 0, 0 to disable",
|
values: "Any number above 1, 0 to disable",
|
||||||
defaultValue: "0",
|
defaultValue: "0",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Categories/labels to filter",
|
name: "Categories/names to filter",
|
||||||
description: "You can filter the items by categories or labels. Use a comma to separate multiple values.",
|
description: "You can filter the items by categories or names. Use a comma to separate multiple values.",
|
||||||
values: "Comma-separated list of categories or labels",
|
values: "Comma-separated list of categories or names",
|
||||||
defaultValue: "-",
|
defaultValue: "-",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,13 +94,13 @@ export const downloadsWidget: WidgetDefinition = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Use filter to calculate Ratio",
|
name: "Use filter to calculate Ratio",
|
||||||
description: "This will use the category filter when calculating the global torrent ratio.",
|
description: "This will use the filter to calculate the ratio of items shown in the widget.",
|
||||||
values: { type: "boolean" },
|
values: { type: "boolean" },
|
||||||
defaultValue: "yes",
|
defaultValue: "yes",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Limit items per integration",
|
name: "Limit items per integration",
|
||||||
description: "This will limit the number of items shown per integration, not globally.",
|
description: "This will limit the number of items shown per integration, not globally",
|
||||||
values: "Any number above 1",
|
values: "Any number above 1",
|
||||||
defaultValue: "50",
|
defaultValue: "50",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { useItemActions } from "./item-actions";
|
|||||||
import itemContentClasses from "./item-content.module.css";
|
import itemContentClasses from "./item-content.module.css";
|
||||||
import { BoardItemMenu } from "./item-menu";
|
import { BoardItemMenu } from "./item-menu";
|
||||||
import { WidgetContextMenu } from "./widget-context-menu";
|
import { WidgetContextMenu } from "./widget-context-menu";
|
||||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
|
||||||
|
|
||||||
interface BoardItemContentProps {
|
interface BoardItemContentProps {
|
||||||
item: SectionItem;
|
item: SectionItem;
|
||||||
@@ -113,7 +112,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
|||||||
{({ reset }) => (
|
{({ reset }) => (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
removePersistedWidgetQueries(queryClient);
|
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
|
||||||
reset();
|
reset();
|
||||||
}}
|
}}
|
||||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
|
||||||
|
|
||||||
describe("removePersistedWidgetQueries", () => {
|
|
||||||
test("removes stale widget queries and allows fresh data to be fetched", async () => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
|
||||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
|
||||||
|
|
||||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
|
||||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
|
||||||
|
|
||||||
removePersistedWidgetQueries(queryClient);
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
|
||||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
|
||||||
await expect(
|
|
||||||
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
|
|
||||||
).resolves.toBe("fresh");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removes persisted queries outside the widget router", () => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
|
|
||||||
const appQueryKey = [["app", "byId"], { input: {} }];
|
|
||||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
|
||||||
|
|
||||||
queryClient.setQueryData(dockerQueryKey, "stale");
|
|
||||||
queryClient.setQueryData(appQueryKey, "stale");
|
|
||||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
|
||||||
|
|
||||||
removePersistedWidgetQueries(queryClient);
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
|
|
||||||
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
|
|
||||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removes persisted widget queries when the widget kind and query path differ", () => {
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
|
|
||||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
|
||||||
|
|
||||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
|
||||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
|
||||||
|
|
||||||
removePersistedWidgetQueries(queryClient);
|
|
||||||
|
|
||||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
|
||||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
|
|
||||||
|
|
||||||
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
|
|
||||||
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
|
||||||
};
|
|
||||||
@@ -19,15 +19,8 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
|
|||||||
options?: Options<TIntegration, TResult>,
|
options?: Options<TIntegration, TResult>,
|
||||||
): Promise<TResult[]> {
|
): Promise<TResult[]> {
|
||||||
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
|
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
|
||||||
const results: TResult[] = [];
|
return settled.flatMap((result, index) => {
|
||||||
const errors: unknown[] = [];
|
if (result.status === "fulfilled") return [result.value];
|
||||||
|
|
||||||
settled.forEach((result, index) => {
|
|
||||||
if (result.status === "fulfilled") {
|
|
||||||
results.push(result.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const integration = integrations[index];
|
const integration = integrations[index];
|
||||||
logger.warn(
|
logger.warn(
|
||||||
new ErrorWithMetadata(
|
new ErrorWithMetadata(
|
||||||
@@ -36,18 +29,7 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
|
|||||||
{ cause: result.reason },
|
{ cause: result.reason },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (options?.fallback && integration) return [options.fallback(integration, result.reason)];
|
||||||
if (options?.fallback && integration) {
|
return [];
|
||||||
results.push(options.fallback(integration, result.reason));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
errors.push(result.reason);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (results.length === 0 && errors.length > 0) {
|
|
||||||
throw errors[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
import { settleIntegrationQueries } from "../settle-integrations";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
logger: { warn: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
|
||||||
createLogger: () => mocks.logger,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@homarr/core/infrastructure/logs/error", () => ({
|
|
||||||
ErrorWithMetadata: Error,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const integrations = [
|
|
||||||
{ id: "first", name: "First", kind: "plex" },
|
|
||||||
{ id: "second", name: "Second", kind: "jellyfin" },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("settleIntegrationQueries", () => {
|
|
||||||
test("returns an empty result when no integrations are configured", async () => {
|
|
||||||
await expect(settleIntegrationQueries([], vi.fn())).resolves.toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps successful integrations when another integration fails", async () => {
|
|
||||||
const error = new Error("Plex unavailable");
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
settleIntegrationQueries(integrations, (integration) =>
|
|
||||||
integration.id === "first" ? Promise.reject(error) : Promise.resolve(integration.name),
|
|
||||||
),
|
|
||||||
).resolves.toEqual(["Second"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects when every integration fails", async () => {
|
|
||||||
const error = new Error("Integration unavailable");
|
|
||||||
|
|
||||||
await expect(settleIntegrationQueries(integrations, () => Promise.reject(error))).rejects.toBe(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses configured fallbacks when every integration fails", async () => {
|
|
||||||
await expect(
|
|
||||||
settleIntegrationQueries(integrations, () => Promise.reject(new Error("Integration unavailable")), {
|
|
||||||
fallback: (integration) => integration.name,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(["First", "Second"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
|
|||||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||||
const type = "torrent";
|
const type = "torrent";
|
||||||
const client = await this.getClientAsync();
|
const client = await this.getClientAsync();
|
||||||
const torrents = await client.listTorrents({ limit: input.limit });
|
const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false });
|
||||||
const rates = torrents.reduce(
|
const rates = torrents.reduce(
|
||||||
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
|
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
|
||||||
{ down: 0, up: 0 },
|
{ down: 0, up: 0 },
|
||||||
|
|||||||
@@ -77,16 +77,15 @@ const optionMapping: OptionMapping = {
|
|||||||
categoryFilter: (oldOptions) => ("labelFilter" in oldOptions ? oldOptions.labelFilter : undefined),
|
categoryFilter: (oldOptions) => ("labelFilter" in oldOptions ? oldOptions.labelFilter : undefined),
|
||||||
filterIsWhitelist: (oldOptions) =>
|
filterIsWhitelist: (oldOptions) =>
|
||||||
"labelFilterIsWhitelist" in oldOptions ? oldOptions.labelFilterIsWhitelist : undefined,
|
"labelFilterIsWhitelist" in oldOptions ? oldOptions.labelFilterIsWhitelist : undefined,
|
||||||
|
enableRowSorting: (oldOptions) => ("rowSorting" in oldOptions ? oldOptions.rowSorting : undefined),
|
||||||
showCompletedTorrent: (oldOptions) =>
|
showCompletedTorrent: (oldOptions) =>
|
||||||
"displayCompletedTorrents" in oldOptions ? oldOptions.displayCompletedTorrents : undefined,
|
"displayCompletedTorrents" in oldOptions ? oldOptions.displayCompletedTorrents : undefined,
|
||||||
columns: () => ["integration", "name", "progress", "time", "state"],
|
columns: () => ["integration", "name", "progress", "time", "actions"],
|
||||||
defaultSort: () => "progress",
|
defaultSort: () => "type",
|
||||||
descendingDefaultSort: () => false,
|
descendingDefaultSort: () => false,
|
||||||
showCompletedUsenet: () => true,
|
showCompletedUsenet: () => true,
|
||||||
showCompletedHttp: () => true,
|
showCompletedHttp: () => true,
|
||||||
limitPerIntegration: () => undefined,
|
limitPerIntegration: () => undefined,
|
||||||
columnOrder: () => undefined,
|
|
||||||
columnWidths: () => undefined,
|
|
||||||
},
|
},
|
||||||
weather: {
|
weather: {
|
||||||
forecastDayCount: (oldOptions) => oldOptions.forecastDays,
|
forecastDayCount: (oldOptions) => oldOptions.forecastDays,
|
||||||
|
|||||||
@@ -13,6 +13,4 @@ export const mediaReleaseRequestHandler = createIntegrationRequestHandler<
|
|||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
return await integrationInstance.getMediaReleasesAsync();
|
return await integrationInstance.getMediaReleasesAsync();
|
||||||
},
|
},
|
||||||
cacheTtlMs: 5 * 60 * 1000,
|
|
||||||
fallbackToStaleOnError: true,
|
|
||||||
});
|
});
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { MediaRelease } from "@homarr/integrations/types";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
import { mediaReleaseRequestHandler } from "../media-release";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
createIntegrationAsync: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@homarr/integrations", () => ({
|
|
||||||
createIntegrationAsync: mocks.createIntegrationAsync,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const integration = {
|
|
||||||
id: "plex-integration",
|
|
||||||
kind: "plex",
|
|
||||||
} as Parameters<typeof mediaReleaseRequestHandler.handler>[0];
|
|
||||||
|
|
||||||
describe("mediaReleaseRequestHandler", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(new Date("2026-07-28T00:00:00Z"));
|
|
||||||
mediaReleaseRequestHandler.invalidateCache();
|
|
||||||
mocks.createIntegrationAsync.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps the last successful releases when a refresh fails", async () => {
|
|
||||||
const releases = [{ id: "release-1" }] as MediaRelease[];
|
|
||||||
const getMediaReleasesAsync = vi.fn().mockResolvedValueOnce(releases).mockRejectedValueOnce(new Error("Plex down"));
|
|
||||||
mocks.createIntegrationAsync.mockResolvedValue({ getMediaReleasesAsync });
|
|
||||||
|
|
||||||
const handler = mediaReleaseRequestHandler.handler(integration, {});
|
|
||||||
const initial = await handler.getDataAsync();
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
|
|
||||||
const fallback = await handler.getDataAsync();
|
|
||||||
|
|
||||||
expect(fallback).toEqual(initial);
|
|
||||||
expect(getMediaReleasesAsync).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3079,20 +3079,11 @@
|
|||||||
"limitPerIntegration": {
|
"limitPerIntegration": {
|
||||||
"label": "Limit items per integration",
|
"label": "Limit items per integration",
|
||||||
"description": "This will limit the number of items shown per integration, not globally"
|
"description": "This will limit the number of items shown per integration, not globally"
|
||||||
},
|
|
||||||
"columnOrder": {
|
|
||||||
"label": "Column order"
|
|
||||||
},
|
|
||||||
"columnWidths": {
|
|
||||||
"label": "Column widths"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"noColumns": "Select Columns in Items",
|
"noColumns": "Select Columns in Items",
|
||||||
"noCommunications": "Can't load data from integration",
|
"noCommunications": "Can't load data from integration"
|
||||||
"noItems": "No downloads found",
|
|
||||||
"actionFailed": "Action failed",
|
|
||||||
"actionFailedMessage": "The action could not be completed. Please try again."
|
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"actions": {
|
"actions": {
|
||||||
@@ -3115,8 +3106,7 @@
|
|||||||
"detailsTitle": "Current index within client"
|
"detailsTitle": "Current index within client"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"columnTitle": "Id",
|
"columnTitle": "Id"
|
||||||
"detailsTitle": "Item ID"
|
|
||||||
},
|
},
|
||||||
"integration": {
|
"integration": {
|
||||||
"columnTitle": "Integration"
|
"columnTitle": "Integration"
|
||||||
@@ -3150,8 +3140,7 @@
|
|||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"columnTitle": "Finish time",
|
"columnTitle": "Finish time",
|
||||||
"detailsTitle": "Time since/to completion",
|
"detailsTitle": "Time since/to completion"
|
||||||
"eta": "ETA"
|
|
||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"columnTitle": "Type",
|
"columnTitle": "Type",
|
||||||
@@ -3195,23 +3184,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalRatio": "Global Ratio",
|
"globalRatio": "Global Ratio"
|
||||||
"contextMenu": {
|
|
||||||
"copyName": "Copy name",
|
|
||||||
"copyId": "Copy ID",
|
|
||||||
"clearFilters": "Clear filters"
|
|
||||||
},
|
|
||||||
"footer": {
|
|
||||||
"filter": "Filter downloads"
|
|
||||||
},
|
|
||||||
"stats": {
|
|
||||||
"show": "Show global stats",
|
|
||||||
"hide": "Hide global stats",
|
|
||||||
"overallProgress": "Overall queue progress",
|
|
||||||
"totalSize": "Total queue size",
|
|
||||||
"clients": "{count, plural, one {# client} other {# clients}}"
|
|
||||||
},
|
|
||||||
"disconnected": "(disconnected)"
|
|
||||||
},
|
},
|
||||||
"mediaReleases": {
|
"mediaReleases": {
|
||||||
"name": "Media releases",
|
"name": "Media releases",
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -8,37 +8,28 @@ import { createWidgetDefinition } from "../definition";
|
|||||||
import { optionsBuilder } from "../options";
|
import { optionsBuilder } from "../options";
|
||||||
|
|
||||||
const columnsList = [
|
const columnsList = [
|
||||||
"name",
|
"id",
|
||||||
"progress",
|
"actions",
|
||||||
"size",
|
|
||||||
"downSpeed",
|
|
||||||
"upSpeed",
|
|
||||||
"time",
|
|
||||||
"state",
|
|
||||||
"added",
|
"added",
|
||||||
"ratio",
|
|
||||||
"received",
|
|
||||||
"sent",
|
|
||||||
"category",
|
"category",
|
||||||
"integration",
|
"downSpeed",
|
||||||
"index",
|
"index",
|
||||||
"type",
|
"integration",
|
||||||
] as const satisfies (keyof ExtendedDownloadClientItem)[];
|
|
||||||
|
|
||||||
const sortColumns = [
|
|
||||||
"name",
|
"name",
|
||||||
"progress",
|
"progress",
|
||||||
"size",
|
|
||||||
"downSpeed",
|
|
||||||
"upSpeed",
|
|
||||||
"time",
|
|
||||||
"added",
|
|
||||||
"ratio",
|
"ratio",
|
||||||
"received",
|
"received",
|
||||||
"sent",
|
"sent",
|
||||||
"index",
|
"size",
|
||||||
|
"state",
|
||||||
|
"time",
|
||||||
"type",
|
"type",
|
||||||
] as const satisfies readonly (typeof columnsList)[number][];
|
"upSpeed",
|
||||||
|
] as const satisfies (keyof ExtendedDownloadClientItem)[];
|
||||||
|
const sortingExclusion = ["actions", "id", "state"] as const satisfies readonly (typeof columnsList)[number][];
|
||||||
|
const columnsSort = columnsList.filter((column) =>
|
||||||
|
sortingExclusion.every((exclusion) => exclusion !== column),
|
||||||
|
) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>;
|
||||||
|
|
||||||
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
|
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
|
||||||
icon: IconDownload,
|
icon: IconDownload,
|
||||||
@@ -47,16 +38,19 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
|
|||||||
return optionsBuilder.from(
|
return optionsBuilder.from(
|
||||||
(factory) => ({
|
(factory) => ({
|
||||||
columns: factory.multiSelect({
|
columns: factory.multiSelect({
|
||||||
defaultValue: ["name", "progress", "downSpeed", "time", "state"],
|
defaultValue: ["integration", "name", "progress", "time", "actions"],
|
||||||
options: columnsList.map((value) => ({
|
options: columnsList.map((value) => ({
|
||||||
value,
|
value,
|
||||||
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
|
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
|
||||||
})),
|
})),
|
||||||
searchable: true,
|
searchable: true,
|
||||||
}),
|
}),
|
||||||
|
enableRowSorting: factory.switch({
|
||||||
|
defaultValue: false,
|
||||||
|
}),
|
||||||
defaultSort: factory.select({
|
defaultSort: factory.select({
|
||||||
defaultValue: "progress",
|
defaultValue: "type",
|
||||||
options: sortColumns.map((value) => ({
|
options: columnsSort.map((value) => ({
|
||||||
value,
|
value,
|
||||||
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
|
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
|
||||||
})),
|
})),
|
||||||
@@ -74,6 +68,7 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
|
|||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
}),
|
}),
|
||||||
activeTorrentThreshold: factory.number({
|
activeTorrentThreshold: factory.number({
|
||||||
|
//in KiB/s
|
||||||
validate: z.number().min(0),
|
validate: z.number().min(0),
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
step: 1,
|
step: 1,
|
||||||
@@ -93,12 +88,14 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
|
|||||||
validate: z.number().min(1),
|
validate: z.number().min(1),
|
||||||
withDescription: true,
|
withDescription: true,
|
||||||
}),
|
}),
|
||||||
columnOrder: factory.text({ defaultValue: "" }),
|
|
||||||
columnWidths: factory.text({ defaultValue: "" }),
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
columnOrder: { shouldHide: () => true },
|
defaultSort: {
|
||||||
columnWidths: { shouldHide: () => true },
|
shouldHide: (options) => !options.enableRowSorting,
|
||||||
|
},
|
||||||
|
descendingDefaultSort: {
|
||||||
|
shouldHide: (options) => !options.enableRowSorting,
|
||||||
|
},
|
||||||
showCompletedUsenet: {
|
showCompletedUsenet: {
|
||||||
shouldHide: (_, integrationKinds) =>
|
shouldHide: (_, integrationKinds) =>
|
||||||
!getIntegrationKindsByCategory("usenet").some((kinds) => integrationKinds.includes(kinds)),
|
!getIntegrationKindsByCategory("usenet").some((kinds) => integrationKinds.includes(kinds)),
|
||||||
|
|||||||
@@ -1,52 +1,12 @@
|
|||||||
/* Make the DataTable transparent so the board card's opacity-controlled
|
/* Make the MantineReactTable transparent so the board card's opacity-controlled
|
||||||
background shows through. The mantine-datatable defaults to
|
background shows through. MRT defaults to var(--mantine-color-body) which is opaque. */
|
||||||
var(--mantine-color-body) which is opaque. */
|
.downloads-widget-table,
|
||||||
.downloads-table,
|
.downloads-widget-table .mantine-Table-table,
|
||||||
.downloads-table .mantine-datatable-table,
|
.downloads-widget-table .mantine-Table-thead,
|
||||||
.downloads-table .mantine-datatable-table thead,
|
.downloads-widget-table .mantine-Table-tbody,
|
||||||
.downloads-table .mantine-datatable-table tbody,
|
.downloads-widget-table .mantine-Table-tfoot,
|
||||||
.downloads-table .mantine-datatable-table tfoot,
|
.downloads-widget-table .mantine-Table-tr,
|
||||||
.downloads-table th,
|
.downloads-widget-table th,
|
||||||
.downloads-table td {
|
.downloads-widget-table td {
|
||||||
background-color: transparent !important;
|
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