refactor(ui): extract shared integration grid logic and deduplicate

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.
This commit is contained in:
Thomas Camlong committed 2026-05-14 16:54:02 +02:00
1 parent 9c0e73922d
commit bfa2295a7c
3 files changed
+119 -173

No files matched your search

@@ -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<string, string> = {
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()));
@@ -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<string, string> = {
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<Set<IntegrationKind>>(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 (
<Stack>
<Input
@@ -157,7 +87,7 @@ export const IntegrationMultiSelectGrid = ({
<Group gap={4} justify="center" wrap="wrap">
{integration.categories.slice(0, 2).map((cat) => (
<Badge key={cat} variant="light" size="xs">
{categoryLabels[cat] ?? cat}
{categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat}
</Badge>
))}
</Group>
@@ -176,12 +106,12 @@ export const IntegrationMultiSelectGrid = ({
}
>
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
{integration.widgets.length} {integration.widgets.length === 1 ? "widget" : "widgets"}
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
</Badge>
</Tooltip>
) : (
<Badge variant="light" color="gray" size="sm">
No widgets
{t("integration.grid.noWidgets")}
</Badge>
)}
</Stack>
@@ -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<string, string> = {
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 (
<Stack>
@@ -128,7 +60,7 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false
<Group gap={4} justify="center" wrap="wrap">
{integration.categories.slice(0, 2).map((cat) => (
<Badge key={cat} variant="light" size="xs">
{categoryLabels[cat] ?? cat}
{categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat}
</Badge>
))}
</Group>
@@ -147,12 +79,12 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false
}
>
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
{integration.widgets.length} {integration.widgets.length === 1 ? "widget" : "widgets"}
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
</Badge>
</Tooltip>
) : (
<Badge variant="light" color="gray" size="sm">
No widgets
{t("integration.grid.noWidgets")}
</Badge>
)}
</Stack>