diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/ImportProjectsPage.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/ImportProjectsPage.tsx index ecf0574be28..218fdf72bf7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/ImportProjectsPage.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/ImportProjectsPage.tsx @@ -1,8 +1,14 @@ +import { Button } from "@superset/ui/button"; +import { Spinner } from "@superset/ui/spinner"; +import type { QueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { LuFolder } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { + getHostServiceClientByUrl, + type HostServiceClient, +} from "renderer/lib/host-service-client"; import { getBaseName } from "renderer/lib/pathBasename"; import { useFinalizeProjectSetup } from "renderer/react-query/projects"; import { ImportPageShell } from "../components/ImportPageShell"; @@ -16,15 +22,32 @@ interface ImportProjectsPageProps { const FIND_BY_PATH_KEY_PREFIX = ["v1-import", "findByPath"] as const; const HOST_PROJECT_LIST_KEY_PREFIX = ["v1-import", "hostProjectList"] as const; +type V1Project = { + id: string; + name: string; + mainRepoPath: string; + githubOwner: string | null; +}; + +type ProjectFindByPathResult = Awaited< + ReturnType +>; + export function ImportProjectsPage({ organizationId, activeHostUrl, }: ImportProjectsPageProps) { const queryClient = useQueryClient(); + const finalizeSetup = useFinalizeProjectSetup(); const projectsQuery = electronTrpc.migration.readV1Projects.useQuery(); const [isRefreshing, setIsRefreshing] = useState(false); + const [importAllProgress, setImportAllProgress] = useState<{ + current: number; + total: number; + } | null>(null); const isLoading = projectsQuery.isPending; + const isImportingAll = importAllProgress !== null; const projects = projectsQuery.data ?? []; @@ -40,6 +63,75 @@ export function ImportProjectsPage({ } }; + const importAll = async () => { + if (isImportingAll) return; + const queue = projects; + setImportAllProgress({ current: 0, total: queue.length }); + try { + for (let i = 0; i < queue.length; i++) { + const project = queue[i]; + if (!project) continue; + setImportAllProgress({ current: i, total: queue.length }); + try { + const findByPathResult = await fetchProjectFindByPath( + queryClient, + project, + activeHostUrl, + ); + if (isProjectAlreadyImported(findByPathResult)) { + continue; + } + if (findByPathResult.candidates.length > 1) { + continue; + } + if ( + findByPathResult.candidates.length === 0 && + findByPathResult.cloudErrors.length > 0 + ) { + continue; + } + const result = await importProject({ + project, + activeHostUrl, + findByPathResult, + finalizeSetup, + }); + if (result.kind === "imported") { + await invalidateProjectImportQueries(queryClient, project); + } + } catch (err) { + console.error("[v1-import] project import all failed", { + v1ProjectId: project.id, + mainRepoPath: project.mainRepoPath, + organizationId, + err, + }); + } + } + } finally { + setImportAllProgress(null); + } + }; + + const headerAction = + projects.length > 0 ? ( + + ) : null; + return ( {projects.map((project) => ( { + const client = getHostServiceClientByUrl(activeHostUrl); + return client.project.findByPath.query({ + repoPath: project.mainRepoPath, + walkAllRemotes: true, + expectedRemoteUrl: expectedRemoteUrlFor(project), + }); + }; +} + +function fetchProjectFindByPath( + queryClient: QueryClient, + project: V1Project, + activeHostUrl: string, +) { + return queryClient.fetchQuery({ + queryKey: projectFindByPathQueryKey(project, activeHostUrl), + queryFn: projectFindByPathQueryFn(project, activeHostUrl), + retry: false, + }); +} + +function isProjectAlreadyImported( + findByPathResult: ProjectFindByPathResult | undefined, +) { + return !!findByPathResult?.candidates.find((c) => c.source === "local-path"); +} + +type FinalizeProjectSetup = ReturnType; + +async function importProject({ + project, + activeHostUrl, + findByPathResult, + finalizeSetup, + linkToProjectId, + allowRelocate = false, +}: { + project: V1Project; + activeHostUrl: string; + findByPathResult: ProjectFindByPathResult | undefined; + finalizeSetup: FinalizeProjectSetup; + linkToProjectId?: string; + allowRelocate?: boolean; +}): Promise< + | { kind: "imported"; v2ProjectId: string } + | { kind: "needs-relocate"; v2ProjectId: string; message: string } +> { + const client = getHostServiceClientByUrl(activeHostUrl); + const candidates = findByPathResult?.candidates ?? []; + + let v2ProjectId: string; + let mainWorkspaceId: string | null = null; + let repoPath = project.mainRepoPath; + + const targetCandidate = linkToProjectId + ? candidates.find((c) => c.id === linkToProjectId) + : candidates[0]; + + if (linkToProjectId && !targetCandidate) { + throw new Error( + "Selected v2 project is no longer in the candidate list. Refresh and pick again.", + ); + } + + if (targetCandidate) { + try { + const result = await client.project.setup.mutate({ + projectId: targetCandidate.id, + mode: { + kind: "import", + repoPath: project.mainRepoPath, + allowRelocate, + }, + }); + v2ProjectId = targetCandidate.id; + mainWorkspaceId = result.mainWorkspaceId; + repoPath = result.repoPath; + } catch (err) { + if (isAlreadySetUpElsewhereError(err) && !allowRelocate) { + return { + kind: "needs-relocate", + v2ProjectId: targetCandidate.id, + message: err instanceof Error ? err.message : String(err), + }; + } + throw err; + } + } else { + const result = await client.project.create.mutate({ + name: project.name, + mode: { kind: "importLocal", repoPath: project.mainRepoPath }, + }); + v2ProjectId = result.projectId; + mainWorkspaceId = result.mainWorkspaceId; + repoPath = result.repoPath; + } + + finalizeSetup(activeHostUrl, { + projectId: v2ProjectId, + repoPath, + mainWorkspaceId, + }); + + return { kind: "imported", v2ProjectId }; +} + +function invalidateProjectImportQueries( + queryClient: QueryClient, + project: V1Project, +) { + return Promise.all([ + queryClient.invalidateQueries({ + queryKey: [...FIND_BY_PATH_KEY_PREFIX, project.mainRepoPath], + }), + queryClient.invalidateQueries({ + queryKey: HOST_PROJECT_LIST_KEY_PREFIX, + }), + ]); +} + function ProjectRow({ project, organizationId, @@ -111,30 +330,14 @@ function ProjectRow({ } | null>(null); const [linkedV2Id, setLinkedV2Id] = useState(null); - const expectedRemoteUrl = expectedRemoteUrlFor(project); - const findByPathQuery = useQuery({ - queryKey: [ - ...FIND_BY_PATH_KEY_PREFIX, - project.mainRepoPath, - expectedRemoteUrl ?? "", - activeHostUrl, - ], - queryFn: async () => { - const client = getHostServiceClientByUrl(activeHostUrl); - return client.project.findByPath.query({ - repoPath: project.mainRepoPath, - walkAllRemotes: true, - expectedRemoteUrl, - }); - }, + queryKey: projectFindByPathQueryKey(project, activeHostUrl), + queryFn: projectFindByPathQueryFn(project, activeHostUrl), retry: false, }); - const importedCandidate = findByPathQuery.data?.candidates.find( - (c) => c.source === "local-path", - ); - const isImported = !!importedCandidate || !!linkedV2Id; + const isImported = + isProjectAlreadyImported(findByPathQuery.data) || !!linkedV2Id; const runImport = async ( linkToProjectId?: string, @@ -144,72 +347,26 @@ function ProjectRow({ setErrorMessage(null); setPendingRelocate(null); try { - const client = getHostServiceClientByUrl(activeHostUrl); - const candidates = findByPathQuery.data?.candidates ?? []; - - let v2ProjectId: string; - let mainWorkspaceId: string | null = null; - let repoPath = project.mainRepoPath; - - const targetCandidate = linkToProjectId - ? candidates.find((c) => c.id === linkToProjectId) - : candidates[0]; - - if (linkToProjectId && !targetCandidate) { - throw new Error( - "Selected v2 project is no longer in the candidate list. Refresh and pick again.", - ); - } + const result = await importProject({ + project, + activeHostUrl, + findByPathResult: findByPathQuery.data, + finalizeSetup, + linkToProjectId, + allowRelocate: options.allowRelocate ?? false, + }); - if (targetCandidate) { - try { - const result = await client.project.setup.mutate({ - projectId: targetCandidate.id, - mode: { - kind: "import", - repoPath: project.mainRepoPath, - allowRelocate: options.allowRelocate ?? false, - }, - }); - v2ProjectId = targetCandidate.id; - mainWorkspaceId = result.mainWorkspaceId; - repoPath = result.repoPath; - } catch (err) { - if (isAlreadySetUpElsewhereError(err) && !options.allowRelocate) { - setPendingRelocate({ - v2ProjectId: targetCandidate.id, - message: err instanceof Error ? err.message : String(err), - }); - setRunning(false); - return; - } - throw err; - } - } else { - const result = await client.project.create.mutate({ - name: project.name, - mode: { kind: "importLocal", repoPath: project.mainRepoPath }, + if (result.kind === "needs-relocate") { + setPendingRelocate({ + v2ProjectId: result.v2ProjectId, + message: result.message, }); - v2ProjectId = result.projectId; - mainWorkspaceId = result.mainWorkspaceId; - repoPath = result.repoPath; + setRunning(false); + return; } - finalizeSetup(activeHostUrl, { - projectId: v2ProjectId, - repoPath, - mainWorkspaceId, - }); - - setLinkedV2Id(v2ProjectId); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: [...FIND_BY_PATH_KEY_PREFIX, project.mainRepoPath], - }), - queryClient.invalidateQueries({ - queryKey: HOST_PROJECT_LIST_KEY_PREFIX, - }), - ]); + setLinkedV2Id(result.v2ProjectId); + await invalidateProjectImportQueries(queryClient, project); } catch (err) { const message = err instanceof Error ? err.message : String(err); setErrorMessage(message); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx index bd292fd0e90..dc460cc5dc9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/V1ImportModal.tsx @@ -15,6 +15,7 @@ import { } from "renderer/stores/v1-import-modal"; import { MOCK_ORG_ID } from "shared/constants"; import { IntroPage } from "./components/IntroPage"; +import { StepProgress } from "./components/StepProgress"; import { WelcomePage } from "./components/WelcomePage"; import { ImportPresetsPage } from "./ImportPresetsPage"; import { ImportProjectsPage } from "./ImportProjectsPage"; @@ -46,6 +47,7 @@ export function V1ImportModal() { > event.preventDefault()} onPointerDownOutside={(event) => event.preventDefault()} onInteractOutside={(event) => event.preventDefault()} @@ -91,36 +93,48 @@ export function V1ImportModal() { )} -
- {previousPage ? ( - - ) : ( -
- )} - {nextPage ? ( - - ) : ( - - )} +
+ + +
+ {previousPage && ( + + )} + {nextPage ? ( + + ) : ( + + )} +
diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/ImportPageShell/ImportPageShell.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/ImportPageShell/ImportPageShell.tsx index 34920d61d84..aadc510f096 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/ImportPageShell/ImportPageShell.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/ImportPageShell/ImportPageShell.tsx @@ -28,7 +28,7 @@ export function ImportPageShell({ }: ImportPageShellProps) { return (
-
+
{title} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/ImportRow/ImportRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/ImportRow/ImportRow.tsx index c58a18ef8d1..601206c14a1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/ImportRow/ImportRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/ImportRow/ImportRow.tsx @@ -52,22 +52,22 @@ export function ImportRow({ action, }: ImportRowProps) { return ( -
+
{icon && ( -
+
{icon}
)} -
+
{primary} {secondary && ( {secondary} @@ -75,7 +75,7 @@ export function ImportRow({ )} {action.kind === "error" && ( {action.message} @@ -83,19 +83,21 @@ export function ImportRow({ )} {action.kind === "blocked" && ( {action.reason} )} {action.kind === "confirm" && ( - + {action.message} )}
- +
+ +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/StepProgress/StepProgress.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/StepProgress/StepProgress.test.tsx new file mode 100644 index 00000000000..d9b48810c94 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/StepProgress/StepProgress.test.tsx @@ -0,0 +1,28 @@ +import { describe, expect, it } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { getStepProgress, StepProgress } from "./StepProgress"; + +describe("StepProgress", () => { + it("converts a zero-based page index into user-facing progress", () => { + expect(getStepProgress({ currentIndex: 2, totalSteps: 5 })).toEqual({ + currentStep: 3, + totalSteps: 5, + percent: 60, + label: "Step 3 of 5", + }); + }); + + it("renders an accessible step progress bar", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Step 3 of 5"); + expect(markup).toContain('role="progressbar"'); + expect(markup).toContain('aria-label="Step 3 of 5"'); + expect(markup).toContain('aria-valuenow="3"'); + expect(markup).toContain('aria-valuemax="5"'); + expect(markup).toContain("-top-px"); + expect(markup).toContain("h-px"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/StepProgress/StepProgress.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/StepProgress/StepProgress.tsx new file mode 100644 index 00000000000..f02fdf54c65 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/StepProgress/StepProgress.tsx @@ -0,0 +1,43 @@ +interface StepProgressProps { + currentIndex: number; + totalSteps: number; +} + +export function getStepProgress({ + currentIndex, + totalSteps, +}: StepProgressProps) { + const safeTotalSteps = Math.max(totalSteps, 1); + const safeCurrentIndex = Math.min( + Math.max(currentIndex, 0), + safeTotalSteps - 1, + ); + const currentStep = safeCurrentIndex + 1; + + return { + currentStep, + totalSteps: safeTotalSteps, + percent: (currentStep / safeTotalSteps) * 100, + label: `Step ${currentStep} of ${safeTotalSteps}`, + }; +} + +export function StepProgress(props: StepProgressProps) { + const progress = getStepProgress(props); + + return ( +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/StepProgress/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/StepProgress/index.ts new file mode 100644 index 00000000000..be1aee8ce71 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/components/StepProgress/index.ts @@ -0,0 +1 @@ +export { getStepProgress, StepProgress } from "./StepProgress";