diff --git a/Cargo.lock b/Cargo.lock index e3f6dbff..f1313a57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11225,6 +11225,7 @@ dependencies = [ "base64 0.22.1", "log 0.4.29", "md5 0.8.0", + "rusqlite", "serde_json", "tempfile", "thiserror 2.0.17", diff --git a/apps/yaak-client/components/ImportDataDialog.tsx b/apps/yaak-client/components/ImportDataDialog.tsx index 01e098e6..6db9b222 100644 --- a/apps/yaak-client/components/ImportDataDialog.tsx +++ b/apps/yaak-client/components/ImportDataDialog.tsx @@ -1,17 +1,37 @@ +import { + type Folder, + type ImportDestination, + type ImportPlan, + modelTypeLabel, + type Workspace, +} from "@yaakapp-internal/models"; +import { HStack, Icon, VStack } from "@yaakapp-internal/ui"; import { platform } from "@yaakapp-internal/platform"; -import { Icon, VStack } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useEffect, useRef, useState } from "react"; import { useLocalStorage } from "react-use"; +import { pluralizeCount } from "../lib/pluralize"; import { CommercialUseBanner } from "./CommercialUseBanner"; import { Button } from "./core/Button"; +import { Checkbox } from "./core/Checkbox"; import { PlainInput } from "./core/PlainInput"; +import { Select } from "./core/Select"; interface Props { - importFile: (filePath: string) => Promise; - importUrl: (url: string) => Promise; + currentWorkspace: Workspace | null; + workspaces: Workspace[]; + selectedFolder: Folder | null; + planFile: (filePath: string, destination: ImportDestination) => Promise; + planUrl: (url: string, destination: ImportDestination) => Promise; + commit: (plan: ImportPlan) => Promise; + cancel: () => void; + onError: (err: unknown) => void; } +/** Sentinel for the "create a new workspace" option. Workspace IDs are prefixed `wk_`, so this + * can never collide with a real one. */ +const NEW_WORKSPACE = "new_workspace"; + /** * An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a * bare host like `example.com/openapi.json` still works (the backend defaults it to https). @@ -31,8 +51,20 @@ function fileName(path: string): string { return path.split(/[/\\]/).at(-1) || path; } -export function ImportDataDialog({ importFile, importUrl }: Props) { +export function ImportDataDialog({ + currentWorkspace, + workspaces, + selectedFolder, + planFile, + planUrl, + commit, + cancel, + onError, +}: Props) { const [isLoading, setIsLoading] = useState(false); + const [plan, setPlan] = useState(null); + const [destinationId, setDestinationId] = useState(NEW_WORKSPACE); + const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null); // A file path or a URL. Both inputs write here, so there is only ever one thing to import const [source, setSource] = useLocalStorage("importPathOrUrl", null); const [forceUpdateKey, setForceUpdateKey] = useState(0); @@ -71,19 +103,117 @@ export function ImportDataDialog({ importFile, importUrl }: Props) { selectSource(selected); }; - const handleImport = async () => { + // The selected folder belongs to the workspace being viewed, so it is only offerable when that + // is also the destination. + const canTargetSelectedFolder = + selectedFolder != null && destinationId === currentWorkspace?.id; + + const destination = (): ImportDestination => { + if (destinationId === NEW_WORKSPACE) { + return { type: "new_workspace" }; + } + return { + type: "existing_workspace", + workspaceId: destinationId, + folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined, + }; + }; + + const handlePreview = async () => { setIsLoading(true); try { - if (filePath != null) { - await importFile(filePath); - } else { - await importUrl(trimmedSource); - } + const nextPlan = + filePath != null + ? await planFile(filePath, destination()) + : await planUrl(trimmedSource, destination()); + setPlan(nextPlan); + } catch (err) { + onError(err); } finally { setIsLoading(false); } }; + const handleCommit = async () => { + if (plan == null) return; + setIsLoading(true); + try { + await commit(plan); + } catch (err) { + onError(err); + } finally { + setIsLoading(false); + } + }; + + if (plan != null) { + const counts = [ + [plan.resources.workspaces[0], plan.resources.workspaces.length], + [plan.resources.environments[0], plan.resources.environments.length], + [plan.resources.folders[0], plan.resources.folders.length], + [plan.resources.httpRequests[0], plan.resources.httpRequests.length], + [plan.resources.grpcRequests[0], plan.resources.grpcRequests.length], + [plan.resources.websocketRequests[0], plan.resources.websocketRequests.length], + ] as const; + const destinationLabel = (() => { + if (plan.destination.type === "new_workspace") return "New workspace"; + const { workspaceId, folderId } = plan.destination; + const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace"; + return folderId != null && folderId === selectedFolder?.id + ? `${name} / ${selectedFolder.name}` + : name; + })(); + + return ( + +
+ + +
+ +
+
Resources
+
    + {counts.map(([model, count]) => + model == null ? null : ( +
  • {pluralizeCount(modelTypeLabel(model), count)}
  • + ), + )} +
+
+ + {plan.warnings.length > 0 && ( +
+
Import details
+
+ {plan.warnings.map((warning) => ( +
+ +
+
{warning.title}
+
{warning.detail}
+
+
+ ))} +
+
+ )} + + + + + +
+ ); + } + return ( @@ -115,25 +245,66 @@ export function ImportDataDialog({ importFile, importUrl }: Props) { + + - 0 + ? [{ type: "separator" as const, label: "Existing Workspaces" }] + : []), + ...workspaces.map((w) => ({ + value: w.id, + label: w.id === currentWorkspace?.id ? `${w.name} (current workspace)` : w.name, + })), + ]} /> + {canTargetSelectedFolder && ( + + )} + + + + - + ); } + +function PreviewRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/apps/yaak-client/components/core/Dialog.tsx b/apps/yaak-client/components/core/Dialog.tsx index e74b7a3d..c7962a1e 100644 --- a/apps/yaak-client/components/core/Dialog.tsx +++ b/apps/yaak-client/components/core/Dialog.tsx @@ -10,11 +10,13 @@ export interface DialogProps { children: ReactNode; open: boolean; onClose?: () => void; - disableBackdropClose?: boolean; + /** Block dismissal from the backdrop, Escape key, and built-in close button. */ + disableClose?: boolean; title?: ReactNode; description?: ReactNode; className?: string; size?: DialogSize; + /** Hide the built-in close button without changing backdrop or Escape behavior. */ hideX?: boolean; noPadding?: boolean; noScroll?: boolean; @@ -27,7 +29,7 @@ export function Dialog({ size = "full", open, onClose, - disableBackdropClose, + disableClose, title, description, hideX, @@ -42,7 +44,7 @@ export function Dialog({ ); return ( - +
{/*Put close at the end so that it's the last thing to be tabbed to*/} - {!hideX && ( + {!disableClose && !hideX && (
({ )} >