refactor(board): modernize item and application select modals with SelectableCard (#6642)
Modernize board item, application, and integration selection flows with shared Mantine components, batch board actions, improved accessibility, and hardened persistence.
This commit is contained in:
1 parent
bd4f6a92c6
commit
f3014fe271
60 files changed
+3791
-1839
No files matched your search
@@ -0,0 +1,279 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { IntegrationSelectGrid } from "./integration-select-grid";
|
||||
|
||||
vi.mock("@homarr/definitions", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@homarr/definitions")>();
|
||||
return {
|
||||
...actual,
|
||||
getWidgetKindsForIntegration: (kind: IntegrationKind) => {
|
||||
if (kind === "wud") return [];
|
||||
return actual.getWidgetKindsForIntegration(kind);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string, values?: { count?: number }) => {
|
||||
const translations: Record<string, string> = {
|
||||
"integration.page.list.search": "Search integrations",
|
||||
"common.noResults": "No results found",
|
||||
"integration.grid.noWidgets": "No widgets tied to this service",
|
||||
"integration.grid.tiedWidgets": "Tied Widgets",
|
||||
"integration.grid.connected": `${values?.count ?? 0} connected`,
|
||||
"integration.grid.more": `+${values?.count ?? 0} more`,
|
||||
"widget.mediaServer.name": "Media Server",
|
||||
"widget.calendar.name": "Calendar",
|
||||
"widget.downloads.name": "Downloads",
|
||||
"integration.category.mediaService": "Media Service",
|
||||
"integration.category.downloadClient": "Download Client",
|
||||
"integration.category.calendar": "Calendar",
|
||||
"integration.category.smartHomeServer": "Smart Home",
|
||||
"integration.category.usenet": "Usenet",
|
||||
"integration.category.torrent": "Torrent",
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
describe("IntegrationSelectGrid", () => {
|
||||
it("renders a 4-column responsive grid of selectable integration cards", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr", "radarr", "sabNzbd"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const cards = host.querySelectorAll("button");
|
||||
expect(cards.length).toBe(4);
|
||||
expect(host.textContent).toContain("Jellyfin");
|
||||
expect(host.textContent).toContain("Sonarr");
|
||||
expect(host.textContent).toContain("Radarr");
|
||||
expect(host.textContent).toContain("SABnzbd");
|
||||
});
|
||||
|
||||
it("renders tied widgets with icons and labels inside integration cards", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Tied Widgets");
|
||||
// Jellyfin is tied to Media Server widget
|
||||
expect(host.textContent).toContain("Media Server");
|
||||
});
|
||||
|
||||
it("displays fallback message when integration has no tied widgets", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["wud"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("button")).not.toBeNull();
|
||||
expect(host.textContent).toContain("No widgets tied to this service");
|
||||
});
|
||||
|
||||
it("displays '{count} connected' in footerLeft when integration instances exist", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const integrationData = [
|
||||
{ kind: "jellyfin" as IntegrationKind, name: "Home Jellyfin" },
|
||||
{ kind: "jellyfin" as IntegrationKind, name: "Remote Jellyfin" },
|
||||
{ kind: "sonarr" as IntegrationKind, name: "Sonarr Instance" },
|
||||
];
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid
|
||||
onSelect={onSelect}
|
||||
allowedKinds={["jellyfin", "radarr"]}
|
||||
integrationData={integrationData}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// Jellyfin has 2 connected
|
||||
expect(host.textContent).toContain("2 connected");
|
||||
|
||||
// Radarr has 0 connected, so it doesn't show "0 connected"
|
||||
expect(host.textContent).not.toContain("0 connected");
|
||||
});
|
||||
|
||||
it("filters integrations dynamically by search input", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr", "radarr", "plex"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
expect(searchInput).not.toBeNull();
|
||||
|
||||
// Type "jelly"
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "jelly");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
const cards = host.querySelectorAll("button");
|
||||
expect(cards.length).toBe(1);
|
||||
expect(host.textContent).toContain("Jellyfin");
|
||||
expect(host.textContent).not.toContain("Sonarr");
|
||||
expect(host.textContent).not.toContain("Radarr");
|
||||
});
|
||||
|
||||
it("displays empty state when no integrations match search query", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "nonexistent-integration-query");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("No results found");
|
||||
expect(host.querySelectorAll("button").length).toBe(0);
|
||||
});
|
||||
|
||||
it("calls onSelect when Enter is pressed with exactly 1 search match", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "jelly");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("jellyfin");
|
||||
});
|
||||
|
||||
it("calls onSelect when an integration card is clicked", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["sonarr"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const cardButton = host.querySelector("button");
|
||||
await act(async () => cardButton?.click());
|
||||
expect(onSelect).toHaveBeenCalledWith("sonarr");
|
||||
});
|
||||
|
||||
it("includes mock integration when enableMockIntegration is true", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} enableMockIntegration allowedKinds={["mock", "sonarr"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Mock");
|
||||
expect(host.textContent).toContain("Sonarr");
|
||||
});
|
||||
|
||||
it("excludes mock integration when enableMockIntegration is false", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} enableMockIntegration={false} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const buttons = Array.from(host.querySelectorAll("button"));
|
||||
const mockButton = buttons.find((btn) => btn.getAttribute("aria-label")?.toLowerCase() === "mock");
|
||||
expect(mockButton).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Card, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { IconPuzzle } from "@tabler/icons-react";
|
||||
import { Badge, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
IconAd,
|
||||
IconApps,
|
||||
IconBinaryTree,
|
||||
IconBookmark,
|
||||
IconBrandDocker,
|
||||
IconBrandMinecraft,
|
||||
IconBrowser,
|
||||
IconBuildingBank,
|
||||
IconBusStop,
|
||||
IconCalendar,
|
||||
IconChartBar,
|
||||
IconClock,
|
||||
IconClockPlay,
|
||||
IconCloud,
|
||||
IconDeviceCctv,
|
||||
IconDeviceGamepad,
|
||||
IconDownload,
|
||||
IconGraphFilled,
|
||||
IconHeartRateMonitor,
|
||||
IconHourglass,
|
||||
IconMessage,
|
||||
IconMovie,
|
||||
IconNotes,
|
||||
IconPuzzle,
|
||||
IconReportSearch,
|
||||
IconRobot,
|
||||
IconRocket,
|
||||
IconRss,
|
||||
IconSearch,
|
||||
IconServer2,
|
||||
IconTicket,
|
||||
IconTopologyFull,
|
||||
IconTransform,
|
||||
IconVideo,
|
||||
IconWall,
|
||||
IconWind,
|
||||
IconZoomQuestion,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { IntegrationAvatar } from "../components/integration-avatar";
|
||||
import { SelectGridLayout, selectGridCardHeight } from "../select-grid-layout";
|
||||
import { SelectableCard } from "../components/selectable-card";
|
||||
import { selectGridCols } from "../select-grid-layout";
|
||||
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
|
||||
import classes from "./integration-select-grid.module.css";
|
||||
import type { IntegrationGridItem } from "./integration-grid-shared";
|
||||
|
||||
export interface IntegrationSelectGridProps {
|
||||
onSelect: (kind: IntegrationKind) => void;
|
||||
enableMockIntegration?: boolean;
|
||||
allowedKinds?: readonly IntegrationKind[];
|
||||
integrationData?: { kind: IntegrationKind; name?: string }[];
|
||||
}
|
||||
|
||||
export const IntegrationSelectGrid = ({
|
||||
onSelect,
|
||||
enableMockIntegration = false,
|
||||
allowedKinds,
|
||||
integrationData,
|
||||
}: IntegrationSelectGridProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const t = useI18n();
|
||||
@@ -32,75 +73,182 @@ export const IntegrationSelectGrid = ({
|
||||
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
|
||||
|
||||
return (
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("integration.page.list.search")}...`}
|
||||
ariaLabel={t("integration.page.list.search")}
|
||||
>
|
||||
{filtered.map((integration) => (
|
||||
<UnstyledButton
|
||||
className={classes.choice}
|
||||
key={integration.kind}
|
||||
onClick={() => onSelect(integration.kind)}
|
||||
aria-label={integration.name}
|
||||
>
|
||||
<Card h={selectGridCardHeight} className={classes.card} withBorder>
|
||||
<Stack justify="space-between" h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Text fw={500} lh={1.2} size="sm" lineClamp={2}>
|
||||
{integration.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<IntegrationGridMeta integration={integration} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Center p="xl" style={{ gridColumn: "1 / -1" }}>
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const IntegrationGridMeta = ({ integration }: { integration: ReturnType<typeof buildSortedIntegrations>[number] }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Stack gap={4} mt="auto">
|
||||
<Group gap={4} wrap="wrap">
|
||||
{integration.categories.slice(0, 2).map((category) => (
|
||||
<Badge key={category} variant="light" size="xs">
|
||||
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{integration.widgets.length > 0 ? (
|
||||
<Tooltip
|
||||
multiline
|
||||
w={200}
|
||||
label={
|
||||
<Stack gap={4}>
|
||||
{integration.widgets.map((widgetKind) => (
|
||||
<Text key={widgetKind} size="xs">
|
||||
{t(`widget.${widgetKind}.name`)}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
<Stack gap="md">
|
||||
{/* Top Search Input */}
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={`${t("integration.page.list.search")}...`}
|
||||
aria-label={t("integration.page.list.search")}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filtered.length === 1 && filtered[0]) {
|
||||
onSelect(filtered[0].kind);
|
||||
}
|
||||
>
|
||||
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
|
||||
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{t("integration.grid.noWidgets")}
|
||||
</Badge>
|
||||
)}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scrollable Container with Integration Cards */}
|
||||
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
|
||||
<Stack gap="md" pt="xs" pr="xs" px={4}>
|
||||
<SimpleGrid cols={selectGridCols} spacing="sm">
|
||||
{filtered.map((integration) => (
|
||||
<IntegrationCard
|
||||
key={integration.kind}
|
||||
integration={integration}
|
||||
onSelect={onSelect}
|
||||
connectedCount={(integrationData ?? []).filter((i) => i.kind === integration.kind).length}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// IntegrationCard: Shared Modular SelectableCard with Tied Widgets
|
||||
// =========================================================================
|
||||
const IntegrationCard = ({
|
||||
integration,
|
||||
onSelect,
|
||||
connectedCount = 0,
|
||||
}: {
|
||||
integration: IntegrationGridItem;
|
||||
onSelect: (kind: IntegrationKind) => void;
|
||||
connectedCount?: number;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<SelectableCard
|
||||
onClick={() => onSelect(integration.kind)}
|
||||
aria-label={integration.name}
|
||||
icon={<IntegrationAvatar kind={integration.kind} size="sm" />}
|
||||
title={integration.name}
|
||||
topRight={<IntegrationCategoryBadges categories={integration.categories} limit={1} />}
|
||||
footerLeft={
|
||||
connectedCount > 0 ? (
|
||||
<Badge variant="light" color="teal" size="xs" radius="xs">
|
||||
{t("integration.grid.connected", { count: connectedCount })}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Text size="10px" tt="uppercase" fw={700} c="dimmed">
|
||||
{t("integration.grid.tiedWidgets")}
|
||||
</Text>
|
||||
<IntegrationTiedWidgets widgets={integration.widgets} />
|
||||
</SelectableCard>
|
||||
);
|
||||
};
|
||||
|
||||
const widgetIconsMap: Partial<Record<WidgetKind, typeof IconPuzzle>> = {
|
||||
clock: IconClock,
|
||||
weather: IconCloud,
|
||||
airQuality: IconWind,
|
||||
countdown: IconHourglass,
|
||||
timer: IconClockPlay,
|
||||
app: IconApps,
|
||||
assistant: IconRobot,
|
||||
iframe: IconBrowser,
|
||||
video: IconDeviceCctv,
|
||||
notebook: IconNotes,
|
||||
anchorNote: IconNotes,
|
||||
dnsHoleSummary: IconAd,
|
||||
dnsHoleControls: IconDeviceGamepad,
|
||||
"smartHome-entityState": IconBinaryTree,
|
||||
"smartHome-executeAutomation": IconBinaryTree,
|
||||
stockPrice: IconBuildingBank,
|
||||
mediaServer: IconVideo,
|
||||
calendar: IconCalendar,
|
||||
downloads: IconDownload,
|
||||
"mediaRequests-requestList": IconZoomQuestion,
|
||||
"mediaRequests-requestStats": IconChartBar,
|
||||
mediaTranscoding: IconTransform,
|
||||
mediaMissing: IconMovie,
|
||||
minecraftServerStatus: IconBrandMinecraft,
|
||||
networkControllerSummary: IconTopologyFull,
|
||||
networkControllerStatus: IconTopologyFull,
|
||||
rssFeed: IconRss,
|
||||
bookmarks: IconBookmark,
|
||||
indexerManager: IconReportSearch,
|
||||
healthMonitoring: IconHeartRateMonitor,
|
||||
releases: IconRocket,
|
||||
mediaReleases: IconTicket,
|
||||
dockerContainers: IconBrandDocker,
|
||||
firewall: IconWall,
|
||||
notifications: IconMessage,
|
||||
systemResources: IconGraphFilled,
|
||||
coolify: IconCloud,
|
||||
systemDisks: IconServer2,
|
||||
timetable: IconBusStop,
|
||||
};
|
||||
|
||||
const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; limit?: number }) => {
|
||||
const t = useI18n();
|
||||
if (widgets.length === 0) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{t("integration.grid.noWidgets")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const items = limit ? widgets.slice(0, limit) : widgets;
|
||||
const moreCount = limit ? widgets.length - limit : 0;
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{items.map((widgetKind) => {
|
||||
const WidgetIcon = widgetIconsMap[widgetKind] ?? IconPuzzle;
|
||||
return (
|
||||
<Badge
|
||||
key={widgetKind}
|
||||
variant="default"
|
||||
radius="xs"
|
||||
size="xs"
|
||||
leftSection={<WidgetIcon size={12} />}
|
||||
style={{ fontWeight: 500 }}
|
||||
>
|
||||
{t(`widget.${widgetKind}.name`)}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{moreCount > 0 && (
|
||||
<Badge variant="subtle" color="gray" size="xs" radius="xs">
|
||||
{t("integration.grid.more", { count: moreCount })}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const IntegrationCategoryBadges = ({
|
||||
categories,
|
||||
limit,
|
||||
}: {
|
||||
categories: IntegrationGridItem["categories"];
|
||||
limit?: number;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const items = limit ? categories.slice(0, limit) : categories;
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{items.map((category) => (
|
||||
<Badge key={category} variant="light" size="xs" color="gray" radius="xs">
|
||||
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user