feat(onboarding): hide 0-widget integrations and add selectable app grid

- Filter out integrations with no widgets from onboarding selection
- Replace single checkbox app toggle with a compact selectable grid
- Each Docker-discovered app is individually toggleable (all on by default)
- Track selected apps by container ID instead of a boolean flag
This commit is contained in:
Thomas Camlong committed 2026-05-27 15:55:40 +02:00
1 parent a6566f74c9
commit 1df84cbccd
4 files changed
+92 -49

No files matched your search

@@ -4,17 +4,18 @@ import { useEffect, useRef, useState } from "react";
import {
Avatar,
Badge,
Checkbox,
Card,
Collapse,
Divider,
Group,
Paper,
ScrollArea,
SimpleGrid,
Stack,
Text,
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import { IconBrandDocker, IconChevronDown } from "@tabler/icons-react";
import { IconBrandDocker, IconCheck, IconChevronDown } from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
@@ -40,31 +41,36 @@ export interface DiscoveredApp {
interface DockerDiscoveryIndicatorProps {
integrations: DiscoveredIntegration[];
apps: DiscoveredApp[];
appsEnabled: boolean;
onToggleAllApps: (checked: boolean) => void;
selectedAppIds: Set<string>;
onToggleApp: (containerId: string) => void;
}
const APP_CARD_HEIGHT = 80;
export const DockerDiscoveryIndicator = ({
integrations,
apps,
appsEnabled,
onToggleAllApps,
selectedAppIds,
onToggleApp,
}: DockerDiscoveryIndicatorProps) => {
const hasApps = apps.length > 0;
const [expanded, setExpanded] = useState(false);
const expandedOnce = useRef(false);
useEffect(() => {
if (hasApps && !expandedOnce.current) {
if ((hasApps || integrations.length > 0) && !expandedOnce.current) {
expandedOnce.current = true;
setExpanded(true);
}
}, [hasApps]);
}, [hasApps, integrations.length]);
const t = useScopedI18n("init.step.integrations.docker");
const totalCount = integrations.length + apps.length;
if (totalCount === 0) return null;
const selectedAppCount = apps.filter((app) => selectedAppIds.has(app.containerId)).length;
return (
<Paper
withBorder
@@ -75,7 +81,7 @@ export const DockerDiscoveryIndicator = ({
background: "var(--mantine-color-blue-light)",
}}
>
<UnstyledButton onClick={() => setExpanded((v) => !v)} w="100%">
<UnstyledButton onClick={() => setExpanded((prev) => !prev)} w="100%">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
@@ -105,41 +111,63 @@ export const DockerDiscoveryIndicator = ({
</UnstyledButton>
<Collapse in={expanded}>
<Stack gap={4} mt="sm">
{integrations.map((integration) => (
<Group key={integration.containerId} gap="xs" wrap="nowrap" py={2}>
<IntegrationAvatar kind={integration.kind} size="xs" />
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{integration.containerName}
</Text>
<Badge size="xs" variant="light" color="blue">
{getIntegrationName(integration.kind)}
</Badge>
</Group>
))}
<Stack gap="xs" mt="sm">
{integrations.length > 0 && (
<Stack gap={4}>
{integrations.map((integration) => (
<Group key={integration.containerId} gap="xs" wrap="nowrap" py={2}>
<IntegrationAvatar kind={integration.kind} size="xs" />
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{integration.containerName}
</Text>
<Badge size="xs" variant="light" color="blue">
{getIntegrationName(integration.kind)}
</Badge>
</Group>
))}
</Stack>
)}
{hasApps && (
<>
{integrations.length > 0 && <Divider my={4} />}
<Checkbox
size="xs"
label={t("appSectionLabel", { count: String(apps.length) })}
checked={appsEnabled}
onChange={() => onToggleAllApps(!appsEnabled)}
styles={{ label: { fontSize: "var(--mantine-font-size-xs)" } }}
/>
{appsEnabled &&
apps.map((app) => (
<Group key={app.containerId} gap="xs" wrap="nowrap" py={2}>
<Avatar size="xs" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
{app.containerName.at(0)?.toUpperCase()}
</Avatar>
<Text size="xs" c="dimmed" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{app.containerName}
</Text>
</Group>
))}
</>
<Stack gap="xs">
<Text size="xs" fw={500}>
{t("appSectionLabel", { count: String(selectedAppCount) })}
</Text>
<ScrollArea.Autosize mah="30vh">
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
{apps.map((app) => {
const isSelected = selectedAppIds.has(app.containerId);
return (
<Card
key={app.containerId}
h={APP_CARD_HEIGHT}
p="xs"
withBorder
style={{
cursor: "pointer",
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: isSelected ? 2 : undefined,
opacity: isSelected ? 1 : 0.6,
}}
onClick={() => onToggleApp(app.containerId)}
>
<Stack justify="space-between" h="100%" gap={4} align="center">
<Group justify="space-between" w="100%" wrap="nowrap">
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{app.containerName}
</Text>
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
</Group>
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
{app.containerName.at(0)?.toUpperCase()}
</Avatar>
</Stack>
</Card>
);
})}
</SimpleGrid>
</ScrollArea.Autosize>
</Stack>
)}
</Stack>
</Collapse>
@@ -64,7 +64,7 @@ export const InitIntegrations = () => {
const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : [];
const dockerApps = dockerData?.status === "success" ? dockerData.apps : [];
const [addDockerApps, setAddDockerApps] = useState(true);
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
const dockerApplied = useRef(false);
useEffect(() => {
@@ -89,12 +89,25 @@ export const InitIntegrations = () => {
setDrafts(newDrafts);
setSelectedKinds(Array.from(newKinds));
setSelectedAppIds(new Set(dockerData.apps.map((app) => app.containerId)));
}, [dockerData]);
const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => {
setSelectedKinds(kinds);
}, []);
const handleToggleApp = useCallback((containerId: string) => {
setSelectedAppIds((prev) => {
const next = new Set(prev);
if (next.has(containerId)) {
next.delete(containerId);
} else {
next.add(containerId);
}
return next;
});
}, []);
const handleContinueToConfig = () => {
const newDrafts = new Map(drafts);
for (const kind of selectedKinds) {
@@ -119,9 +132,10 @@ export const InitIntegrations = () => {
const finishSetupAsync = async () => {
setPhase("done");
try {
if (addDockerApps && dockerApps.length > 0) {
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
if (appsToCreate.length > 0) {
await createApps(
dockerApps.map((app) => ({
appsToCreate.map((app) => ({
name: app.containerName,
href: app.suggestedUrl || null,
iconUrl: app.iconUrl,
@@ -190,8 +204,8 @@ export const InitIntegrations = () => {
<DockerDiscoveryIndicator
integrations={dockerIntegrations}
apps={dockerApps}
appsEnabled={addDockerApps}
onToggleAllApps={setAddDockerApps}
selectedAppIds={selectedAppIds}
onToggleApp={handleToggleApp}
/>
<Stack gap="xs">
@@ -74,6 +74,7 @@ export const buildSortedIntegrations = (
integrationKinds
.filter((kind) => {
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
if (options.onboarding && widgetsByIntegration[kind].length === 0) return false;
if (!options.enableMockIntegration && kind === "mock") return false;
return true;
})
+1 -1
View File
@@ -125,7 +125,7 @@
"detected": "Docker detected {count} services",
"detectedBreakdown": "{integrations} integrations · {apps} apps",
"appBadge": "App",
"appSectionLabel": "Also add {count} apps to dashboard"
"appSectionLabel": "Apps to add to dashboard ({count} selected)"
}
},
"finish": {