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 <> - 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.",