From bfa2295a7cc7db9758685731ca2590ab8f39ee32 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 14 May 2026 16:54:02 +0200 Subject: [PATCH] refactor(ui): extract shared integration grid logic and deduplicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both IntegrationMultiSelectGrid and IntegrationSelectGrid had duplicated logic for building widget-by-integration maps, category labels, sorting, and filtering. Extract shared code into integration-grid-shared.ts using objectEntries and IntegrationKind typing. Use i18n keys for category badges and widget counts instead of hardcoded strings. Remove useEffect for onSelectionChange in multi-select grid — call it directly in the toggle callback. --- .../integration/integration-grid-shared.ts | 84 +++++++++++++ .../integration-multi-select-grid.tsx | 114 ++++-------------- .../integration/integration-select-grid.tsx | 94 ++------------- 3 files changed, 119 insertions(+), 173 deletions(-) create mode 100644 apps/nextjs/src/components/integration/integration-grid-shared.ts diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts new file mode 100644 index 000000000..fcd08dc38 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -0,0 +1,84 @@ +import { objectEntries } from "@homarr/common"; +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import { featuredIntegrations, getIntegrationName, integrationDefs, integrationKinds } from "@homarr/definitions"; +import { widgetImports } from "@homarr/widgets"; + +export const CARD_HEIGHT = 180; + +const buildWidgetsByIntegration = () => { + const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record< + IntegrationKind, + WidgetKind[] + >; + + for (const [widgetKind, widget] of objectEntries(widgetImports)) { + const supported = + "supportedIntegrations" in widget.definition + ? (widget.definition.supportedIntegrations as IntegrationKind[]) + : []; + for (const kind of supported) { + if (kind in map) { + map[kind].push(widgetKind); + } + } + } + return map; +}; + +export const widgetsByIntegration = buildWidgetsByIntegration(); + +export const categoryTranslationKeys: Record = { + dnsHole: "integration.category.dnsHole", + mediaService: "integration.category.mediaService", + calendar: "integration.category.calendar", + mediaSearch: "integration.category.mediaSearch", + mediaRelease: "integration.category.mediaRelease", + mediaRequest: "integration.category.mediaRequest", + downloadClient: "integration.category.downloadClient", + usenet: "integration.category.usenet", + torrent: "integration.category.torrent", + miscellaneous: "integration.category.miscellaneous", + smartHomeServer: "integration.category.smartHomeServer", + indexerManager: "integration.category.indexerManager", + healthMonitoring: "integration.category.healthMonitoring", + search: "integration.category.search", + mediaTranscoding: "integration.category.mediaTranscoding", + networkController: "integration.category.networkController", + releasesProvider: "integration.category.releasesProvider", + notifications: "integration.category.notifications", + firewall: "integration.category.firewall", + timetable: "integration.category.timetable", + photoService: "integration.category.photoService", + notes: "integration.category.notes", + mediaMonitoring: "integration.category.mediaMonitoring", + speedtest: "integration.category.speedtest", + analytics: "integration.category.analytics", +}; + +export interface IntegrationGridItem { + kind: IntegrationKind; + name: string; + categories: string[]; + widgets: WidgetKind[]; +} + +export const buildSortedIntegrations = (enableMockIntegration: boolean): IntegrationGridItem[] => + integrationKinds + .filter((kind) => enableMockIntegration || kind !== "mock") + .map((kind) => ({ + kind, + name: getIntegrationName(kind), + categories: [...new Set(integrationDefs[kind].category.flat())] as string[], + widgets: widgetsByIntegration[kind], + })) + .sort((left, right) => { + const leftIdx = featuredIntegrations.indexOf(left.kind); + const rightIdx = featuredIntegrations.indexOf(right.kind); + if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; + if (leftIdx !== -1) return -1; + if (rightIdx !== -1) return 1; + return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); + }); + +export const filterIntegrations = (items: IntegrationGridItem[], search: string) => + items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())); diff --git a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx index 3f0972f6a..74762ae8b 100644 --- a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx +++ b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx @@ -1,72 +1,25 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Badge, Card, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core"; import { IconCheck, IconPuzzle, IconSearch } from "@tabler/icons-react"; -import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import { featuredIntegrations, getIntegrationName, integrationDefs, integrationKinds } from "@homarr/definitions"; +import type { IntegrationKind } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; import { IntegrationAvatar } from "@homarr/ui"; -import { widgetImports } from "@homarr/widgets"; + +import { + buildSortedIntegrations, + CARD_HEIGHT, + categoryTranslationKeys, + filterIntegrations, +} from "./integration-grid-shared"; interface IntegrationMultiSelectGridProps { onSelectionChange: (kinds: IntegrationKind[]) => void; enableMockIntegration?: boolean; } -const getWidgetsByIntegration = () => { - const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record< - IntegrationKind, - WidgetKind[] - >; - - for (const [widgetKind, widget] of Object.entries(widgetImports) as [ - WidgetKind, - (typeof widgetImports)[WidgetKind], - ][]) { - const supported = - "supportedIntegrations" in widget.definition ? (widget.definition.supportedIntegrations as string[]) : []; - for (const kind of supported) { - if (kind in map) { - map[kind as IntegrationKind].push(widgetKind); - } - } - } - return map; -}; - -const widgetsByIntegration = getWidgetsByIntegration(); - -const categoryLabels: Record = { - dnsHole: "DNS", - mediaService: "Media", - calendar: "Calendar", - mediaSearch: "Search", - mediaRelease: "Releases", - mediaRequest: "Requests", - downloadClient: "Downloads", - usenet: "Usenet", - torrent: "Torrent", - miscellaneous: "Misc", - smartHomeServer: "Smart Home", - indexerManager: "Indexer", - healthMonitoring: "Health", - search: "Search", - mediaTranscoding: "Transcoding", - networkController: "Network", - releasesProvider: "Releases", - notifications: "Notifications", - firewall: "Firewall", - timetable: "Timetable", - photoService: "Photos", - notes: "Notes", - mediaMonitoring: "Monitoring", - speedtest: "Speed Test", -}; - -const CARD_HEIGHT = 180; - export const IntegrationMultiSelectGrid = ({ onSelectionChange, enableMockIntegration = false, @@ -75,46 +28,23 @@ export const IntegrationMultiSelectGrid = ({ const [selectedKinds, setSelectedKinds] = useState>(new Set()); const t = useI18n(); - useEffect(() => { - onSelectionChange(Array.from(selectedKinds)); - }, [selectedKinds, onSelectionChange]); - - const toggleKind = useCallback((kind: IntegrationKind) => { - setSelectedKinds((prev) => { - const next = new Set(prev); + const toggleKind = useCallback( + (kind: IntegrationKind) => { + const next = new Set(selectedKinds); if (next.has(kind)) { next.delete(kind); } else { next.add(kind); } - return next; - }); - }, []); - - const integrations = useMemo(() => { - return integrationKinds - .filter((kind) => enableMockIntegration || kind !== "mock") - .map((kind) => ({ - kind, - name: getIntegrationName(kind), - categories: [...new Set(integrationDefs[kind].category.flat())] as string[], - widgets: widgetsByIntegration[kind], - })) - .sort((left, right) => { - const leftIdx = featuredIntegrations.indexOf(left.kind); - const rightIdx = featuredIntegrations.indexOf(right.kind); - if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; - if (leftIdx !== -1) return -1; - if (rightIdx !== -1) return 1; - return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); - }); - }, [enableMockIntegration]); - - const filtered = useMemo( - () => integrations.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())), - [integrations, search], + setSelectedKinds(next); + onSelectionChange(Array.from(next)); + }, + [selectedKinds, onSelectionChange], ); + const integrations = useMemo(() => buildSortedIntegrations(enableMockIntegration), [enableMockIntegration]); + const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]); + return ( {integration.categories.slice(0, 2).map((cat) => ( - {categoryLabels[cat] ?? cat} + {categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat} ))} @@ -176,12 +106,12 @@ export const IntegrationMultiSelectGrid = ({ } > }> - {integration.widgets.length} {integration.widgets.length === 1 ? "widget" : "widgets"} + {t("integration.grid.widgetCount", { count: String(integration.widgets.length) })} ) : ( - No widgets + {t("integration.grid.noWidgets")} )} diff --git a/apps/nextjs/src/components/integration/integration-select-grid.tsx b/apps/nextjs/src/components/integration/integration-select-grid.tsx index 8637137de..2a985d7c3 100644 --- a/apps/nextjs/src/components/integration/integration-select-grid.tsx +++ b/apps/nextjs/src/components/integration/integration-select-grid.tsx @@ -4,96 +4,28 @@ import { useMemo, useState } from "react"; import { Badge, Card, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core"; import { IconPuzzle, IconSearch } from "@tabler/icons-react"; -import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import { featuredIntegrations, getIntegrationName, integrationDefs, integrationKinds } from "@homarr/definitions"; +import type { IntegrationKind } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; import { IntegrationAvatar } from "@homarr/ui"; -import { widgetImports } from "@homarr/widgets"; + +import { + buildSortedIntegrations, + CARD_HEIGHT, + categoryTranslationKeys, + filterIntegrations, +} from "./integration-grid-shared"; interface IntegrationSelectGridProps { onSelect: (kind: IntegrationKind) => void; enableMockIntegration?: boolean; } -const getWidgetsByIntegration = () => { - const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record< - IntegrationKind, - WidgetKind[] - >; - - for (const [widgetKind, widget] of Object.entries(widgetImports) as [ - WidgetKind, - (typeof widgetImports)[WidgetKind], - ][]) { - const supported = - "supportedIntegrations" in widget.definition ? (widget.definition.supportedIntegrations as string[]) : []; - for (const kind of supported) { - if (kind in map) { - map[kind as IntegrationKind].push(widgetKind); - } - } - } - return map; -}; - -const widgetsByIntegration = getWidgetsByIntegration(); - -const categoryLabels: Record = { - dnsHole: "DNS", - mediaService: "Media", - calendar: "Calendar", - mediaSearch: "Search", - mediaRelease: "Releases", - mediaRequest: "Requests", - downloadClient: "Downloads", - usenet: "Usenet", - torrent: "Torrent", - miscellaneous: "Misc", - smartHomeServer: "Smart Home", - indexerManager: "Indexer", - healthMonitoring: "Health", - search: "Search", - mediaTranscoding: "Transcoding", - networkController: "Network", - releasesProvider: "Releases", - notifications: "Notifications", - firewall: "Firewall", - timetable: "Timetable", - photoService: "Photos", - notes: "Notes", - mediaMonitoring: "Monitoring", - speedtest: "Speed Test", -}; - -const CARD_HEIGHT = 180; - export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false }: IntegrationSelectGridProps) => { const [search, setSearch] = useState(""); const t = useI18n(); - const integrations = useMemo(() => { - return integrationKinds - .filter((kind) => enableMockIntegration || kind !== "mock") - .map((kind) => ({ - kind, - name: getIntegrationName(kind), - categories: [...new Set(integrationDefs[kind].category.flat())] as string[], - widgets: widgetsByIntegration[kind], - })) - .sort((left, right) => { - const leftIdx = featuredIntegrations.indexOf(left.kind); - const rightIdx = featuredIntegrations.indexOf(right.kind); - if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; - if (leftIdx !== -1) return -1; - if (rightIdx !== -1) return 1; - return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); - }); - }, [enableMockIntegration]); - - const filtered = useMemo( - () => integrations.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())), - [integrations, search], - ); + const integrations = useMemo(() => buildSortedIntegrations(enableMockIntegration), [enableMockIntegration]); + const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]); return ( @@ -128,7 +60,7 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false {integration.categories.slice(0, 2).map((cat) => ( - {categoryLabels[cat] ?? cat} + {categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat} ))} @@ -147,12 +79,12 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false } > }> - {integration.widgets.length} {integration.widgets.length === 1 ? "widget" : "widgets"} + {t("integration.grid.widgetCount", { count: String(integration.widgets.length) })} ) : ( - No widgets + {t("integration.grid.noWidgets")} )}