fix(api): refactor onboard-router widget placement and step advancement

- Extract placeWidgetAsync into a standalone function with a
  PlaceWidgetContext parameter for clearer state management.
- Fix widget skip logic: use config?.skip instead of !config || config.skip
  so integration-specific widgets without an explicit defaultWidgetConfigs
  entry are still placed with default 2x2 sizes.
- Add createIntegration mutation for onboarding (no app linking or
  search engine creation needed during init).
- Fix nextOnboardingStepAsync call: pass undefined instead of "start"
  to advance naturally without resetting to the beginning.
- Add early return when no board is found in setupIntegrations.
This commit is contained in:
Thomas Camlong committed 2026-05-14 17:00:20 +02:00
1 parent 97e39ced44
commit bef275804c
1 file changed
+198 -108
+198 -108
View File
@@ -2,26 +2,97 @@ import superjson from "superjson";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { groups, integrationItems, itemLayouts, items, onboarding } from "@homarr/db/schema";
import {
groups,
integrationItems,
integrations,
integrationSecrets,
itemLayouts,
items,
onboarding,
} from "@homarr/db/schema";
import {
defaultBookmarkApps,
defaultWidgetConfigs,
emptySuperJSON,
everyoneGroup,
getWidgetKindsForIntegration,
integrationKinds,
integrationSecretKinds,
onboardingSteps,
} from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { zodEnumFromArray } from "@homarr/validation/enums";
import { createTRPCRouter, onboardingProcedure, publicProcedure } from "../../trpc";
import { MissingSecretError, testConnectionAsync } from "../integration/integration-test-connection";
import { mapTestConnectionError } from "../integration/map-test-connection-error";
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
const widgetConfigMap = new Map(defaultWidgetConfigs.map((config) => [config.kind, config]));
const getWidgetConfig = (kind: WidgetKind) => widgetConfigMap.get(kind);
interface PlaceWidgetContext {
db: Database;
boardId: string;
sectionId: string;
layoutId: string;
columnCount: number;
xOffset: number;
yOffset: number;
rowMaxHeight: number;
}
const placeWidgetAsync = async (
ctx: PlaceWidgetContext,
kind: WidgetKind,
linkedIntegrationIds: string[],
options?: string,
size?: { width: number; height: number },
) => {
const itemWidth = size?.width ?? 2;
const itemHeight = size?.height ?? 2;
if (ctx.xOffset + itemWidth > ctx.columnCount) {
ctx.xOffset = 0;
ctx.yOffset += ctx.rowMaxHeight;
ctx.rowMaxHeight = 0;
}
const itemId = createId();
await ctx.db.insert(items).values({
id: itemId,
boardId: ctx.boardId,
kind,
options: options ?? emptySuperJSON,
advancedOptions: emptySuperJSON,
});
await ctx.db.insert(itemLayouts).values({
itemId,
sectionId: ctx.sectionId,
layoutId: ctx.layoutId,
xOffset: ctx.xOffset,
yOffset: ctx.yOffset,
width: itemWidth,
height: itemHeight,
});
for (const integrationId of linkedIntegrationIds) {
await ctx.db.insert(integrationItems).values({
itemId,
integrationId,
});
}
ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight);
ctx.xOffset += itemWidth;
};
export const onboardRouter = createTRPCRouter({
currentStep: publicProcedure.query(async ({ ctx }) => {
return await getOnboardingOrFallbackAsync(ctx.db);
@@ -29,6 +100,7 @@ export const onboardRouter = createTRPCRouter({
nextStep: publicProcedure
.input(
z.object({
// Preferred step is only needed for 'preferred' conditions
preferredStep: zodEnumFromArray(onboardingSteps).optional(),
}),
)
@@ -47,6 +119,61 @@ export const onboardRouter = createTRPCRouter({
step: "start",
});
}),
createIntegration: onboardingProcedure
.requiresStep("integrations")
.input(
z.object({
name: z.string().nonempty().max(127),
url: z
.string()
.url()
.regex(/^https?:\/\//),
kind: zodEnumFromArray(integrationKinds),
secrets: z.array(
z.object({
kind: zodEnumFromArray(integrationSecretKinds),
value: z.string().nonempty(),
}),
),
}),
)
.mutation(async ({ ctx, input }) => {
const result = await testConnectionAsync({
id: "new",
name: input.name,
url: input.url,
kind: input.kind,
secrets: input.secrets,
}).catch((error) => {
if (error instanceof MissingSecretError) {
return { success: false as const, error };
}
throw error;
});
if (!result.success) {
return { error: mapTestConnectionError(result.error) };
}
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId: null,
});
if (input.secrets.length >= 1) {
await ctx.db.insert(integrationSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
})),
);
}
}),
setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => {
const db = ctx.db;
@@ -60,123 +187,86 @@ export const onboardRouter = createTRPCRouter({
},
});
if (board) {
const everyoneGroupRow = await db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
});
if (everyoneGroupRow && !everyoneGroupRow.homeBoardId) {
await db.update(groups).set({ homeBoardId: board.id }).where(eq(groups.id, everyoneGroupRow.id));
if (!board) {
await nextOnboardingStepAsync(db, undefined);
return;
}
const everyoneGroupRow = await db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
});
if (everyoneGroupRow && !everyoneGroupRow.homeBoardId) {
await db.update(groups).set({ homeBoardId: board.id }).where(eq(groups.id, everyoneGroupRow.id));
}
const section = board.sections.find((boardSection) => boardSection.kind === "empty");
const layout = board.layouts[0];
if (section && layout) {
const widgetCtx: PlaceWidgetContext = {
db,
boardId: board.id,
sectionId: section.id,
layoutId: layout.id,
columnCount: layout.columnCount,
xOffset: 0,
yOffset: 0,
rowMaxHeight: 0,
};
const placedWidgets = new Set<WidgetKind>();
for (const integration of allIntegrations) {
for (const widgetKind of getWidgetKindsForIntegration(integration.kind)) {
if (placedWidgets.has(widgetKind)) continue;
const config = getWidgetConfig(widgetKind);
if (config?.skip) continue;
placedWidgets.add(widgetKind);
const matchingIds = allIntegrations
.filter((row) => getWidgetKindsForIntegration(row.kind).includes(widgetKind))
.map((row) => row.id);
const options = config?.options ? superjson.stringify(config.options) : undefined;
await placeWidgetAsync(
widgetCtx,
widgetKind,
matchingIds,
options,
config && {
width: config.width,
height: config.height,
},
);
}
}
const section = board.sections.find((boardSection) => boardSection.kind === "empty");
const layout = board.layouts[0];
for (const app of allApps) {
const appOptions = superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true });
await placeWidgetAsync(widgetCtx, "app", [], appOptions, { width: 1, height: 1 });
}
if (section && layout) {
const columnCount = layout.columnCount;
let xOffset = 0;
let yOffset = 0;
let rowMaxHeight = 0;
const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name));
const bookmarkAppIds = allApps.filter((app) => bookmarkAppNames.has(app.name)).map((app) => app.id);
const placeWidgetAsync = async (
kind: WidgetKind,
linkedIntegrationIds: string[],
options?: string,
size?: { width: number; height: number },
) => {
const itemWidth = size?.width ?? 2;
const itemHeight = size?.height ?? 2;
for (const config of defaultWidgetConfigs) {
if (config.skip) continue;
if (placedWidgets.has(config.kind)) continue;
placedWidgets.add(config.kind);
if (xOffset + itemWidth > columnCount) {
xOffset = 0;
yOffset += rowMaxHeight;
rowMaxHeight = 0;
}
let options = config.options ? { ...config.options } : undefined;
const itemId = createId();
await db.insert(items).values({
id: itemId,
boardId: board.id,
kind,
options: options ?? emptySuperJSON,
advancedOptions: emptySuperJSON,
});
await db.insert(itemLayouts).values({
itemId,
sectionId: section.id,
layoutId: layout.id,
xOffset,
yOffset,
width: itemWidth,
height: itemHeight,
});
for (const integrationId of linkedIntegrationIds) {
await db.insert(integrationItems).values({
itemId,
integrationId,
});
}
rowMaxHeight = Math.max(rowMaxHeight, itemHeight);
xOffset += itemWidth;
};
const placedWidgets = new Set<WidgetKind>();
// Place integration-specific widgets
for (const integration of allIntegrations) {
const kind = integration.kind;
for (const widgetKind of getWidgetKindsForIntegration(kind)) {
if (placedWidgets.has(widgetKind)) continue;
const config = getWidgetConfig(widgetKind);
if (config?.skip) continue;
placedWidgets.add(widgetKind);
const matchingIds = allIntegrations
.filter((row) => getWidgetKindsForIntegration(row.kind).includes(widgetKind))
.map((row) => row.id);
const options = config?.options ? superjson.stringify(config.options) : undefined;
await placeWidgetAsync(
widgetKind,
matchingIds,
options,
config && { width: config.width, height: config.height },
);
}
if (config.kind === "bookmarks" && bookmarkAppIds.length > 0) {
options = { ...options, items: bookmarkAppIds };
}
// Place an app widget for each app in the DB
for (const app of allApps) {
const appOptions = superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true });
await placeWidgetAsync("app", [], appOptions, { width: 1, height: 1 });
}
// Place general widgets from config
const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name));
const bookmarkAppIds = allApps.filter((app) => bookmarkAppNames.has(app.name)).map((app) => app.id);
for (const config of defaultWidgetConfigs) {
if (config.skip) continue;
if (placedWidgets.has(config.kind)) continue;
placedWidgets.add(config.kind);
let options = config.options ? { ...config.options } : undefined;
if (config.kind === "bookmarks" && bookmarkAppIds.length > 0) {
options = { ...options, items: bookmarkAppIds };
}
await placeWidgetAsync(config.kind, [], options ? superjson.stringify(options) : undefined, {
width: config.width,
height: config.height,
});
}
await placeWidgetAsync(widgetCtx, config.kind, [], options ? superjson.stringify(options) : undefined, {
width: config.width,
height: config.height,
});
}
}
await nextOnboardingStepAsync(db, "start");
await nextOnboardingStepAsync(db, undefined);
}),
});