diff --git a/apps/nextjs/src/components/backup/database-restore-flow.spec.ts b/apps/nextjs/src/components/backup/database-restore-flow.spec.ts
index ab9114813..e52332e89 100644
--- a/apps/nextjs/src/components/backup/database-restore-flow.spec.ts
+++ b/apps/nextjs/src/components/backup/database-restore-flow.spec.ts
@@ -2,7 +2,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { waitForServerReadinessAsync } from "./database-restore-flow";
+import { getStepAfterRestorePreview, waitForServerReadinessAsync } from "./database-restore-flow";
+
+it("requires confirmation after every restore preview", () => {
+ expect(getStepAfterRestorePreview()).toBe("confirm");
+});
describe("waitForServerReadinessAsync", () => {
beforeEach(() => {
diff --git a/apps/nextjs/src/components/backup/database-restore-flow.tsx b/apps/nextjs/src/components/backup/database-restore-flow.tsx
index 0708cd525..eaa83301e 100644
--- a/apps/nextjs/src/components/backup/database-restore-flow.tsx
+++ b/apps/nextjs/src/components/backup/database-restore-flow.tsx
@@ -29,6 +29,8 @@ const RESTART_REQUEST_TIMEOUT_MS = 5_000;
export type ServerReadinessResult = "ready" | "timedOut" | "aborted";
+export const getStepAfterRestorePreview = (): RestoreStep => "confirm";
+
interface WaitForServerReadinessOptions {
restartAfterMs: number;
signal: AbortSignal;
@@ -289,11 +291,8 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
<>
- }
- onClick={() => (variant === "standalone" ? void handleConfirm() : setStep("confirm"))}
- >
- {variant === "standalone" ? t("confirm.submit") : t("continueToRestore")}
+ } onClick={() => setStep(getStepAfterRestorePreview())}>
+ {t("continueToRestore")}
>
diff --git a/packages/onboarding/src/board-validation.spec.ts b/packages/onboarding/src/board-validation.spec.ts
new file mode 100644
index 000000000..9493c1a19
--- /dev/null
+++ b/packages/onboarding/src/board-validation.spec.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+
+import { getBoardValidationErrors } from "./board-validation";
+
+describe("getBoardValidationErrors", () => {
+ it("keeps attempted board errors visible until each value is corrected", () => {
+ expect(
+ getBoardValidationErrors({
+ attempted: true,
+ hasExistingBoards: true,
+ selectedBoardId: null,
+ boardName: "",
+ }),
+ ).toEqual({ target: true, name: true });
+
+ expect(
+ getBoardValidationErrors({
+ attempted: true,
+ hasExistingBoards: true,
+ selectedBoardId: "board-id",
+ boardName: "dashboard",
+ }),
+ ).toEqual({ target: false, name: false });
+ });
+
+ it("does not require a target when setup has no existing board", () => {
+ expect(
+ getBoardValidationErrors({
+ attempted: true,
+ hasExistingBoards: false,
+ selectedBoardId: null,
+ boardName: "dashboard",
+ }).target,
+ ).toBe(false);
+ });
+});
diff --git a/packages/onboarding/src/board-validation.ts b/packages/onboarding/src/board-validation.ts
new file mode 100644
index 000000000..3fd3718f0
--- /dev/null
+++ b/packages/onboarding/src/board-validation.ts
@@ -0,0 +1,16 @@
+interface BoardValidationInput {
+ attempted: boolean;
+ hasExistingBoards: boolean;
+ selectedBoardId: string | null;
+ boardName: string;
+}
+
+export const getBoardValidationErrors = ({
+ attempted,
+ hasExistingBoards,
+ selectedBoardId,
+ boardName,
+}: BoardValidationInput) => ({
+ target: attempted && hasExistingBoards && !selectedBoardId,
+ name: attempted && !boardName.trim(),
+});
diff --git a/packages/onboarding/src/setup-studio.tsx b/packages/onboarding/src/setup-studio.tsx
index 39358f23b..2803caa96 100644
--- a/packages/onboarding/src/setup-studio.tsx
+++ b/packages/onboarding/src/setup-studio.tsx
@@ -96,6 +96,7 @@ import { BoardColorInput, ColorSchemeCombobox, IntegrationAvatar, LanguageCombob
import { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid";
import type { OnboardingStudioProps } from "./types";
+import { getBoardValidationErrors } from "./board-validation";
import { OnboardingBackdrop } from "./onboarding-backdrop";
import { OnboardingWordmark } from "./onboarding-wordmark";
import { useOnboardingSounds } from "./use-onboarding-sounds";
@@ -230,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 [boardValidationAttempted, setBoardValidationAttempted] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const [applyProgress, setApplyProgress] = useState(0);
const [applyMessage, setApplyMessage] = useState("");
@@ -255,8 +257,6 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
showErrorNotification({ title: t("review.errorTitle"), message: error.message });
},
});
- const validateIntegration = clientApi.onboard.testIntegration.useMutation();
-
const dockerData = docker.data;
const discoveredIntegrations = dockerData?.integrations ?? emptyDiscoveredIntegrations;
const discoveredApps = dockerData?.apps ?? emptyDiscoveredApps;
@@ -423,6 +423,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
setAppError(null);
setAppErrorSourceId(null);
setApplyProgress(5);
+ setBoardValidationAttempted(true);
try {
if (!selectedBoardId && environment.availableBoards.length > 0) {
setActiveSection("board");
@@ -444,18 +445,6 @@ 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));
- await validateIntegration
- .mutateAsync({
- sourceId: draft.sourceId ?? draft.id,
- name: draft.name,
- url: normalizeServiceUrl(draft.url) ?? draft.url,
- kind: draft.kind,
- secrets: draft.secrets,
- })
- .catch(() => undefined);
- }
setApplyProgress(20);
await new Promise((resolve) => setTimeout(resolve, 300));
const selectedSourceIds = new Set(draftsToApply.flatMap((draft) => (draft.sourceId ? [draft.sourceId] : [])));
@@ -607,6 +596,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
boardName,
setBoardName,
selectedBoardId,
+ boardValidationAttempted,
setSelectedBoardId: (value) => {
sounds.click();
setSelectedBoardId(value);
@@ -852,6 +842,7 @@ interface StudioSectionProps {
boardName: string;
setBoardName: (value: string) => void;
selectedBoardId: string | null;
+ boardValidationAttempted: boolean;
setSelectedBoardId: (value: string | null) => void;
primaryColor: string;
setPrimaryColor: (value: string) => void;
@@ -1449,6 +1440,12 @@ const formatSecretKind = (kind: IntegrationSecretKind) =>
const BoardBuilder = (props: StudioSectionProps) => {
const t = useScopedI18n("init.studio.board");
const sounds = useOnboardingSounds();
+ const validationErrors = getBoardValidationErrors({
+ attempted: props.boardValidationAttempted,
+ hasExistingBoards: props.environment.availableBoards.length > 0,
+ selectedBoardId: props.selectedBoardId,
+ boardName: props.boardName,
+ });
const previewWidgetKinds = getPreviewWidgetKinds(props);
const previewAppCount = getPreviewAppCount(props);
const colorsCustomized =
@@ -1474,7 +1471,7 @@ const BoardBuilder = (props: StudioSectionProps) => {
allowDeselect={false}
searchable
withAsterisk
- error={!props.selectedBoardId && props.applyProgress > 0 ? t("targetRequired") : undefined}
+ error={validationErrors.target ? t("targetRequired") : undefined}
/>
) : null}
{
value={props.boardName}
onChange={(event) => props.setBoardName(event.currentTarget.value.replace(/[^A-Za-z0-9-_]/g, ""))}
withAsterisk
+ error={validationErrors.name ? t("nameRequired") : undefined}
/>
@@ -1511,7 +1509,7 @@ const BoardBuilder = (props: StudioSectionProps) => {
}}
w="fit-content"
>
- Reset colors
+ {t("resetColors")}
) : null}
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index df14c588d..97d711541 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -144,8 +144,10 @@
"targetRequired": "Select the board to configure before continuing.",
"name": "Board name",
"nameDescription": "Used in the board URL; letters, numbers, dashes, and underscores only.",
+ "nameRequired": "Enter a board name before continuing.",
"primaryColor": "Primary color",
"secondaryColor": "Secondary color",
+ "resetColors": "Reset colors",
"radius": "Widget corner radius",
"columns": "Board columns",
"columnsDescription": "Columns define the horizontal grid used to size and position items. 12 is plenty for most people.",