feat: consolidate onboarding
This commit is contained in:
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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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[];
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user