mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-16 14:51:37 +02:00
Replace the default workspace with a home screen (#660)
This commit is contained in:
@@ -447,7 +447,7 @@ function LoadedImportDataDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text-subtlest">
|
<div className="text-xs text-text-subtlest">
|
||||||
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
|
Supports OpenAPI, Swagger, Postman, Insomnia, curl, and Yaak exports
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -460,7 +460,7 @@ function LoadedImportDataDialog({
|
|||||||
onChange={setSource}
|
onChange={setSource}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VStack space={2}>
|
<VStack space={2} className={classNames(workspaces.length === 0 && "hidden")}>
|
||||||
<Select
|
<Select
|
||||||
name="import-destination-kind"
|
name="import-destination-kind"
|
||||||
label="Import location"
|
label="Import location"
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import type { Color } from "@yaakapp-internal/plugins";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
|
import { HeaderSize, Heading, Icon, type IconProps, LoadingIcon } from "@yaakapp-internal/ui";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||||
|
import { showDialog } from "../lib/dialog";
|
||||||
|
import { importData } from "../lib/importData";
|
||||||
|
import {
|
||||||
|
createExampleWorkspace,
|
||||||
|
createFreshWorkspace,
|
||||||
|
type OnboardingChoice,
|
||||||
|
recordOnboardingChoice,
|
||||||
|
} from "../lib/onboarding";
|
||||||
|
import { showErrorToast } from "../lib/toast";
|
||||||
|
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
|
||||||
|
import { Button } from "./core/Button";
|
||||||
|
|
||||||
|
/** Shown instead of a workspace when there are none. */
|
||||||
|
export function Onboarding() {
|
||||||
|
const settings = useAtomValue(settingsAtom);
|
||||||
|
const [busy, setBusy] = useState<OnboardingChoice | null>(null);
|
||||||
|
|
||||||
|
const choose = (choice: OnboardingChoice, run: () => Promise<void> | void) => async () => {
|
||||||
|
recordOnboardingChoice(choice);
|
||||||
|
setBusy(choice);
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} catch (err) {
|
||||||
|
showErrorToast({
|
||||||
|
id: "onboarding-failed",
|
||||||
|
title: "Something went wrong",
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
||||||
|
<HeaderSize
|
||||||
|
data-tauri-drag-region
|
||||||
|
size="lg"
|
||||||
|
className="x-theme-appHeader bg-surface"
|
||||||
|
osType={platform.osType()}
|
||||||
|
hideWindowControls={settings.hideWindowControls}
|
||||||
|
useNativeTitlebar={settings.useNativeTitlebar}
|
||||||
|
interfaceScale={settings.interfaceScale}
|
||||||
|
/>
|
||||||
|
<div className="overflow-auto px-6 py-10 grid">
|
||||||
|
<div className="m-auto w-full max-w-lg flex flex-col gap-7">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Heading>How would you like to get started?</Heading>
|
||||||
|
<p className="text-text-subtle">
|
||||||
|
Bring over existing work, try a real API, or start fresh.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<StartOption
|
||||||
|
color="primary"
|
||||||
|
icon="folder_input"
|
||||||
|
title="Migrate from another tool"
|
||||||
|
description="Postman, Insomnia, OpenAPI, or curl"
|
||||||
|
busy={busy === "import"}
|
||||||
|
disabled={busy != null}
|
||||||
|
onClick={choose("import", () => importData.mutateAsync())}
|
||||||
|
/>
|
||||||
|
<StartOption
|
||||||
|
color="info"
|
||||||
|
icon="flask"
|
||||||
|
title="Try Yaak with a real API"
|
||||||
|
description="Ready-made requests you can send right away"
|
||||||
|
busy={busy === "example"}
|
||||||
|
disabled={busy != null}
|
||||||
|
onClick={choose("example", createExampleWorkspace)}
|
||||||
|
/>
|
||||||
|
<StartOption
|
||||||
|
color="success"
|
||||||
|
icon="plus"
|
||||||
|
title="Start fresh"
|
||||||
|
description="An empty workspace for your first request"
|
||||||
|
busy={busy === "fresh"}
|
||||||
|
disabled={busy != null}
|
||||||
|
onClick={choose("fresh", createFreshWorkspace)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-5 border-t border-dashed border-border-subtle flex flex-col items-start gap-2.5">
|
||||||
|
<span className="text-sm text-text-subtle">Already using Yaak?</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="border"
|
||||||
|
color="secondary"
|
||||||
|
leftSlot={<Icon icon="folder_open" size="sm" />}
|
||||||
|
disabled={busy != null}
|
||||||
|
onClick={choose("open_folder", async () => {
|
||||||
|
const dir = await platform.dialog.open({
|
||||||
|
title: "Select Workspace Directory",
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
if (dir == null) return;
|
||||||
|
await openWorkspaceFromSyncDir.mutateAsync(dir);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Open folder
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="border"
|
||||||
|
color="secondary"
|
||||||
|
leftSlot={<Icon icon="git_branch" size="sm" />}
|
||||||
|
disabled={busy != null}
|
||||||
|
onClick={choose("clone_git", () => {
|
||||||
|
showDialog({
|
||||||
|
id: "clone-git-repository",
|
||||||
|
size: "md",
|
||||||
|
title: "Clone Git Repository",
|
||||||
|
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Clone repository
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="border"
|
||||||
|
color="secondary"
|
||||||
|
leftSlot={<Icon icon="import" size="sm" />}
|
||||||
|
disabled={busy != null}
|
||||||
|
onClick={choose("import_yaak", () => importData.mutateAsync())}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StartOption({
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onClick,
|
||||||
|
busy,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
color: Color;
|
||||||
|
icon: IconProps["icon"];
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
onClick: () => void;
|
||||||
|
busy: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className={classNames(
|
||||||
|
"group w-full text-left rounded-lg px-3 py-2.5 border border-border-subtle",
|
||||||
|
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3.5",
|
||||||
|
"enabled:hocus:bg-surface-highlight/50 enabled:hocus:border-border",
|
||||||
|
"outline-border-focus focus-visible:outline-2",
|
||||||
|
disabled && !busy && "opacity-disabled",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* The banner theme tints this tile from the theme's own color for each option */}
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
`x-theme-banner--${color}`,
|
||||||
|
"size-10 rounded-md bg-surface-highlight grid place-items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{busy ? <LoadingIcon size="sm" /> : <Icon icon={icon} color={color} size="md" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold text-text">{title}</div>
|
||||||
|
<div className="text-sm text-text-subtle">{description}</div>
|
||||||
|
</div>
|
||||||
|
<Icon
|
||||||
|
icon="chevron_right"
|
||||||
|
className="text-text-subtlest group-enabled:group-hover:text-text-subtle group-enabled:group-focus-visible:text-text-subtle"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { getRecentRequests } from "../hooks/useRecentRequests";
|
|||||||
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
|
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
|
||||||
import { fireAndForget } from "../lib/fireAndForget";
|
import { fireAndForget } from "../lib/fireAndForget";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
|
import { Onboarding } from "./Onboarding";
|
||||||
|
|
||||||
export function RedirectToLatestWorkspace() {
|
export function RedirectToLatestWorkspace() {
|
||||||
const workspaces = useAtomValue(workspacesAtom);
|
const workspaces = useAtomValue(workspacesAtom);
|
||||||
@@ -14,10 +15,6 @@ export function RedirectToLatestWorkspace() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workspaces.length === 0 || recentWorkspaces == null) {
|
if (workspaces.length === 0 || recentWorkspaces == null) {
|
||||||
console.log("No workspaces found to redirect to. Skipping.", {
|
|
||||||
workspaces,
|
|
||||||
recentWorkspaces,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,5 +37,11 @@ export function RedirectToLatestWorkspace() {
|
|||||||
);
|
);
|
||||||
}, [recentWorkspaces, workspaces, workspaces.length]);
|
}, [recentWorkspaces, workspaces, workspaces.length]);
|
||||||
|
|
||||||
|
// The global models are loaded before the router mounts, so an empty list is a real
|
||||||
|
// first launch (or the last workspace was just deleted), not a store still loading
|
||||||
|
if (workspaces.length === 0) {
|
||||||
|
return <Onboarding />;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { ErrorBoundary } from "./ErrorBoundary";
|
|||||||
import { FolderLayout } from "./FolderLayout";
|
import { FolderLayout } from "./FolderLayout";
|
||||||
import { GrpcConnectionLayout } from "./GrpcConnectionLayout";
|
import { GrpcConnectionLayout } from "./GrpcConnectionLayout";
|
||||||
import { HttpRequestLayout } from "./HttpRequestLayout";
|
import { HttpRequestLayout } from "./HttpRequestLayout";
|
||||||
|
import { RedirectToLatestWorkspace } from "./RedirectToLatestWorkspace";
|
||||||
import Sidebar from "./Sidebar";
|
import Sidebar from "./Sidebar";
|
||||||
import { SidebarActions } from "./SidebarActions";
|
import { SidebarActions } from "./SidebarActions";
|
||||||
import { WebsocketRequestLayout } from "./WebsocketRequestLayout";
|
import { WebsocketRequestLayout } from "./WebsocketRequestLayout";
|
||||||
@@ -66,9 +67,8 @@ export function Workspace() {
|
|||||||
return { background };
|
return { background };
|
||||||
}, [activeEnvironment?.color]);
|
}, [activeEnvironment?.color]);
|
||||||
|
|
||||||
// We're loading still
|
|
||||||
if (workspaces.length === 0) {
|
if (workspaces.length === 0) {
|
||||||
return null;
|
return <RedirectToLatestWorkspace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const header = (
|
const header = (
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { BatchUpsertResult } from "@yaakapp-internal/models";
|
||||||
|
import { createGlobalModel } from "@yaakapp-internal/models";
|
||||||
|
import { router } from "./router";
|
||||||
|
import { rpc } from "./rpc";
|
||||||
|
import { setKeyValue } from "./keyValueStore";
|
||||||
|
|
||||||
|
export type OnboardingChoice =
|
||||||
|
| "import"
|
||||||
|
| "import_yaak"
|
||||||
|
| "example"
|
||||||
|
| "fresh"
|
||||||
|
| "open_folder"
|
||||||
|
| "clone_git";
|
||||||
|
|
||||||
|
const NEW_WORKSPACE_NAME = "My Workspace";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remembered so later surfaces can lean toward what the user came for (an importer, the
|
||||||
|
* example, or a blank slate). Nothing reads it yet.
|
||||||
|
*/
|
||||||
|
export function recordOnboardingChoice(choice: OnboardingChoice) {
|
||||||
|
setKeyValue({
|
||||||
|
namespace: "global",
|
||||||
|
key: "onboarding",
|
||||||
|
value: { choice, at: new Date().toISOString() },
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFreshWorkspace() {
|
||||||
|
const workspaceId = await createGlobalModel({ model: "workspace", name: NEW_WORKSPACE_NAME });
|
||||||
|
await router.navigate({ to: "/workspaces/$workspaceId", params: { workspaceId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createExampleWorkspace() {
|
||||||
|
const created = await rpc<BatchUpsertResult>("cmd_create_example_workspace", {});
|
||||||
|
const workspace = created.workspaces[0];
|
||||||
|
if (workspace == null) throw new Error("Example workspace was not created");
|
||||||
|
const firstRequest = created.httpRequests.find((r) => r.name === "List posts");
|
||||||
|
await router.navigate({
|
||||||
|
to: "/workspaces/$workspaceId",
|
||||||
|
params: { workspaceId: workspace.id },
|
||||||
|
search: firstRequest ? { request_id: firstRequest.id } : {},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -671,6 +671,13 @@ async fn cmd_export_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdExportDataReq) -
|
|||||||
Ok(yaak_commands::data::cmd_export_data(ctx, req).await?)
|
Ok(yaak_commands::data::cmd_export_data(ctx, req).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn cmd_create_example_workspace<R: Runtime>(
|
||||||
|
ctx: ClientCtx<R>,
|
||||||
|
req: CmdCreateExampleWorkspaceReq,
|
||||||
|
) -> Result<BatchUpsertResult> {
|
||||||
|
Ok(yaak_commands::data::cmd_create_example_workspace(ctx, req).await?)
|
||||||
|
}
|
||||||
|
|
||||||
async fn cmd_save_base64_to_binary<R: Runtime>(
|
async fn cmd_save_base64_to_binary<R: Runtime>(
|
||||||
ctx: ClientCtx<R>,
|
ctx: ClientCtx<R>,
|
||||||
req: CmdSaveBase64ToBinaryReq,
|
req: CmdSaveBase64ToBinaryReq,
|
||||||
|
|||||||
+3
-1
File diff suppressed because one or more lines are too long
@@ -366,6 +366,10 @@ pub struct CmdCurlToRequestReq {
|
|||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, TS)]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
pub struct CmdCreateExampleWorkspaceReq {}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
#[derive(Debug, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
@@ -957,6 +961,7 @@ macro_rules! with_commands {
|
|||||||
cmd_call_http_authentication_action(CmdCallHttpAuthenticationActionReq) -> (),
|
cmd_call_http_authentication_action(CmdCallHttpAuthenticationActionReq) -> (),
|
||||||
cmd_curl_to_request(CmdCurlToRequestReq) -> HttpRequest,
|
cmd_curl_to_request(CmdCurlToRequestReq) -> HttpRequest,
|
||||||
cmd_export_data(CmdExportDataReq) -> (),
|
cmd_export_data(CmdExportDataReq) -> (),
|
||||||
|
cmd_create_example_workspace(CmdCreateExampleWorkspaceReq) -> BatchUpsertResult,
|
||||||
cmd_save_base64_to_binary(CmdSaveBase64ToBinaryReq) -> (),
|
cmd_save_base64_to_binary(CmdSaveBase64ToBinaryReq) -> (),
|
||||||
cmd_save_response(CmdSaveResponseReq) -> (),
|
cmd_save_response(CmdSaveResponseReq) -> (),
|
||||||
cmd_send_http_request(CmdSendHttpRequestReq) -> HttpResponse,
|
cmd_send_http_request(CmdSendHttpRequestReq) -> HttpResponse,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::host::Host;
|
use crate::host::Host;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use yaak::example::create_example_workspace;
|
||||||
use yaak::export::{self, ExportDataParams};
|
use yaak::export::{self, ExportDataParams};
|
||||||
|
use yaak_models::util::BatchUpsertResult;
|
||||||
use yaak_rpc_schema::*;
|
use yaak_rpc_schema::*;
|
||||||
use yaak_templates::format_json::format_json;
|
use yaak_templates::format_json::format_json;
|
||||||
|
|
||||||
@@ -18,6 +20,13 @@ pub async fn cmd_export_data<H: Host>(host: H, req: CmdExportDataReq) -> Result<
|
|||||||
})?)
|
})?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn cmd_create_example_workspace<H: Host>(
|
||||||
|
host: H,
|
||||||
|
_req: CmdCreateExampleWorkspaceReq,
|
||||||
|
) -> Result<BatchUpsertResult> {
|
||||||
|
Ok(create_example_workspace(host.query_manager(), &host.update_source())?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn cmd_format_json<H: Host>(_host: H, req: CmdFormatJsonReq) -> Result<String> {
|
pub async fn cmd_format_json<H: Host>(_host: H, req: CmdFormatJsonReq) -> Result<String> {
|
||||||
Ok(format_json(&req.text, " "))
|
Ok(format_json(&req.text, " "))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use sea_query::{IntoColumnRef, IntoIden, SimpleExpr};
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
use std::sync::mpsc;
|
|
||||||
use yaak_database::DbContext;
|
use yaak_database::DbContext;
|
||||||
|
|
||||||
/// A read handle. Comes from the reader pool and can only query.
|
/// A read handle. Comes from the reader pool and can only query.
|
||||||
@@ -80,7 +79,6 @@ impl<'a> ClientDb<'a> {
|
|||||||
/// discards them along with the rows.
|
/// discards them along with the rows.
|
||||||
pub struct WriteDb<'a> {
|
pub struct WriteDb<'a> {
|
||||||
db: ClientDb<'a>,
|
db: ClientDb<'a>,
|
||||||
events_tx: mpsc::Sender<ModelPayload>,
|
|
||||||
pending_events: RefCell<Vec<ModelPayload>>,
|
pending_events: RefCell<Vec<ModelPayload>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +91,8 @@ impl<'a> Deref for WriteDb<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> WriteDb<'a> {
|
impl<'a> WriteDb<'a> {
|
||||||
pub fn new(ctx: DbContext<'a>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
|
pub fn new(ctx: DbContext<'a>) -> Self {
|
||||||
Self { db: ClientDb::new(ctx), events_tx, pending_events: RefCell::new(Vec::new()) }
|
Self { db: ClientDb::new(ctx), pending_events: RefCell::new(Vec::new()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The events for everything written so far, to send once the
|
/// The events for everything written so far, to send once the
|
||||||
|
|||||||
@@ -171,11 +171,11 @@ pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<Mod
|
|||||||
Ok((query_manager, blob_manager, rx))
|
Ok((query_manager, blob_manager, rx))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The rows every client assumes exist: settings and at least one workspace.
|
/// The rows every client assumes exist. Workspaces are not among them: a fresh
|
||||||
|
/// install has none, and the client shows onboarding until the user makes one.
|
||||||
fn bootstrap(query_manager: &QueryManager) -> Result<()> {
|
fn bootstrap(query_manager: &QueryManager) -> Result<()> {
|
||||||
query_manager.with_tx(|tx| {
|
query_manager.with_tx(|tx| {
|
||||||
tx.ensure_settings()?;
|
tx.ensure_settings()?;
|
||||||
tx.ensure_default_workspace()?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,8 +192,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn request_resolution_preserves_duplicate_request_headers() {
|
fn request_resolution_preserves_duplicate_request_headers() {
|
||||||
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||||
|
let workspace = query_manager
|
||||||
|
.with_tx(|tx| tx.upsert_workspace(&Workspace::default(), &UpdateSource::Background))
|
||||||
|
.expect("Failed to create workspace");
|
||||||
let db = query_manager.connect();
|
let db = query_manager.connect();
|
||||||
let workspace = db.list_workspaces().expect("Failed to list workspaces").remove(0);
|
|
||||||
let request = HttpRequest {
|
let request = HttpRequest {
|
||||||
workspace_id: workspace.id,
|
workspace_id: workspace.id,
|
||||||
headers: vec![
|
headers: vec![
|
||||||
|
|||||||
@@ -79,18 +79,6 @@ impl<'a> ClientDb<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> WriteDb<'a> {
|
impl<'a> WriteDb<'a> {
|
||||||
/// There is always at least one workspace. Called at startup and after a
|
|
||||||
/// workspace is deleted.
|
|
||||||
pub fn ensure_default_workspace(&self) -> Result<()> {
|
|
||||||
if self.find_all::<Workspace>()?.is_empty() {
|
|
||||||
self.upsert_workspace(
|
|
||||||
&Workspace { name: "Yaak".to_string(), ..Default::default() },
|
|
||||||
&UpdateSource::Background,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a workspace and everything in it.
|
/// Delete a workspace and everything in it.
|
||||||
///
|
///
|
||||||
/// Children are bulk-deleted with one statement per table and are NOT
|
/// Children are bulk-deleted with one statement per table and are NOT
|
||||||
@@ -138,7 +126,6 @@ impl<'a> WriteDb<'a> {
|
|||||||
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
|
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
|
||||||
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
|
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
|
||||||
let deleted = self.delete(workspace, source)?;
|
let deleted = self.delete(workspace, source)?;
|
||||||
self.ensure_default_workspace()?;
|
|
||||||
|
|
||||||
// Best-effort cleanup of response bodies (disk files and blob chunks).
|
// Best-effort cleanup of response bodies (disk files and blob chunks).
|
||||||
// Failures only orphan unreferenced data, and are logged.
|
// Failures only orphan unreferenced data, and are logged.
|
||||||
@@ -193,18 +180,32 @@ pub fn default_headers() -> Vec<HttpRequestHeader> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::init_in_memory;
|
use crate::init_in_memory;
|
||||||
|
use crate::models::Workspace;
|
||||||
|
use crate::util::UpdateSource;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bootstraps_first_workspace_with_real_defaults() {
|
fn fresh_install_has_no_workspaces() {
|
||||||
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||||
let db = query_manager.connect();
|
let workspaces = query_manager.connect().list_workspaces().expect("Failed to list");
|
||||||
|
assert!(workspaces.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
let workspaces = db.list_workspaces().expect("Failed to list workspaces");
|
#[test]
|
||||||
let workspace = workspaces.first().expect("No workspace was bootstrapped");
|
fn default_workspace_carries_real_defaults() {
|
||||||
|
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||||
|
let created = query_manager
|
||||||
|
.with_tx(|tx| {
|
||||||
|
tx.upsert_workspace(
|
||||||
|
&Workspace { name: "Yaak".to_string(), ..Default::default() },
|
||||||
|
&UpdateSource::Background,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.expect("Failed to create workspace");
|
||||||
|
let workspace = query_manager.connect().get_workspace(&created.id).expect("get");
|
||||||
|
|
||||||
// This workspace is built in Rust and never deserialized, so it only gets
|
// A workspace built in Rust and never deserialized only gets these values
|
||||||
// these values if `Workspace::default()` carries them. Asserted through the
|
// if `Workspace::default()` carries them. Asserted through the DB round
|
||||||
// DB round trip, since the column values are what a fresh install lives with.
|
// trip, since the column values are what the user lives with.
|
||||||
assert!(workspace.setting_send_cookies, "setting_send_cookies");
|
assert!(workspace.setting_send_cookies, "setting_send_cookies");
|
||||||
assert!(workspace.setting_store_cookies, "setting_store_cookies");
|
assert!(workspace.setting_store_cookies, "setting_store_cookies");
|
||||||
assert!(workspace.setting_follow_redirects, "setting_follow_redirects");
|
assert!(workspace.setting_follow_redirects, "setting_follow_redirects");
|
||||||
|
|||||||
@@ -62,8 +62,7 @@ impl QueryManager {
|
|||||||
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
|
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
|
||||||
.map_err(crate::error::Error::SqlError)?;
|
.map_err(crate::error::Error::SqlError)?;
|
||||||
|
|
||||||
let db =
|
let db = WriteDb::new(DbContext::new(ConnectionOrTx::Transaction(&tx)));
|
||||||
WriteDb::new(DbContext::new(ConnectionOrTx::Transaction(&tx)), self.events_tx.clone());
|
|
||||||
|
|
||||||
match func(&db) {
|
match func(&db) {
|
||||||
Ok(val) => {
|
Ok(val) => {
|
||||||
|
|||||||
@@ -247,8 +247,8 @@ fn dispatch(
|
|||||||
source: &UpdateSource,
|
source: &UpdateSource,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
match cmd {
|
match cmd {
|
||||||
// The one big read. Same list, same order, and the same four lazy
|
// The one big read. Same list, same order, and the same lazy
|
||||||
// creates (settings, first workspace, cookie jar, base environment) as
|
// creates (settings, cookie jar, base environment) as
|
||||||
// `models_workspace_models` on the desktop — this call is where an
|
// `models_workspace_models` on the desktop — this call is where an
|
||||||
// empty database becomes a usable one. Returned as a JSON *string*
|
// empty database becomes a usable one. Returned as a JSON *string*
|
||||||
// because that is what the desktop returns and what the store parses.
|
// because that is what the desktop returns and what the store parses.
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
//! The example workspace offered during onboarding.
|
||||||
|
//!
|
||||||
|
//! Authored as JSON with `{{PLACEHOLDER}}` ids so requests can reference each
|
||||||
|
//! other (the chaining example names another request by id). Every placeholder
|
||||||
|
//! gets a fresh id per creation, so the example can be created more than once.
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
|
use yaak_models::models::{Environment, Folder, HttpRequest, UpsertModelInfo, Workspace};
|
||||||
|
use yaak_models::query_manager::QueryManager;
|
||||||
|
use yaak_models::util::{BatchUpsertResult, UpdateSource};
|
||||||
|
|
||||||
|
const EXAMPLE_JSON: &str = include_str!("example_workspace.json");
|
||||||
|
|
||||||
|
const WORKSPACE_IDS: &[&str] = &["WORKSPACE"];
|
||||||
|
const ENVIRONMENT_IDS: &[&str] = &["ENV_BASE", "ENV_USER_2"];
|
||||||
|
const FOLDER_IDS: &[&str] = &[
|
||||||
|
"FOLDER_BASICS",
|
||||||
|
"FOLDER_VARIABLES",
|
||||||
|
"FOLDER_CHAINING",
|
||||||
|
"FOLDER_AUTH",
|
||||||
|
];
|
||||||
|
const REQUEST_IDS: &[&str] = &[
|
||||||
|
"RQ_LIST_POSTS",
|
||||||
|
"RQ_GET_POST",
|
||||||
|
"RQ_CREATE_POST",
|
||||||
|
"RQ_CURRENT_USER",
|
||||||
|
"RQ_LIST_USERS",
|
||||||
|
"RQ_POSTS_BY_FIRST_USER",
|
||||||
|
"RQ_TODOS",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn create_example_workspace(
|
||||||
|
query_manager: &QueryManager,
|
||||||
|
source: &UpdateSource,
|
||||||
|
) -> Result<BatchUpsertResult> {
|
||||||
|
let resources = example_resources()?;
|
||||||
|
Ok(query_manager.with_tx(|tx| {
|
||||||
|
tx.batch_upsert(
|
||||||
|
resources.workspaces,
|
||||||
|
resources.environments,
|
||||||
|
resources.folders,
|
||||||
|
resources.http_requests,
|
||||||
|
resources.grpc_requests,
|
||||||
|
resources.websocket_requests,
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn example_resources() -> Result<BatchUpsertResult> {
|
||||||
|
let mut json = EXAMPLE_JSON.to_string();
|
||||||
|
let placeholders = WORKSPACE_IDS
|
||||||
|
.iter()
|
||||||
|
.map(|p| (*p, Workspace::generate_id()))
|
||||||
|
.chain(ENVIRONMENT_IDS.iter().map(|p| (*p, Environment::generate_id())))
|
||||||
|
.chain(FOLDER_IDS.iter().map(|p| (*p, Folder::generate_id())))
|
||||||
|
.chain(REQUEST_IDS.iter().map(|p| (*p, HttpRequest::generate_id())));
|
||||||
|
for (placeholder, id) in placeholders {
|
||||||
|
json = json.replace(&format!("{{{{{placeholder}}}}}"), &id);
|
||||||
|
}
|
||||||
|
debug_assert!(!json.contains("{{"), "example workspace has an unmapped placeholder");
|
||||||
|
Ok(serde_json::from_str(&json)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use yaak_models::init_in_memory;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_placeholder_is_mapped() {
|
||||||
|
let json = serde_json::to_string(&example_resources().unwrap()).unwrap();
|
||||||
|
assert!(!json.contains("{{"), "unmapped placeholder in {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chained_request_references_a_created_request() {
|
||||||
|
let (query_manager, _blobs, _rx) = init_in_memory().unwrap();
|
||||||
|
let created = create_example_workspace(&query_manager, &UpdateSource::Background).unwrap();
|
||||||
|
assert_eq!(created.workspaces.len(), 1);
|
||||||
|
let workspace_id = &created.workspaces[0].id;
|
||||||
|
|
||||||
|
let db = query_manager.connect();
|
||||||
|
let requests = db.list_http_requests(workspace_id).unwrap();
|
||||||
|
assert_eq!(requests.len(), REQUEST_IDS.len());
|
||||||
|
assert_eq!(db.list_folders(workspace_id).unwrap().len(), FOLDER_IDS.len());
|
||||||
|
assert_eq!(db.list_environments(workspace_id).unwrap().len(), ENVIRONMENT_IDS.len());
|
||||||
|
|
||||||
|
let list_users = requests.iter().find(|r| r.name == "List users").unwrap();
|
||||||
|
let chained = requests.iter().find(|r| r.name == "Posts by the first user").unwrap();
|
||||||
|
let param = &chained.url_parameters[0].value;
|
||||||
|
assert!(param.contains(&format!("request='{}'", list_users.id)), "{param}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creating_twice_makes_two_workspaces() {
|
||||||
|
let (query_manager, _blobs, _rx) = init_in_memory().unwrap();
|
||||||
|
create_example_workspace(&query_manager, &UpdateSource::Background).unwrap();
|
||||||
|
create_example_workspace(&query_manager, &UpdateSource::Background).unwrap();
|
||||||
|
assert_eq!(query_manager.connect().list_workspaces().unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
{
|
||||||
|
"workspaces": [
|
||||||
|
{
|
||||||
|
"id": "{{WORKSPACE}}",
|
||||||
|
"name": "Example Workspace",
|
||||||
|
"description": "A small tour of Yaak using the free [JSONPlaceholder](https://jsonplaceholder.typicode.com) API. Nothing here needs an account.\n\n1. Open **Getting Started / List posts** and press Send\n2. Open **Variables / Get the current user**, then switch the environment in the top-left to change which user it fetches\n3. Open **Chaining / Posts by the first user** to see how one request reads a value out of another request's response\n\nDelete this workspace whenever you're done with it."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"environments": [
|
||||||
|
{
|
||||||
|
"id": "{{ENV_BASE}}",
|
||||||
|
"workspaceId": "{{WORKSPACE}}",
|
||||||
|
"name": "Global Variables",
|
||||||
|
"parentModel": "workspace",
|
||||||
|
"public": true,
|
||||||
|
"variables": [
|
||||||
|
{ "name": "base_url", "value": "https://jsonplaceholder.typicode.com", "enabled": true },
|
||||||
|
{ "name": "user_id", "value": "1", "enabled": true },
|
||||||
|
{ "name": "api_token", "value": "example-token", "enabled": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "{{ENV_USER_2}}",
|
||||||
|
"workspaceId": "{{WORKSPACE}}",
|
||||||
|
"name": "Another User",
|
||||||
|
"parentModel": "environment",
|
||||||
|
"public": true,
|
||||||
|
"color": "#c084fc",
|
||||||
|
"variables": [{ "name": "user_id", "value": "2", "enabled": true }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"id": "{{FOLDER_BASICS}}",
|
||||||
|
"workspaceId": "{{WORKSPACE}}",
|
||||||
|
"name": "Getting Started",
|
||||||
|
"sortPriority": 1,
|
||||||
|
"description": "Plain requests. Send one, then look through the response tabs: Body, Headers, Cookies, and Timeline."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "{{FOLDER_VARIABLES}}",
|
||||||
|
"workspaceId": "{{WORKSPACE}}",
|
||||||
|
"name": "Variables",
|
||||||
|
"sortPriority": 2,
|
||||||
|
"description": "Anything wrapped in `${[ ... ]}` is a template. Variables come from the environment selected in the top-left, and sub-environments override the base values."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "{{FOLDER_CHAINING}}",
|
||||||
|
"workspaceId": "{{WORKSPACE}}",
|
||||||
|
"name": "Chaining",
|
||||||
|
"sortPriority": 3,
|
||||||
|
"description": "The `response()` template function reads a value out of another request's response, sending that request first if it has to. Put the cursor in a URL and press Ctrl+Space to insert one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "{{FOLDER_AUTH}}",
|
||||||
|
"workspaceId": "{{WORKSPACE}}",
|
||||||
|
"name": "Authentication",
|
||||||
|
"sortPriority": 4,
|
||||||
|
"description": "This folder sets Bearer auth from the `api_token` variable. Every request inside inherits it, so the token lives in one place. JSONPlaceholder ignores the header, so these still succeed.",
|
||||||
|
"authenticationType": "bearer",
|
||||||
|
"authentication": { "token": "${[ api_token ]}" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"httpRequests": [
|
||||||
|
{
|
||||||
|
"id": "{{RQ_LIST_POSTS}}",
|
||||||
|
"workspaceId": "{{WORKSPACE}}",
|
||||||
|
"folderId": "{{FOLDER_BASICS}}",
|
||||||
|
"name": "List posts",
|
||||||
|
"method": "GET",
|
||||||
|
"url": "${[ base_url ]}/posts",
|
||||||
|
"sortPriority": 1,
|
||||||
|
"urlParameters": [{ "name": "_limit", "value": "5", "enabled": true }],
|
||||||
|
"description": "Query parameters live in the Params tab. Try raising `_limit`."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "{{RQ_GET_POST}}",
|
||||||
|
"workspaceId": "{{WORKSPACE}}",
|
||||||
|
"folderId": "{{FOLDER_BASICS}}",
|
||||||
|
"name": "Get a post",
|
||||||
|
"method": "GET",
|
||||||
|
"url": "${[ base_url ]}/posts/1",
|
||||||
|
"sortPriority": 2,
|
||||||
|
"description": "Change the ID at the end of the URL and send again."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "{{RQ_CREATE_POST}}",
|
||||||
|
"workspaceId": "{{WORKSPACE}}",
|
||||||
|
"folderId": "{{FOLDER_BASICS}}",
|
||||||
|
"name": "Create a post",
|
||||||
|
"method": "POST",
|
||||||
|
"url": "${[ base_url ]}/posts",
|
||||||
|
"sortPriority": 3,
|
||||||
|
"bodyType": "application/json",
|
||||||
|
"body": {
|
||||||
|
"text": "{\n \"title\": \"Hello from Yaak\",\n \"body\": \"Templates work inside bodies too\",\n \"userId\": ${[ user_id ]}\n}"
|
||||||
|
},
|
||||||
|
"headers": [{ "name": "Content-Type", "value": "application/json", "enabled": true }],
|
||||||
|
"description": "JSONPlaceholder pretends to create the post and echoes it back with a new ID."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "{{RQ_CURRENT_USER}}",
|
||||||
|
"workspaceId": "{{WORKSPACE}}",
|
||||||
|
"folderId": "{{FOLDER_VARIABLES}}",
|
||||||
|
"name": "Get the current user",
|
||||||
|
"method": "GET",
|
||||||
|
"url": "${[ base_url ]}/users/${[ user_id ]}",
|
||||||
|
"sortPriority": 1,
|
||||||
|
"description": "Switch to the **Another User** environment in the top-left and send again. Hover a template in the URL to see what it resolves to."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "{{RQ_LIST_USERS}}",
|
||||||
|
"workspaceId": "{{WORKSPACE}}",
|
||||||
|
"folderId": "{{FOLDER_CHAINING}}",
|
||||||
|
"name": "List users",
|
||||||
|
"method": "GET",
|
||||||
|
"url": "${[ base_url ]}/users",
|
||||||
|
"sortPriority": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "{{RQ_POSTS_BY_FIRST_USER}}",
|
||||||
|
"workspaceId": "{{WORKSPACE}}",
|
||||||
|
"folderId": "{{FOLDER_CHAINING}}",
|
||||||
|
"name": "Posts by the first user",
|
||||||
|
"method": "GET",
|
||||||
|
"url": "${[ base_url ]}/posts",
|
||||||
|
"sortPriority": 2,
|
||||||
|
"urlParameters": [
|
||||||
|
{
|
||||||
|
"name": "userId",
|
||||||
|
"value": "${[ response.body.path(request='{{RQ_LIST_USERS}}', path='$[0].id') ]}",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "The `userId` parameter is pulled from the **List users** response with the JSONPath `$[0].id`. If that request hasn't been sent yet, Yaak sends it first."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "{{RQ_TODOS}}",
|
||||||
|
"workspaceId": "{{WORKSPACE}}",
|
||||||
|
"folderId": "{{FOLDER_AUTH}}",
|
||||||
|
"name": "List the user's todos",
|
||||||
|
"method": "GET",
|
||||||
|
"url": "${[ base_url ]}/users/${[ user_id ]}/todos",
|
||||||
|
"sortPriority": 1,
|
||||||
|
"urlParameters": [{ "name": "_limit", "value": "5", "enabled": true }],
|
||||||
|
"description": "Open the Auth tab: it's set to inherit from the folder. Check the Timeline tab after sending to see the Authorization header that was sent."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod example;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod plugin_events;
|
pub mod plugin_events;
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
|||||||
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
|
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
|
||||||
cmd_import_sources_for_origin: ["Importing isn't available in the browser yet", null],
|
cmd_import_sources_for_origin: ["Importing isn't available in the browser yet", null],
|
||||||
cmd_export_data: ["Exporting to a file isn't available in the browser yet", "localFiles"],
|
cmd_export_data: ["Exporting to a file isn't available in the browser yet", "localFiles"],
|
||||||
|
cmd_create_example_workspace: ["The example workspace isn't available in the browser yet", null],
|
||||||
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
|
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
|
||||||
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
|
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
|
||||||
cmd_format_graphql: ["Formatting GraphQL needs a plugin, which this host doesn't run", null],
|
cmd_format_graphql: ["Formatting GraphQL needs a plugin, which this host doesn't run", null],
|
||||||
|
|||||||
Reference in New Issue
Block a user