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:
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>
|
||||
|
||||
Reference in new issue
Block a user