feat: consolidate onboarding

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:32:12 +02:00
1 parent 8239a631f2
commit 7b1a801ab8
8 files changed
+153 -25

No files matched your search

@@ -47,14 +47,29 @@
}
.tile {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
border: rem(1px) solid color-mix(in srgb, var(--mantine-primary-color-filled) 32%, transparent);
border-radius: max(rem(2px), calc(var(--mantine-radius-default) / 2));
background: color-mix(in srgb, var(--mantine-primary-color-filled) 18%, var(--mantine-color-body));
box-shadow: 0 rem(1px) rem(3px) rgb(0 0 0 / 8%);
}
.itemIcon {
position: absolute;
top: 50%;
left: 50%;
width: rem(16px) !important;
height: rem(16px) !important;
max-width: 55%;
max-height: 55%;
object-fit: contain;
color: var(--mantine-color-text);
transform: translate(-50%, -50%);
}
.container {
border-style: dashed;
border-color: color-mix(in srgb, var(--mantine-color-teal-filled) 45%, transparent);
@@ -1,9 +1,10 @@
import { Box, Center } from "@mantine/core";
import { Box, Center, Image } from "@mantine/core";
import { IconLayoutGrid } from "@tabler/icons-react";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { projectBoardLayout } from "@homarr/boards/layout-preview";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import classes from "./board-layout-thumbnail.module.css";
@@ -73,17 +74,27 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro
gridTemplateRows: `repeat(${rowCount}, minmax(0, 1fr))`,
}}
>
{laneElements.map((element) => (
<span
key={`${element.type}-${element.id}`}
aria-hidden
className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`}
style={{
gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`,
gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`,
}}
/>
))}
{laneElements.map((element) => {
const item = element.type === "item" ? preview.items.find((item) => item.id === element.id) : undefined;
const WidgetIcon = item?.kind ? widgetCatalogIcons[item.kind] : undefined;
return (
<span
key={`${element.type}-${element.id}`}
aria-hidden
className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`}
style={{
gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`,
gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`,
}}
>
{item?.iconUrl ? (
<Image className={classes.itemIcon} src={item.iconUrl} alt="" fit="contain" />
) : WidgetIcon ? (
<WidgetIcon className={classes.itemIcon} stroke={1.7} />
) : null}
</span>
);
})}
</div>
);
})}
+1 -1
View File
@@ -197,7 +197,7 @@ describe("Onboarding", () => {
try {
const initialBaseUrl = `http://${initialContainer.getHost()}:${initialContainer.getMappedPort(7575)}`;
await page.goto(`${initialBaseUrl}/init`);
await page.getByRole("button", { name: "Come from a backup" }).click();
await page.getByRole("button", { name: "Restore backup" }).click();
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: "missing-metadata.zip",
+36 -1
View File
@@ -23,6 +23,7 @@ import {
integrationItems,
integrationUserPermissions,
integrations,
apps,
itemLayouts,
items,
layouts,
@@ -295,6 +296,11 @@ export const boardRouter = createTRPCRouter({
width: true,
height: true,
},
with: {
item: {
columns: { kind: true, options: true },
},
},
where: and(
inArray(itemLayouts.layoutId, previewLayoutIds),
inArray(itemLayouts.sectionId, rootSectionIds),
@@ -333,6 +339,27 @@ export const boardRouter = createTRPCRouter({
])
: [[], []];
const appIdByItemId = new Map(
previewItemLayouts.flatMap((layout) => {
if (layout.item.kind !== "app") return [];
try {
const { appId } = superjson.parse<{ appId?: unknown }>(layout.item.options);
return typeof appId === "string" ? [[layout.itemId, appId] as const] : [];
} catch {
return [];
}
}),
);
const previewAppIds = [...new Set(appIdByItemId.values())];
const previewApps =
previewAppIds.length > 0
? await ctx.db.query.apps.findMany({
columns: { id: true, iconUrl: true },
where: inArray(apps.id, previewAppIds),
})
: [];
const appIconUrlById = new Map(previewApps.map((app) => [app.id, app.iconUrl]));
return dbBoards.map(({ layouts: boardLayouts, sections: boardSections, ...board }) => {
const previewLayout = boardLayouts.at(0);
@@ -365,7 +392,15 @@ export const boardRouter = createTRPCRouter({
const itemPreview = previewLayout
? previewItemLayouts
.filter((layout) => layout.layoutId === previewLayout.id && isInsideRootLane(layout))
.map((layout) => ({ id: layout.itemId, layouts: [layout] }))
.map((layout) => {
const appId = appIdByItemId.get(layout.itemId);
return {
id: layout.itemId,
kind: layout.item.kind,
iconUrl: appId ? appIconUrlById.get(appId) : undefined,
layouts: [layout],
};
})
: [];
const containerPreview = previewLayout
? previewSectionLayouts
@@ -6,6 +6,7 @@ import { createId } from "@homarr/common";
import type { Database, InferInsertModel } from "@homarr/db";
import { and, eq } from "@homarr/db";
import {
apps,
boardGroupPermissions,
boards,
boardUserPermissions,
@@ -457,6 +458,7 @@ describe("getManageOverview", () => {
items: [
{
id: itemId,
kind: "clock",
layouts: [{ layoutId: baseLayoutId, sectionId: rootSectionId, width: 4, height: 2 }],
},
],
@@ -475,6 +477,64 @@ describe("getManageOverview", () => {
expect(result[0]?.preview?.sections).toHaveLength(2);
});
test("includes app and widget icons in compact previews", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const boardId = createId();
const layoutId = createId();
const rootSectionId = createId();
const appId = createId();
const appItemId = createId();
await db.insert(users).values({ id: defaultCreatorId });
await db.insert(boards).values({ id: boardId, name: "icon-overview", creatorId: defaultCreatorId });
await db.insert(layouts).values({
id: layoutId,
name: "Base",
boardId,
columnCount: 12,
breakpoint: 768,
role: "base",
});
await db.insert(sections).values({
id: rootSectionId,
boardId,
kind: "empty",
xOffset: 0,
yOffset: 0,
});
await db.insert(apps).values({
id: appId,
name: "Sonarr",
iconUrl: "https://example.com/sonarr.svg",
});
await db.insert(items).values({
id: appItemId,
boardId,
kind: "app",
options: SuperJSON.stringify({ appId }),
});
await db.insert(itemLayouts).values({
itemId: appItemId,
sectionId: rootSectionId,
layoutId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 2,
});
const result = await caller.getManageOverview();
expect(result[0]?.preview?.items).toEqual([
expect.objectContaining({
id: appItemId,
kind: "app",
iconUrl: "https://example.com/sonarr.svg",
}),
]);
});
test("uses the highest-breakpoint layout when a board has no base layout", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+3 -1
View File
@@ -1,6 +1,6 @@
import { generateResponsiveGridFor } from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common";
import type { BoardLane, LayoutRole } from "@homarr/definitions";
import type { BoardLane, LayoutRole, WidgetKind } from "@homarr/definitions";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
export interface BoardPreviewLayout {
@@ -35,6 +35,8 @@ export interface BoardPreviewData {
sections: readonly BoardPreviewSection[];
items: readonly {
id: string;
kind?: WidgetKind;
iconUrl?: string | null;
layouts: readonly BoardPreviewItemLayout[];
}[];
}
+14 -9
View File
@@ -231,6 +231,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
const [columnCount, setColumnCount] = useState(10);
const [leftSidebar, setLeftSidebar] = useState(false);
const [rightSidebar, setRightSidebar] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const [applyProgress, setApplyProgress] = useState(0);
const [applyMessage, setApplyMessage] = useState("");
const [appError, setAppError] = useState<string | null>(null);
@@ -256,7 +257,6 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
},
});
const validateIntegration = clientApi.onboard.testIntegration.useMutation();
const isApplying = complete.isPending || validateIntegration.isPending;
const dockerData = docker.data;
const discoveredIntegrations = dockerData?.integrations ?? emptyDiscoveredIntegrations;
@@ -280,6 +280,10 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
[discoveredIntegrations],
);
useEffect(() => {
setServerOrigin((current) => current || window.location.origin);
}, []);
useEffect(() => {
if (!docker.data) return;
const newIntegrationSourceIds = takeNewSourceIds(
@@ -412,6 +416,8 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
};
const applySetupAsync = async () => {
if (isApplying) return;
setIsApplying(true);
setAppError(null);
setAppErrorSourceId(null);
setApplyProgress(5);
@@ -438,19 +444,15 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
setApplyMessage(t("review.progress.applying"));
for (const [index, draft] of draftsToApply.entries()) {
setApplyProgress(5 + Math.round(((index + 1) / Math.max(1, draftsToApply.length)) * 15));
try {
const result = await validateIntegration.mutateAsync({
await validateIntegration
.mutateAsync({
sourceId: draft.sourceId ?? draft.id,
name: draft.name,
url: normalizeServiceUrl(draft.url) ?? draft.url,
kind: draft.kind,
secrets: draft.secrets,
});
if (result.success) sounds.success();
else sounds.error();
} catch {
sounds.error();
}
})
.catch(() => undefined);
}
setApplyProgress(20);
await new Promise((resolve) => setTimeout(resolve, 300));
@@ -543,6 +545,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
} catch {
sounds.error();
setApplyProgress(0);
setIsApplying(false);
}
};
@@ -1439,6 +1442,7 @@ const formatSecretKind = (kind: IntegrationSecretKind) =>
const BoardBuilder = (props: StudioSectionProps) => {
const t = useScopedI18n("init.studio.board");
const sounds = useOnboardingSounds();
const previewWidgetKinds = getPreviewWidgetKinds(props);
const previewAppCount = getPreviewAppCount(props);
const colorsCustomized =
@@ -1495,6 +1499,7 @@ const BoardBuilder = (props: StudioSectionProps) => {
size="compact-sm"
leftSection={<IconRefresh size={14} />}
onClick={() => {
sounds.pop();
props.setPrimaryColor(initialPrimaryColor);
props.setSecondaryColor(initialSecondaryColor);
}}
+1 -1
View File
@@ -31,7 +31,7 @@
"title": "Make home feel organized.",
"description": "Welcome to Homarr v{version}. We’ll guide you through the essentials and help you build your first board.",
"start": "Get started",
"restore": "Come from a backup",
"restore": "Restore backup",
"restoreTitle": "Restore this Homarr",
"restoreDescription": "A full SQLite backup restores boards, people, apps, integrations, and settings. Homarr validates it before replacing anything.",
"errorTitle": "Setup could not start",