diff --git a/apps/docs/docs/widgets/downloads/index.mdx b/apps/docs/docs/widgets/downloads/index.mdx index 621d57265..000fd066b 100644 --- a/apps/docs/docs/widgets/downloads/index.mdx +++ b/apps/docs/docs/widgets/downloads/index.mdx @@ -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** — toggle, reorder, resize, and pin columns via the header menu; preferences persist in local storage +- **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 @@ -84,7 +84,6 @@ import contextMenuImg from './img/context-menu.png'; - **Right-click** any download to open a context menu with pause/resume, delete, and copy actions - **Click** any row to expand it and see full download details inline - **Hover** a download name for a quick stats tooltip -- **Column header menu** — show/hide, reorder, resize, or pin columns (reset via the columns button in the footer) - **Stats toggle** — show or hide the global stats bar from the footer - **Pause / Resume** all downloads using the footer controls - **Filter** by integration or status using the filter button in the footer diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 14276ddd3..f2559adcf 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3073,7 +3073,10 @@ }, "errors": { "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": { "actions": { @@ -3189,14 +3192,6 @@ "overallProgress": "Overall queue progress", "totalSize": "Total queue size", "clients": "{count, plural, one {# client} other {# clients}}" - }, - "columns": { - "settings": "Column settings", - "resetVisibility": "Reset visibility", - "resetOrder": "Reset order", - "resetWidth": "Reset widths", - "resetPinning": "Reset pinning", - "scrolled": "Scrolled {y}px from top" } }, "mediaReleases": { diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index 670cc6dde..c53ab7258 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -48,6 +48,7 @@ import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common"; import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions"; import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations"; +import { showErrorNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; @@ -117,11 +118,13 @@ export default function DownloadClientsWidget({ options, width, }: WidgetComponentProps<"downloads">) { - const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) => - integrationIds.includes(id) ? [id] : [], + const allInteractAccess = useIntegrationsWithInteractAccess(); + const integrationsWithInteractions = useMemo( + () => allInteractAccess.flatMap(({ id }) => (integrationIds.includes(id) ? [id] : [])), + [allInteractAccess, integrationIds], ); - const { data: currentItems = [], isFetching } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({ + const { data: currentItems = [], isFetching, isError } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({ integrationIds, limitPerIntegration: options.limitPerIntegration, }); @@ -134,12 +137,15 @@ export default function DownloadClientsWidget({ const [showStats, { toggle: toggleStats }] = useDisclosure(false); const utils = clientApi.useUtils(); - const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() }; - const { mutate: mutateResumeItem } = clientApi.widget.downloads.resumeItem.useMutation(invalidateDownloads); - const { mutate: mutatePauseItem } = clientApi.widget.downloads.pauseItem.useMutation(invalidateDownloads); - const { mutate: mutateDeleteItem } = clientApi.widget.downloads.deleteItem.useMutation(invalidateDownloads); - const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation(invalidateDownloads); - const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(invalidateDownloads); + const mutationOptions = { + onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate(), + onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }), + }; + const { mutate: mutateResumeItem } = clientApi.widget.downloads.resumeItem.useMutation(mutationOptions); + const { mutate: mutatePauseItem } = clientApi.widget.downloads.pauseItem.useMutation(mutationOptions); + const { mutate: mutateDeleteItem } = clientApi.widget.downloads.deleteItem.useMutation(mutationOptions); + const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation(mutationOptions); + const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(mutationOptions); const [sortStatus, setSortStatus] = useState>({ columnAccessor: options.defaultSort, @@ -176,7 +182,7 @@ export default function DownloadClientsWidget({ ...item, category: item.category !== undefined && item.category.length > 0 ? item.category : undefined, received, - ratio: item.sent !== undefined ? item.sent / item.size : undefined, + ratio: item.sent !== undefined && item.size > 0 ? item.sent / item.size : undefined, actions: integrationsWithInteractions.includes(pair.integration.id) ? { resume: () => mutateResumeItem({ integrationIds: ids, item }), @@ -235,7 +241,7 @@ export default function DownloadClientsWidget({ return { integration, interact, - status: { totalUp, totalDown, ratio: totalUp === undefined ? undefined : totalUp / totalDown, ...d.status }, + status: { totalUp, totalDown, ratio: totalUp === undefined || totalDown === 0 ? undefined : totalUp / totalDown, ...d.status }, }; }); }, [currentItems, integrationIds, integrationsWithInteractions, options.applyFilterToRatio, options.categoryFilter, options.filterIsWhitelist]); @@ -244,7 +250,7 @@ export default function DownloadClientsWidget({ const hasMultipleClients = clients.length > 1; const hasMultipleTypes = new Set(data.map(({ type }) => type)).size > 1; - const size = getSizeConfig(width); + const size = useMemo(() => getSizeConfig(width), [width]); const optionsColumnSet = useMemo(() => new Set(options.columns), [options.columns]); const shouldIncludeColumn = useCallback( @@ -489,6 +495,14 @@ export default function DownloadClientsWidget({ ); } + if (isError && currentItems.length === 0) { + return ( +
+ {t("errors.noCommunications")} +
+ ); + } + return ( {showStats && ( @@ -520,8 +534,8 @@ export default function DownloadClientsWidget({ textSelectionDisabled sortStatus={sortStatus} onSortStatusChange={setSortStatus} - noRecordsText={t("errors.noColumns")} - idAccessor="id" + noRecordsText={t("errors.noItems")} + idAccessor={(record) => `${record.integration.id}:${record.id}`} height="100%" className="downloads-table" defaultColumnProps={{