mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-05 12:31:47 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ac33e351b |
@@ -10,6 +10,7 @@ import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const COMMERCIAL_USE_BANNER_MESSAGE =
|
||||
"Personal use of Yaak is free. If you’re using Yaak at work, please purchase a license.";
|
||||
const hiddenBanner = <span aria-hidden className="block h-0 w-0 shrink-0 overflow-hidden" />;
|
||||
|
||||
export function CommercialUseBanner({
|
||||
source,
|
||||
@@ -55,7 +56,7 @@ export function CommercialUseBanner({
|
||||
}, [setSnoozedAt, snoozed, source]);
|
||||
|
||||
if (!visible || isSnoozeLoading || (snoozed && !snoozeStartedRef.current)) {
|
||||
return null;
|
||||
return hiddenBanner;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { HStack, VStack } from "@yaakapp-internal/ui";
|
||||
import { useRef, useState } from "react";
|
||||
import type { FeedbackFeature } from "../lib/featureFeedbackConstants";
|
||||
import { FEEDBACK_FEATURES } from "../lib/featureFeedbackConstants";
|
||||
import { invokeCmd } from "../lib/tauri";
|
||||
import { hideToastById, showToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
import { Input } from "./core/Input";
|
||||
|
||||
interface Props {
|
||||
feature: FeedbackFeature;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function FeedbackToast({ feature, onDone }: Props) {
|
||||
const [text, setText] = useState<string>("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const sentRef = useRef(false);
|
||||
|
||||
const handleDismiss = () => {
|
||||
onDone();
|
||||
hideToastById(`feature-feedback-${feature}`);
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
const trimmedText = text.trim();
|
||||
if (sentRef.current || trimmedText.length === 0) return;
|
||||
|
||||
sentRef.current = true;
|
||||
setSent(true);
|
||||
onDone();
|
||||
|
||||
// Fire-and-forget; failures are intentionally ignored
|
||||
invokeCmd("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {});
|
||||
showToast({
|
||||
id: `feature-feedback-${feature}`,
|
||||
timeout: 3000,
|
||||
color: "success",
|
||||
message: "Thanks for the feedback!",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={2}>
|
||||
<p className="text-sm font-semibold">{FEEDBACK_FEATURES[feature]}</p>
|
||||
<div className="h-20">
|
||||
<Input
|
||||
size="xs"
|
||||
// The editor forces its mono font on the scroller, so the override
|
||||
// has to target it directly
|
||||
className="[&_.cm-scroller]:font-sans! [&_.cm-scroller]:text-sm!"
|
||||
label="Feedback"
|
||||
hideLabel
|
||||
stateKey={null}
|
||||
multiLine
|
||||
fullHeight
|
||||
placeholder="Your thoughts..."
|
||||
onChange={setText}
|
||||
/>
|
||||
</div>
|
||||
<HStack space={1.5} justifyContent="end">
|
||||
<Button size="xs" color="secondary" variant="border" onClick={handleDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="primary"
|
||||
disabled={sent || text.trim().length === 0}
|
||||
onClick={handleSend}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import { deepEqualAtom } from "../lib/atoms";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { generateId } from "../lib/generateId";
|
||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
||||
import { convertRequestBody } from "../lib/requestBodyConversion";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_MULTIPART,
|
||||
@@ -196,14 +195,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
});
|
||||
};
|
||||
|
||||
const patch: Partial<HttpRequest> = {
|
||||
bodyType,
|
||||
body: convertRequestBody({
|
||||
body: activeRequest.body,
|
||||
fromBodyType: activeRequest.bodyType,
|
||||
toBodyType: bodyType,
|
||||
}),
|
||||
};
|
||||
const patch: Partial<HttpRequest> = { bodyType };
|
||||
let newContentType: string | null | undefined;
|
||||
if (bodyType === BODY_TYPE_NONE) {
|
||||
newContentType = null;
|
||||
|
||||
@@ -92,9 +92,7 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => {
|
||||
onPinnedResponseId(r.id);
|
||||
},
|
||||
onSelect: () => onPinnedResponseId(r.id),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -112,17 +112,6 @@ export function SettingsGeneral() {
|
||||
</SettingsSection>
|
||||
</CargoFeature>
|
||||
|
||||
<CargoFeature feature="license">
|
||||
<SettingsSection title="Feedback">
|
||||
<SettingRowBoolean
|
||||
title="Prompt for feedback"
|
||||
description="Show rare one-time prompts asking how new features are working."
|
||||
checked={settings.promptFeedback}
|
||||
onChange={(promptFeedback) => patchModel(settings, { promptFeedback })}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</CargoFeature>
|
||||
|
||||
{showWorkspaceSettingsMovedBanner && (
|
||||
<DismissibleBanner
|
||||
id="workspace-settings-moved-2026-06-30"
|
||||
|
||||
@@ -318,7 +318,6 @@ function BaseInput({
|
||||
editorClassName,
|
||||
multiLine && size === "md" && "py-1.5",
|
||||
multiLine && size === "sm" && "py-1",
|
||||
multiLine && (size === "xs" || size === "2xs") && "py-0.5",
|
||||
)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import * as m from "motion/react-m";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useKey } from "react-use";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
@@ -16,12 +15,6 @@ export interface ToastProps {
|
||||
action?: (args: { hide: () => void }) => ReactNode;
|
||||
icon?: ShowToastRequest["icon"] | null;
|
||||
color?: ShowToastRequest["color"];
|
||||
// Grow with the content (up to the viewport) instead of scrolling internally
|
||||
// past the default max height
|
||||
dynamicHeight?: boolean;
|
||||
// Hide the close button, for toasts that render their own dismiss action.
|
||||
// Escape still closes the toast
|
||||
hideDismiss?: boolean;
|
||||
}
|
||||
|
||||
const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
|
||||
@@ -35,47 +28,7 @@ const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon
|
||||
warning: "alert_triangle",
|
||||
};
|
||||
|
||||
export function Toast({
|
||||
children,
|
||||
open,
|
||||
onClose,
|
||||
timeout,
|
||||
action,
|
||||
icon,
|
||||
color,
|
||||
dynamicHeight,
|
||||
hideDismiss,
|
||||
}: ToastProps) {
|
||||
const onCloseRef = useRef(onClose);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [autoHideCanceled, setAutoHideCanceled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
const cancelAutoHide = useCallback(() => {
|
||||
if (timeoutRef.current == null) return;
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
setAutoHideCanceled(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || timeout == null || autoHideCanceled) return;
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
onCloseRef.current();
|
||||
}, timeout);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current == null) return;
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
};
|
||||
}, [autoHideCanceled, open, timeout]);
|
||||
|
||||
export function Toast({ children, open, onClose, timeout, action, icon, color }: ToastProps) {
|
||||
useKey(
|
||||
"Escape",
|
||||
() => {
|
||||
@@ -103,17 +56,8 @@ export function Toast({
|
||||
"relative pointer-events-auto bg-surface text-text rounded-lg",
|
||||
"border border-border shadow-lg w-100",
|
||||
)}
|
||||
onFocusCapture={cancelAutoHide}
|
||||
onKeyDownCapture={cancelAutoHide}
|
||||
onPointerDownCapture={cancelAutoHide}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
|
||||
hideDismiss ? "pr-3" : "pr-10",
|
||||
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
|
||||
)}
|
||||
>
|
||||
<div className="pl-3 py-3 pr-10 flex items-start gap-2 w-full max-h-44 overflow-auto">
|
||||
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
|
||||
<VStack space={2} className="w-full min-w-0">
|
||||
<div className="select-auto">{children}</div>
|
||||
@@ -121,7 +65,6 @@ export function Toast({
|
||||
</VStack>
|
||||
</div>
|
||||
|
||||
{!hideDismiss && (
|
||||
<IconButton
|
||||
color={color}
|
||||
variant="border"
|
||||
@@ -130,9 +73,8 @@ export function Toast({
|
||||
icon="x"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{timeout != null && !autoHideCanceled && (
|
||||
{timeout != null && (
|
||||
<div className="w-full absolute bottom-0 left-0 right-0">
|
||||
<m.div
|
||||
className="bg-surface-highlight h-[3px]"
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal
|
||||
import classNames from "classnames";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { modelToYaml } from "../../lib/diffYaml";
|
||||
import { trackFeatureUsage } from "../../lib/featureFeedback";
|
||||
import { resolvedModelName } from "../../lib/resolvedModelName";
|
||||
import { showConfirm } from "../../lib/confirm";
|
||||
import { showErrorToast } from "../../lib/toast";
|
||||
@@ -56,7 +55,6 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
setCommitError(null);
|
||||
try {
|
||||
await commit.mutateAsync({ message });
|
||||
trackFeatureUsage("git-sync");
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setCommitError(String(err));
|
||||
@@ -68,7 +66,6 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
try {
|
||||
const r = await commitAndPush.mutateAsync({ message });
|
||||
handlePushResult(r);
|
||||
trackFeatureUsage("git-sync");
|
||||
onDone();
|
||||
} catch (err) {
|
||||
showErrorToast({
|
||||
@@ -209,10 +206,9 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
layout="horizontal"
|
||||
defaultRatio={0.6}
|
||||
firstSlot={({ style }) => (
|
||||
<div style={style} className="h-full px-4 flex flex-col gap-3">
|
||||
<div style={style} className="h-full px-4 grid grid-rows-[auto_minmax(0,1fr)] gap-3">
|
||||
<CommercialUseBanner source="git-commit" title="Using Git for work?" />
|
||||
<SplitLayout
|
||||
className="min-h-0 flex-1"
|
||||
storageKey="commit-vertical"
|
||||
layout="vertical"
|
||||
defaultRatio={0.35}
|
||||
@@ -243,10 +239,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
</div>
|
||||
)}
|
||||
secondSlot={({ style: innerStyle }) => (
|
||||
<div
|
||||
style={innerStyle}
|
||||
className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"
|
||||
>
|
||||
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
|
||||
<Input
|
||||
className="text-base! font-sans rounded-md"
|
||||
placeholder="Commit message..."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL";
|
||||
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
||||
@@ -11,13 +11,9 @@ import type { DropdownItem } from "../core/Dropdown";
|
||||
import { Dropdown } from "../core/Dropdown";
|
||||
import type { EditorProps } from "../core/Editor/Editor";
|
||||
import { Editor } from "../core/Editor/LazyEditor";
|
||||
import type { RadioDropdownItem } from "../core/RadioDropdown";
|
||||
import { RadioDropdown } from "../core/RadioDropdown";
|
||||
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
||||
import { Separator } from "../core/Separator";
|
||||
import { tryFormatGraphql } from "../../lib/formatters";
|
||||
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
||||
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
|
||||
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
||||
|
||||
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
||||
@@ -26,8 +22,6 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
|
||||
request: HttpRequest;
|
||||
};
|
||||
|
||||
const OPERATION_NAME_NOT_SPECIFIED = "";
|
||||
|
||||
export function GraphQLEditor(props: Props) {
|
||||
// There's some weirdness with stale onChange being called when switching requests, so we'll
|
||||
// key on the request ID as a workaround for now.
|
||||
@@ -44,25 +38,25 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
||||
query: string;
|
||||
variables: string | undefined;
|
||||
operationName?: string;
|
||||
}>(() => {
|
||||
// Migrate text bodies to GraphQL format
|
||||
// NOTE: This is how GraphQL used to be stored
|
||||
return normalizeGraphQLBody(request.body);
|
||||
if ("text" in request.body) {
|
||||
const b = tryParseJson(request.body.text, {});
|
||||
const variables = JSON.stringify(b.variables || undefined, null, 2);
|
||||
return { query: b.query ?? "", variables };
|
||||
}
|
||||
|
||||
return { query: request.body.query ?? "", variables: request.body.variables ?? "" };
|
||||
}, [extraEditorProps.forceUpdateKey]);
|
||||
|
||||
const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);
|
||||
const isDocOpen = isDocOpenRecord[request.id] !== undefined;
|
||||
const parsedOperationNames = useMemo(
|
||||
() => parseGraphQLOperationNames(currentBody.query),
|
||||
[currentBody.query],
|
||||
);
|
||||
const operationNames = useMemo(() => parsedOperationNames ?? [], [parsedOperationNames]);
|
||||
|
||||
const handleChangeQuery = useCallback(
|
||||
(query: string) => {
|
||||
setCurrentBody(({ variables, operationName }) => {
|
||||
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||
setCurrentBody(({ variables }) => {
|
||||
const newBody = { query, variables };
|
||||
onChange(newBody);
|
||||
return newBody;
|
||||
});
|
||||
@@ -72,8 +66,8 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
|
||||
const handleChangeVariables = useCallback(
|
||||
(variables: string) => {
|
||||
setCurrentBody(({ query, operationName }) => {
|
||||
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||
setCurrentBody(({ query }) => {
|
||||
const newBody = { query, variables: variables || undefined };
|
||||
onChange(newBody);
|
||||
return newBody;
|
||||
});
|
||||
@@ -81,78 +75,9 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
[onChange, setCurrentBody],
|
||||
);
|
||||
|
||||
const handleChangeOperationName = useCallback(
|
||||
(operationName: string) => {
|
||||
setCurrentBody(({ query, variables }) => {
|
||||
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||
onChange(newBody);
|
||||
return newBody;
|
||||
});
|
||||
},
|
||||
[onChange, setCurrentBody],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (parsedOperationNames == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentBody.operationName && operationNames.includes(currentBody.operationName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the saved body aligned with the visible default, so send/copy use the selected operation.
|
||||
const operationName = operationNames[0];
|
||||
if (currentBody.operationName === operationName) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentBody(({ query, variables }) => {
|
||||
const newBody = buildGraphQLBody({ query, variables, operationName });
|
||||
onChange(newBody);
|
||||
return newBody;
|
||||
});
|
||||
}, [
|
||||
currentBody.operationName,
|
||||
onChange,
|
||||
operationNames,
|
||||
parsedOperationNames,
|
||||
setCurrentBody,
|
||||
]);
|
||||
|
||||
const actions = useMemo<EditorProps["actions"]>(
|
||||
() => [
|
||||
operationNames.length > 0 ? (
|
||||
<div key="operation" className="opacity-100!">
|
||||
<RadioDropdown
|
||||
value={currentBody.operationName ?? operationNames[0] ?? OPERATION_NAME_NOT_SPECIFIED}
|
||||
onChange={handleChangeOperationName}
|
||||
items={[
|
||||
{ type: "separator", label: "Operation Name" },
|
||||
{
|
||||
label: <span className="text-text-subtle italic">Not specified</span>,
|
||||
value: OPERATION_NAME_NOT_SPECIFIED,
|
||||
},
|
||||
...operationNames.map((operationName) => ({
|
||||
label: operationName,
|
||||
value: operationName,
|
||||
})),
|
||||
] satisfies RadioDropdownItem<string>[]}
|
||||
>
|
||||
<Button size="sm" variant="border" title="Select Operation" forDropdown>
|
||||
{currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED ? (
|
||||
<span className="text-text-subtle italic">Not specified</span>
|
||||
) : (
|
||||
currentBody.operationName ?? operationNames[0]
|
||||
)}
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
</div>
|
||||
) : null,
|
||||
<div key="actions" className="flex flex-row opacity-100! shadow!">
|
||||
<div key="introspection" className="opacity-100!">
|
||||
{schema === undefined ? null /* Initializing */ : (
|
||||
<Dropdown
|
||||
@@ -260,17 +185,15 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
],
|
||||
[
|
||||
schema,
|
||||
clear,
|
||||
error,
|
||||
currentBody.operationName,
|
||||
handleChangeOperationName,
|
||||
isDocOpen,
|
||||
isLoading,
|
||||
operationNames,
|
||||
refetch,
|
||||
autoIntrospectDisabled,
|
||||
baseRequest.id,
|
||||
@@ -314,23 +237,10 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
);
|
||||
}
|
||||
|
||||
function buildGraphQLBody(body: {
|
||||
query: string;
|
||||
variables: string | undefined;
|
||||
operationName?: string;
|
||||
}) {
|
||||
const result: {
|
||||
query: string;
|
||||
variables: string | undefined;
|
||||
operationName?: string;
|
||||
} = {
|
||||
query: body.query,
|
||||
variables: body.variables || undefined,
|
||||
};
|
||||
|
||||
if (typeof body.operationName === "string") {
|
||||
result.operationName = body.operationName;
|
||||
function tryParseJson(text: string, fallback: unknown) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ function ActualEventStreamViewer({ response }: Props) {
|
||||
summary.data.fragmentCount === 0 &&
|
||||
!summary.isFetching &&
|
||||
summary.error == null;
|
||||
|
||||
const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true;
|
||||
const applyToDetails = showExtractedText && applyToDetailsSetting.value === true;
|
||||
const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true;
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { FeedbackToast } from "../components/FeedbackToast";
|
||||
import { appInfo } from "./appInfo";
|
||||
import type { FeedbackFeature } from "./featureFeedbackConstants";
|
||||
import { dialogsAtom } from "./dialog";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { getKeyValue, setKeyValue } from "./keyValueStore";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
interface FeatureFeedbackState {
|
||||
uses: number;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
const FEEDBACK_PROMPT_DELAY_MS = 1500;
|
||||
const FEEDBACK_PROMPT_TIMEOUT_MS = 8000;
|
||||
|
||||
// Ask once the user has used a feature enough times to have formed an opinion
|
||||
const PROMPT_AFTER_USES = 3;
|
||||
|
||||
// Show at most one feedback prompt per app session to stay unobtrusive
|
||||
let promptedThisSession = false;
|
||||
|
||||
const lastTrackedAt: Partial<Record<FeedbackFeature, number>> = {};
|
||||
const FEATURE_USE_DEBOUNCE_MS = 10_000;
|
||||
|
||||
const kvArgs = (feature: FeedbackFeature) => ({
|
||||
namespace: "global",
|
||||
key: ["feature-feedback", feature],
|
||||
});
|
||||
|
||||
function getFeatureFeedbackState(feature: FeedbackFeature): FeatureFeedbackState {
|
||||
return getKeyValue<FeatureFeedbackState>({
|
||||
...kvArgs(feature),
|
||||
fallback: { uses: 0, done: false },
|
||||
});
|
||||
}
|
||||
|
||||
function patchFeatureFeedbackState(feature: FeedbackFeature, patch: Partial<FeatureFeedbackState>) {
|
||||
const value = { ...getFeatureFeedbackState(feature), ...patch };
|
||||
setKeyValue({ ...kvArgs(feature), value }).catch(console.error);
|
||||
}
|
||||
|
||||
function markFeatureFeedbackDone(feature: FeedbackFeature) {
|
||||
patchFeatureFeedbackState(feature, { done: true });
|
||||
}
|
||||
|
||||
function showFeedbackToast(feature: FeedbackFeature) {
|
||||
if (!jotaiStore.get(settingsAtom).promptFeedback) return;
|
||||
|
||||
showToast({
|
||||
id: `feature-feedback-${feature}`,
|
||||
timeout: FEEDBACK_PROMPT_TIMEOUT_MS,
|
||||
dynamicHeight: true,
|
||||
hideDismiss: true,
|
||||
message: (
|
||||
<FeedbackToast feature={feature} onDone={() => markFeatureFeedbackDone(feature)} />
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function showFeedbackToastWhenReady(feature: FeedbackFeature) {
|
||||
setTimeout(() => {
|
||||
if (!jotaiStore.get(settingsAtom).promptFeedback) return;
|
||||
|
||||
if (jotaiStore.get(dialogsAtom).length === 0) {
|
||||
showFeedbackToast(feature);
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = jotaiStore.sub(dialogsAtom, () => {
|
||||
if (jotaiStore.get(dialogsAtom).length > 0) return;
|
||||
|
||||
unsubscribe();
|
||||
showFeedbackToast(feature);
|
||||
});
|
||||
}, FEEDBACK_PROMPT_DELAY_MS);
|
||||
}
|
||||
|
||||
// Record a successful use of a feature, and prompt for feedback on the Nth use.
|
||||
// Nothing is ever sent to the server from here; showing the toast is local-only
|
||||
// and a submission only happens when the user clicks Send in it.
|
||||
export function trackFeatureUsage(feature: FeedbackFeature) {
|
||||
if (appInfo.featureLicense !== true || !jotaiStore.get(settingsAtom).promptFeedback) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (lastTrackedAt[feature] != null && now - lastTrackedAt[feature] < FEATURE_USE_DEBOUNCE_MS) {
|
||||
return;
|
||||
}
|
||||
lastTrackedAt[feature] = now;
|
||||
|
||||
const state = getFeatureFeedbackState(feature);
|
||||
if (state.done) return;
|
||||
|
||||
const uses = state.uses + 1;
|
||||
const shouldPrompt = uses >= PROMPT_AFTER_USES && !promptedThisSession;
|
||||
|
||||
patchFeatureFeedbackState(feature, { uses });
|
||||
if (!shouldPrompt) return;
|
||||
|
||||
promptedThisSession = true;
|
||||
showFeedbackToastWhenReady(feature);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// Feature keys are sent to the server and used to group feedback for analysis.
|
||||
// NEVER rename a key once it has shipped, or historical feedback will be split
|
||||
// across the old and new names.
|
||||
export const FEEDBACK_FEATURES = {
|
||||
"git-sync": "How is Git sync working for you?",
|
||||
} as const;
|
||||
|
||||
export type FeedbackFeature = keyof typeof FEEDBACK_FEATURES;
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { getGraphQLOperationNames, parseGraphQLOperationNames } from "./graphqlOperationNames";
|
||||
|
||||
describe("getGraphQLOperationNames", () => {
|
||||
test("returns named operations from a GraphQL document", () => {
|
||||
expect(
|
||||
getGraphQLOperationNames(`
|
||||
query GetUser { user { id } }
|
||||
mutation UpdateUser { updateUser { id } }
|
||||
subscription UserChanged { userChanged { id } }
|
||||
fragment UserFields on User { id }
|
||||
`),
|
||||
).toEqual(["GetUser", "UpdateUser", "UserChanged"]);
|
||||
});
|
||||
|
||||
test("ignores anonymous operations", () => {
|
||||
expect(getGraphQLOperationNames(`{ user { id } }`)).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns unique operation names in document order", () => {
|
||||
expect(
|
||||
getGraphQLOperationNames(`
|
||||
query GetUser { user { id } }
|
||||
query GetUser { user { name } }
|
||||
query ListUsers { users { id } }
|
||||
`),
|
||||
).toEqual(["GetUser", "ListUsers"]);
|
||||
});
|
||||
|
||||
test("returns no operations for invalid in-progress documents", () => {
|
||||
expect(getGraphQLOperationNames(`query GetUser { user {`)).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns null when parsing invalid in-progress documents", () => {
|
||||
expect(parseGraphQLOperationNames(`query GetUser { user {`)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Kind, parse } from "graphql";
|
||||
|
||||
export function getGraphQLOperationNames(query: string): string[] {
|
||||
return parseGraphQLOperationNames(query) ?? [];
|
||||
}
|
||||
|
||||
export function parseGraphQLOperationNames(query: string): string[] | null {
|
||||
try {
|
||||
const names: string[] = [];
|
||||
|
||||
for (const definition of parse(query).definitions) {
|
||||
if (definition.kind !== Kind.OPERATION_DEFINITION || definition.name == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = definition.name.value;
|
||||
if (!names.includes(name)) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_URLENCODED,
|
||||
BODY_TYPE_GRAPHQL,
|
||||
BODY_TYPE_JSON,
|
||||
BODY_TYPE_NONE,
|
||||
BODY_TYPE_OTHER,
|
||||
BODY_TYPE_XML,
|
||||
} from "./model_util";
|
||||
import { convertRequestBody } from "./requestBodyConversion";
|
||||
|
||||
describe("convertRequestBody", () => {
|
||||
test("converts imported JSON GraphQL bodies to GraphQL shape", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_GRAPHQL,
|
||||
body: {
|
||||
text: JSON.stringify({
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: { id: "123" },
|
||||
operationName: "GetUser",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: '{\n "id": "123"\n}',
|
||||
operationName: "GetUser",
|
||||
});
|
||||
});
|
||||
|
||||
test("converts GraphQL bodies to JSON text", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_GRAPHQL,
|
||||
toBodyType: BODY_TYPE_JSON,
|
||||
body: {
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: '{ "id": "123" }',
|
||||
operationName: "GetUser",
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: JSON.stringify(
|
||||
{
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: { id: "123" },
|
||||
operationName: "GetUser",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
test("converts urlencoded forms to urlencoded text for text-like bodies", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_FORM_URLENCODED,
|
||||
toBodyType: BODY_TYPE_OTHER,
|
||||
body: {
|
||||
form: [
|
||||
{ enabled: true, name: "basic", value: "aaa" },
|
||||
{ enabled: true, name: "funky stuff", value: "*)%&#$)@ *$#)@&" },
|
||||
{ enabled: false, name: "disabled", value: "hidden" },
|
||||
{ enabled: true, name: "", value: "unnamed" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: "basic=aaa&funky+stuff=*%29%25%26%23%24%29%40+*%24%23%29%40%26",
|
||||
});
|
||||
});
|
||||
|
||||
test("converts urlencoded forms to JSON text for JSON bodies", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_FORM_URLENCODED,
|
||||
toBodyType: BODY_TYPE_JSON,
|
||||
body: {
|
||||
form: [
|
||||
{ enabled: true, name: "tag", value: "one" },
|
||||
{ enabled: true, name: "tag", value: "two" },
|
||||
{ enabled: true, name: "limit", value: "10" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: JSON.stringify({ tag: ["one", "two"], limit: "10" }, null, 2),
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves text when converting to form bodies cannot build form pairs", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_XML,
|
||||
toBodyType: BODY_TYPE_FORM_URLENCODED,
|
||||
body: { text: "a=1&b=two+words" },
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: "a=1&b=two+words",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves JSON text that is not a GraphQL envelope", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_GRAPHQL,
|
||||
body: { text: JSON.stringify({ name: "Yaak" }) },
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: JSON.stringify({ name: "Yaak" }),
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves JSON arrays and primitives when converting to GraphQL", () => {
|
||||
for (const text of [JSON.stringify([1, 2, 3]), JSON.stringify("query"), "123", "null"]) {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_GRAPHQL,
|
||||
body: { text },
|
||||
});
|
||||
|
||||
expect(body).toEqual({ text });
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves text when converting to binary cannot build a file body", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_BINARY,
|
||||
body: { text: '{ "name": "Yaak" }' },
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: '{ "name": "Yaak" }',
|
||||
});
|
||||
});
|
||||
|
||||
test("clears body when converting to no body", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_NONE,
|
||||
body: { text: '{ "name": "Yaak" }' },
|
||||
});
|
||||
|
||||
expect(body).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,199 +0,0 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_MULTIPART,
|
||||
BODY_TYPE_FORM_URLENCODED,
|
||||
BODY_TYPE_GRAPHQL,
|
||||
BODY_TYPE_JSON,
|
||||
BODY_TYPE_NONE,
|
||||
} from "./model_util";
|
||||
|
||||
type Body = HttpRequest["body"];
|
||||
type BodyType = HttpRequest["bodyType"];
|
||||
type GraphQLBody = {
|
||||
query: string;
|
||||
variables: string | undefined;
|
||||
operationName?: string;
|
||||
};
|
||||
|
||||
export function convertRequestBody({
|
||||
body,
|
||||
fromBodyType,
|
||||
toBodyType,
|
||||
}: {
|
||||
body: Body;
|
||||
fromBodyType: BodyType;
|
||||
toBodyType: BodyType;
|
||||
}): Body {
|
||||
if (toBodyType === BODY_TYPE_NONE) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (toBodyType === BODY_TYPE_GRAPHQL) {
|
||||
return toGraphQLBody(body) ?? body;
|
||||
}
|
||||
|
||||
if (toBodyType === BODY_TYPE_FORM_URLENCODED || toBodyType === BODY_TYPE_FORM_MULTIPART) {
|
||||
return toFormBody(body) ?? body;
|
||||
}
|
||||
|
||||
if (toBodyType === BODY_TYPE_BINARY) {
|
||||
return typeof body.filePath === "string" ? { filePath: body.filePath } : body;
|
||||
}
|
||||
|
||||
return toTextBody(body, fromBodyType, toBodyType) ?? body;
|
||||
}
|
||||
|
||||
export function normalizeGraphQLBody(body: Body): GraphQLBody {
|
||||
return toGraphQLBody(body) ?? { query: "", variables: undefined };
|
||||
}
|
||||
|
||||
function toGraphQLBody(body: Body): GraphQLBody | null {
|
||||
if (typeof body.query === "string") {
|
||||
const result: GraphQLBody = {
|
||||
query: body.query,
|
||||
variables: typeof body.variables === "string" ? body.variables : undefined,
|
||||
};
|
||||
if (typeof body.operationName === "string") {
|
||||
result.operationName = body.operationName;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (typeof body.text === "string") {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body.text);
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof parsed.query !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = parsed.query;
|
||||
const variables =
|
||||
parsed.variables == null ? undefined : JSON.stringify(parsed.variables, null, 2);
|
||||
|
||||
const result: GraphQLBody = { query, variables };
|
||||
if (typeof parsed.operationName === "string") {
|
||||
result.operationName = parsed.operationName;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
return { query: body.text, variables: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toFormBody(body: Body): Body | null {
|
||||
if (Array.isArray(body.form)) {
|
||||
return {
|
||||
form: body.form.map((p) => ({
|
||||
enabled: p.enabled !== false,
|
||||
name: typeof p.name === "string" ? p.name : "",
|
||||
value: stringifyFormValue(p.value ?? p.file),
|
||||
contentType: typeof p.contentType === "string" ? p.contentType : undefined,
|
||||
filename: typeof p.filename === "string" ? p.filename : undefined,
|
||||
file: typeof p.file === "string" ? p.file : undefined,
|
||||
id: typeof p.id === "string" ? p.id : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toTextBody(body: Body, fromBodyType: BodyType, toBodyType: BodyType): Body | null {
|
||||
const sendJsonComments =
|
||||
typeof body.sendJsonComments === "boolean" ? { sendJsonComments: body.sendJsonComments } : {};
|
||||
|
||||
if (typeof body.text === "string") {
|
||||
return { text: body.text, ...sendJsonComments };
|
||||
}
|
||||
|
||||
if (Array.isArray(body.form)) {
|
||||
if (toBodyType === BODY_TYPE_JSON) {
|
||||
return { text: JSON.stringify(formBodyToObject(body.form), null, 2) };
|
||||
}
|
||||
|
||||
return { text: formBodyToUrlEncodedText(body.form) };
|
||||
}
|
||||
|
||||
if (typeof body.query === "string") {
|
||||
if (toBodyType === BODY_TYPE_JSON || fromBodyType === BODY_TYPE_GRAPHQL) {
|
||||
const value: Record<string, unknown> = { query: body.query };
|
||||
if (typeof body.variables === "string" && body.variables.trim() !== "") {
|
||||
value.variables = parseJson(body.variables) ?? body.variables;
|
||||
}
|
||||
if (typeof body.operationName === "string" && body.operationName.trim() !== "") {
|
||||
value.operationName = body.operationName;
|
||||
}
|
||||
|
||||
return { text: JSON.stringify(value, null, 2) };
|
||||
}
|
||||
|
||||
return { text: body.query };
|
||||
}
|
||||
|
||||
if (typeof body.filePath === "string") {
|
||||
return { text: body.filePath };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formBodyToUrlEncodedText(form: unknown[]): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const pair of form) {
|
||||
if (!isRecord(pair)) continue;
|
||||
if (pair.enabled === false) continue;
|
||||
if (typeof pair.name !== "string" || pair.name === "") continue;
|
||||
params.append(pair.name, stringifyFormValue(pair.value));
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function formBodyToObject(form: unknown[]) {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const pair of form) {
|
||||
if (!isRecord(pair)) continue;
|
||||
if (pair.enabled === false) continue;
|
||||
if (typeof pair.name !== "string" || pair.name === "") continue;
|
||||
|
||||
const value = stringifyFormValue(pair.value);
|
||||
if (pair.name in result) {
|
||||
const existing = result[pair.name];
|
||||
result[pair.name] = Array.isArray(existing) ? [...existing, value] : [existing, value];
|
||||
} else {
|
||||
result[pair.name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function stringifyFormValue(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function parseJson(text: string): unknown | null {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -48,7 +48,6 @@ type TauriCmd =
|
||||
| "cmd_save_response"
|
||||
| "cmd_secure_template"
|
||||
| "cmd_send_ephemeral_request"
|
||||
| "cmd_send_feedback"
|
||||
| "cmd_send_http_request"
|
||||
| "cmd_template_function_summaries"
|
||||
| "cmd_template_function_config"
|
||||
|
||||
@@ -28,17 +28,15 @@ export function showToast({
|
||||
|
||||
setTimeout(() => {
|
||||
const newToast: ToastInstance = { id, uniqueKey, timeout, ...props };
|
||||
if (timeout != null) {
|
||||
setTimeout(() => hideToast(newToast), timeout);
|
||||
}
|
||||
jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]);
|
||||
}, delay);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export function hideToastById(id: string) {
|
||||
const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id);
|
||||
if (toast) hideToast(toast);
|
||||
}
|
||||
|
||||
export function hideToast(toHide: ToastInstance) {
|
||||
jotaiStore.set(toastsAtom, (all) => {
|
||||
const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
use log::{debug, warn};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Runtime, is_dev};
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_common::platform::get_os_str;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FeedbackPayload {
|
||||
feature: String,
|
||||
text: String,
|
||||
app_version: String,
|
||||
os: String,
|
||||
}
|
||||
|
||||
/// Send explicit user feedback for a feature. Fire-and-forget: errors are
|
||||
/// logged and swallowed so a failed send never surfaces to the user.
|
||||
pub async fn send_feedback<R: Runtime>(app_handle: &AppHandle<R>, feature: String, text: String) {
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
let payload = FeedbackPayload {
|
||||
feature,
|
||||
text,
|
||||
app_version: app_version.clone(),
|
||||
os: get_os_str().to_string(),
|
||||
};
|
||||
|
||||
let client = match yaak_api_client(ApiClientKind::App, &app_version) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
debug!("Failed to build feedback client: {e:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let url = build_url("/app-feedback");
|
||||
debug!(
|
||||
"Sending feature feedback to {url}: feature={}, app_version={}, os={}, text_len={}",
|
||||
payload.feature,
|
||||
payload.app_version,
|
||||
payload.os,
|
||||
payload.text.len()
|
||||
);
|
||||
|
||||
match client.post(&url).json(&payload).send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
debug!("Sent feature feedback with status {status}");
|
||||
} else {
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|e| format!("<failed to read response body: {e:?}>"));
|
||||
warn!("Failed to send feature feedback with status {status}: {body}");
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to send feature feedback: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_url(path: &str) -> String {
|
||||
if is_dev() {
|
||||
format!("http://localhost:9444/api/v1{path}")
|
||||
} else {
|
||||
format!("https://api.yaak.app/api/v1{path}")
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,6 @@ use yaak_tls::find_client_certificate;
|
||||
mod commands;
|
||||
mod encoding;
|
||||
mod error;
|
||||
mod feedback;
|
||||
mod git_ext;
|
||||
mod git_watcher;
|
||||
mod grpc;
|
||||
@@ -293,16 +292,6 @@ async fn cmd_render_template<R: Runtime>(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_send_feedback<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
feature: String,
|
||||
text: String,
|
||||
) -> YaakResult<()> {
|
||||
feedback::send_feedback(&app_handle, feature, text).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_dismiss_notification<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
@@ -1830,7 +1819,6 @@ pub fn run() {
|
||||
cmd_delete_send_history,
|
||||
cmd_dismiss_notification,
|
||||
cmd_export_data,
|
||||
cmd_send_feedback,
|
||||
cmd_http_request_body,
|
||||
cmd_http_response_body,
|
||||
cmd_format_json,
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use log::{debug, warn};
|
||||
#[cfg(target_os = "linux")]
|
||||
use tauri::Emitter;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::{AppHandle, Emitter, Runtime};
|
||||
|
||||
pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
|
||||
pub const INITIAL_APPEARANCE_SOURCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE_SOURCE__";
|
||||
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -47,8 +42,6 @@ impl InitialAppearanceSource {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SystemAppearanceState {
|
||||
// Only read by the Linux polling thread
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
last_appearance: Arc<Mutex<Option<Appearance>>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -191,16 +191,12 @@ fn build_url(r: &HttpRequest) -> String {
|
||||
fn append_graphql_query_params(url: &str, body: &BTreeMap<String, serde_json::Value>) -> String {
|
||||
let query = get_str_map(body, "query").to_string();
|
||||
let variables = strip_json_comments(&get_str_map(body, "variables"));
|
||||
let operation_name = get_str_map(body, "operationName").to_string();
|
||||
let mut params = vec![("query".to_string(), query)];
|
||||
if !variables.trim().is_empty() {
|
||||
params.push(("variables".to_string(), variables));
|
||||
}
|
||||
if !operation_name.trim().is_empty() {
|
||||
params.push(("operationName".to_string(), operation_name));
|
||||
}
|
||||
// Strip existing query/variables params to avoid duplicates
|
||||
let url = strip_query_params(url, &["query", "variables", "operationName"]);
|
||||
let url = strip_query_params(url, &["query", "variables"]);
|
||||
append_query_params(&url, params)
|
||||
}
|
||||
|
||||
@@ -333,30 +329,23 @@ fn build_graphql_body(
|
||||
) -> Option<SendableBodyWithMeta> {
|
||||
let query = get_str_map(body, "query");
|
||||
let variables = strip_json_comments(&get_str_map(body, "variables"));
|
||||
let operation_name = get_str_map(body, "operationName");
|
||||
|
||||
if method.to_lowercase() == "get" {
|
||||
// GraphQL GET requests use query parameters, not a body
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut body = serde_json::Map::new();
|
||||
body.insert("query".to_string(), serde_json::Value::String(query.to_string()));
|
||||
if !variables.trim().is_empty() {
|
||||
body.insert(
|
||||
"variables".to_string(),
|
||||
serde_json::from_str(&variables)
|
||||
.unwrap_or_else(|_| serde_json::Value::String(variables)),
|
||||
);
|
||||
}
|
||||
if !operation_name.trim().is_empty() {
|
||||
body.insert(
|
||||
"operationName".to_string(),
|
||||
serde_json::Value::String(operation_name.to_string()),
|
||||
);
|
||||
}
|
||||
let body = if variables.trim().is_empty() {
|
||||
format!(r#"{{"query":{}}}"#, serde_json::to_string(&query).unwrap_or_default())
|
||||
} else {
|
||||
format!(
|
||||
r#"{{"query":{},"variables":{}}}"#,
|
||||
serde_json::to_string(&query).unwrap_or_default(),
|
||||
variables
|
||||
)
|
||||
};
|
||||
|
||||
Some(SendableBodyWithMeta::Bytes(Bytes::from(serde_json::to_string(&body).unwrap_or_default())))
|
||||
Some(SendableBodyWithMeta::Bytes(Bytes::from(body)))
|
||||
}
|
||||
|
||||
async fn build_multipart_body(
|
||||
@@ -533,33 +522,6 @@ mod tests {
|
||||
assert_eq!(result, "https://example.com/api?foo=bar&baz=qux");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_replaces_graphql_operation_name_from_body() {
|
||||
let mut body = BTreeMap::new();
|
||||
body.insert("query".to_string(), json!("query Foo { foo } query Bar { bar }"));
|
||||
body.insert("operationName".to_string(), json!("Bar"));
|
||||
|
||||
let r = HttpRequest {
|
||||
method: "GET".to_string(),
|
||||
body_type: Some("graphql".to_string()),
|
||||
body,
|
||||
url: "https://example.com/graphql".to_string(),
|
||||
url_parameters: vec![HttpUrlParameter {
|
||||
enabled: true,
|
||||
name: "operationName".to_string(),
|
||||
value: "Foo".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = build_url(&r);
|
||||
assert_eq!(
|
||||
result,
|
||||
"https://example.com/graphql?query=query%20Foo%20%7B%20foo%20%7D%20query%20Bar%20%7B%20bar%20%7D&operationName=Bar",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_with_disabled_params() {
|
||||
let r = HttpRequest {
|
||||
@@ -918,34 +880,9 @@ mod tests {
|
||||
let result = build_graphql_body("POST", &body);
|
||||
match result {
|
||||
Some(SendableBodyWithMeta::Bytes(bytes)) => {
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&bytes).unwrap(),
|
||||
json!({
|
||||
"query": "{ user(id: $id) { name } }",
|
||||
"variables": { "id": "123" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected Some(SendableBody::Bytes)"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_body_with_operation_name() {
|
||||
let mut body = BTreeMap::new();
|
||||
body.insert("query".to_string(), json!("query Search { viewer { id } }"));
|
||||
body.insert("operationName".to_string(), json!("Search"));
|
||||
|
||||
let result = build_graphql_body("POST", &body);
|
||||
match result {
|
||||
Some(SendableBodyWithMeta::Bytes(bytes)) => {
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&bytes).unwrap(),
|
||||
json!({
|
||||
"query": "query Search { viewer { id } }",
|
||||
"operationName": "Search",
|
||||
}),
|
||||
);
|
||||
let expected =
|
||||
r#"{"query":"{ user(id: $id) { name } }","variables":{"id": "123"}}"#;
|
||||
assert_eq!(bytes, Bytes::from(expected));
|
||||
}
|
||||
_ => panic!("Expected Some(SendableBody::Bytes)"),
|
||||
}
|
||||
|
||||
-1
@@ -402,7 +402,6 @@ export type Settings = {
|
||||
themeLight: string;
|
||||
updateChannel: string;
|
||||
hideLicenseBadge: boolean;
|
||||
promptFeedback: boolean;
|
||||
autoupdate: boolean;
|
||||
autoDownloadUpdates: boolean;
|
||||
checkNotifications: boolean;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Add a setting to enable in-app feature feedback prompts
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN prompt_feedback BOOLEAN DEFAULT TRUE NOT NULL;
|
||||
@@ -246,7 +246,6 @@ pub struct Settings {
|
||||
pub theme_light: String,
|
||||
pub update_channel: String,
|
||||
pub hide_license_badge: bool,
|
||||
pub prompt_feedback: bool,
|
||||
pub autoupdate: bool,
|
||||
pub auto_download_updates: bool,
|
||||
pub check_notifications: bool,
|
||||
@@ -304,7 +303,6 @@ impl UpsertModelInfo for Settings {
|
||||
(ThemeLight, self.theme_light.as_str().into()),
|
||||
(UpdateChannel, self.update_channel.into()),
|
||||
(HideLicenseBadge, self.hide_license_badge.into()),
|
||||
(PromptFeedback, self.prompt_feedback.into()),
|
||||
(Autoupdate, self.autoupdate.into()),
|
||||
(AutoDownloadUpdates, self.auto_download_updates.into()),
|
||||
(ColoredMethods, self.colored_methods.into()),
|
||||
@@ -334,7 +332,6 @@ impl UpsertModelInfo for Settings {
|
||||
SettingsIden::ThemeLight,
|
||||
SettingsIden::UpdateChannel,
|
||||
SettingsIden::HideLicenseBadge,
|
||||
SettingsIden::PromptFeedback,
|
||||
SettingsIden::Autoupdate,
|
||||
SettingsIden::AutoDownloadUpdates,
|
||||
SettingsIden::ColoredMethods,
|
||||
@@ -375,7 +372,6 @@ impl UpsertModelInfo for Settings {
|
||||
autoupdate: row.get("autoupdate")?,
|
||||
auto_download_updates: row.get("auto_download_updates")?,
|
||||
hide_license_badge: row.get("hide_license_badge")?,
|
||||
prompt_feedback: row.get("prompt_feedback")?,
|
||||
colored_methods: row.get("colored_methods")?,
|
||||
check_notifications: row.get("check_notifications")?,
|
||||
hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(),
|
||||
|
||||
@@ -38,7 +38,6 @@ impl<'a> ClientDb<'a> {
|
||||
autoupdate: true,
|
||||
colored_methods: false,
|
||||
hide_license_badge: false,
|
||||
prompt_feedback: true,
|
||||
auto_download_updates: true,
|
||||
check_notifications: true,
|
||||
hotkeys: HashMap::new(),
|
||||
|
||||
+57
-15
@@ -511,7 +511,10 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
.map_err(SendHttpRequestError::PrepareSendableRequest)?;
|
||||
}
|
||||
|
||||
let request_content_length = sendable_body_length(sendable_request.body.as_ref());
|
||||
let request_content_length = match sendable_request.body.as_ref() {
|
||||
Some(SendableBody::Bytes(_)) => sendable_body_length(sendable_request.body.as_ref()),
|
||||
Some(SendableBody::Stream { .. }) | None => None,
|
||||
};
|
||||
let mut response = params.existing_response.unwrap_or_default();
|
||||
response.request_id = params.request.id.clone();
|
||||
response.workspace_id = params.request.workspace_id.clone();
|
||||
@@ -811,16 +814,6 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
})?;
|
||||
drop(body_stream);
|
||||
|
||||
if let Some(task) = request_body_capture_task.take() {
|
||||
match task.await {
|
||||
Ok(Ok(total)) => {
|
||||
response.request_content_length = Some(usize_to_i32(total));
|
||||
}
|
||||
Ok(Err(err)) => request_body_capture_error = Some(err),
|
||||
Err(err) => request_body_capture_error = Some(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(err) = request_body_capture_error.take() {
|
||||
response.error = Some(append_error_message(
|
||||
response.error.take(),
|
||||
@@ -828,10 +821,6 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(join_err) = event_handle.await {
|
||||
warn!("Failed to join response event task: {}", join_err);
|
||||
}
|
||||
|
||||
if let Some(err) = body_read_error {
|
||||
if persist_response {
|
||||
let _ = persist_response_error(
|
||||
@@ -849,6 +838,16 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
cookie_jar.as_mut(),
|
||||
cookie_behavior.store.as_ref(),
|
||||
)?;
|
||||
if let Some(task) = request_body_capture_task.take() {
|
||||
match task.await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(err)) => warn!("Failed to store request body after response error: {err}"),
|
||||
Err(err) => warn!("Failed to join request body capture task: {err}"),
|
||||
}
|
||||
}
|
||||
if let Err(join_err) = event_handle.await {
|
||||
warn!("Failed to join response event task: {}", join_err);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
@@ -875,6 +874,49 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
|
||||
persist_cookie_jar(params.query_manager, cookie_jar.as_mut(), cookie_behavior.store.as_ref())?;
|
||||
|
||||
// Request-body history can be much larger than the response. It should not keep the
|
||||
// response in a loading state after the network/response-body work has completed.
|
||||
if let Some(task) = request_body_capture_task.take() {
|
||||
let mut update_response = false;
|
||||
match task.await {
|
||||
Ok(Ok(total)) => {
|
||||
let total = Some(usize_to_i32(total));
|
||||
if response.request_content_length != total {
|
||||
response.request_content_length = total;
|
||||
update_response = true;
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
response.error = Some(append_error_message(
|
||||
response.error.take(),
|
||||
format!("Request succeeded but failed to store request body: {err}"),
|
||||
));
|
||||
update_response = true;
|
||||
}
|
||||
Err(err) => {
|
||||
response.error = Some(append_error_message(
|
||||
response.error.take(),
|
||||
format!("Request succeeded but failed to store request body: {err}"),
|
||||
));
|
||||
update_response = true;
|
||||
}
|
||||
}
|
||||
|
||||
if update_response && persist_response {
|
||||
response = params
|
||||
.query_manager
|
||||
.connect()
|
||||
.upsert_http_response(&response, ¶ms.update_source, params.blob_manager)
|
||||
.map_err(SendHttpRequestError::PersistResponse)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Timeline events are useful history, but they should not keep the response in a loading state
|
||||
// after the network/response-body work has completed.
|
||||
if let Err(join_err) = event_handle.await {
|
||||
warn!("Failed to join response event task: {}", join_err);
|
||||
}
|
||||
|
||||
Ok(SendHttpRequestResult { rendered_request, response, response_body })
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
|
||||
const body = {
|
||||
query: request.body.query || "",
|
||||
variables: maybeParseJSON(request.body.variables, undefined),
|
||||
operationName: request.body.operationName || undefined,
|
||||
};
|
||||
xs.push("--data", quote(JSON.stringify(body)));
|
||||
xs.push(NEWLINE);
|
||||
|
||||
@@ -66,25 +66,6 @@ describe("exporter-curl", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("Exports POST with GraphQL operation name", async () => {
|
||||
expect(
|
||||
await convertToCurl({
|
||||
url: "https://yaak.app",
|
||||
method: "POST",
|
||||
bodyType: "graphql",
|
||||
body: {
|
||||
query: "query Foo { foo } query Bar { bar }",
|
||||
operationName: "Foo",
|
||||
},
|
||||
}),
|
||||
).toEqual(
|
||||
[
|
||||
`curl -X POST 'https://yaak.app'`,
|
||||
`--data '{"query":"query Foo { foo } query Bar { bar }","operationName":"Foo"}'`,
|
||||
].join(" \\\n "),
|
||||
);
|
||||
});
|
||||
|
||||
test("Exports POST with GraphQL data no variables", async () => {
|
||||
expect(
|
||||
await convertToCurl({
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
type GraphQLDetectionSignal = {
|
||||
score: number;
|
||||
requiresGraphQLDocument?: boolean;
|
||||
};
|
||||
|
||||
export type GraphQLJsonBody = {
|
||||
query: string;
|
||||
variables?: string;
|
||||
operationName?: string;
|
||||
};
|
||||
|
||||
type GraphQLJsonBodyArgs = {
|
||||
mimeType: string | null;
|
||||
text: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export function isGraphQLJsonBody(args: GraphQLJsonBodyArgs): boolean {
|
||||
return parseGraphQLJsonBody(args) != null;
|
||||
}
|
||||
|
||||
export function parseGraphQLJsonBody({
|
||||
mimeType,
|
||||
text,
|
||||
url,
|
||||
}: GraphQLJsonBodyArgs): GraphQLJsonBody | null {
|
||||
if (mimeType !== "application/json") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = parsed as Record<string, unknown>;
|
||||
if (typeof body.query !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasExtraGraphQLEnvelopeFields(body)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const signals = getGraphQLDetectionSignals(body, url);
|
||||
const score = signals.reduce((total, signal) => total + signal.score, 0);
|
||||
const hasGraphQLDocument = signals.some((signal) => signal.requiresGraphQLDocument);
|
||||
if (!hasGraphQLDocument || score < 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: GraphQLJsonBody = { query: body.query };
|
||||
if (body.variables != null) {
|
||||
result.variables =
|
||||
typeof body.variables === "string" ? body.variables : JSON.stringify(body.variables, null, 2);
|
||||
}
|
||||
if (typeof body.operationName === "string") {
|
||||
result.operationName = body.operationName;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function hasExtraGraphQLEnvelopeFields(body: Record<string, unknown>): boolean {
|
||||
const allowedKeys = new Set(["query", "variables", "operationName"]);
|
||||
return Object.keys(body).some((key) => !allowedKeys.has(key));
|
||||
}
|
||||
|
||||
function getGraphQLDetectionSignals(
|
||||
body: Record<string, unknown>,
|
||||
url: string,
|
||||
): GraphQLDetectionSignal[] {
|
||||
const signals: GraphQLDetectionSignal[] = [];
|
||||
const query = body.query as string;
|
||||
const urlPath = getUrlPath(url).toLowerCase();
|
||||
|
||||
if (/\b(graphql|gql)\b/.test(urlPath)) {
|
||||
signals.push({ score: 2 });
|
||||
}
|
||||
|
||||
if (/^(query|mutation|subscription|fragment)\b/.test(query.trim())) {
|
||||
signals.push({ score: 3 });
|
||||
} else if (/^\{[\s\S]*\}$/.test(query.trim())) {
|
||||
signals.push({ score: 3, requiresGraphQLDocument: true });
|
||||
}
|
||||
|
||||
if (/\{[\s\S]*\}/.test(query)) {
|
||||
signals.push({ score: 1, requiresGraphQLDocument: true });
|
||||
}
|
||||
|
||||
if (typeof body.operationName === "string" && body.operationName.trim() !== "") {
|
||||
signals.push({ score: 1 });
|
||||
}
|
||||
|
||||
if (
|
||||
body.variables != null &&
|
||||
(typeof body.variables === "object" || typeof body.variables === "string")
|
||||
) {
|
||||
signals.push({ score: 1 });
|
||||
}
|
||||
|
||||
return signals;
|
||||
}
|
||||
|
||||
function getUrlPath(url: string): string {
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
Workspace,
|
||||
} from "@yaakapp/api";
|
||||
import { split } from "shlex";
|
||||
import { parseGraphQLJsonBody } from "./graphql";
|
||||
|
||||
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||
|
||||
@@ -465,8 +464,6 @@ function importCommand(parseEntries: string[], workspaceId: string) {
|
||||
let body = {};
|
||||
let bodyType: string | null = null;
|
||||
const bodyAsGET = getPairValue(flagsByName, false, ["G", "get"]);
|
||||
const hasDataBody = dataParameters.length > 0 && !bodyAsGET;
|
||||
const hasFormBody = multipartFormDataFromRaw != null || formDataParams.length > 0;
|
||||
|
||||
if (multipartFormDataFromRaw) {
|
||||
// Handle multipart form data parsed from --data-raw (Chrome DevTools format)
|
||||
@@ -494,21 +491,15 @@ function importCommand(parseEntries: string[], workspaceId: string) {
|
||||
enabled: true,
|
||||
});
|
||||
} else if (dataParameters.length > 0) {
|
||||
const text = dataParameters
|
||||
bodyType =
|
||||
mimeType === "application/json" || mimeType === "text/xml" || mimeType === "text/plain"
|
||||
? mimeType
|
||||
: "other";
|
||||
body = {
|
||||
text: dataParameters
|
||||
.map(({ name, value }) => (name && value ? `${name}=${value}` : name || value))
|
||||
.join("&");
|
||||
const graphqlBody = parseGraphQLJsonBody({ mimeType, text, url });
|
||||
|
||||
if (graphqlBody != null) {
|
||||
bodyType = "graphql";
|
||||
body = graphqlBody;
|
||||
} else if (mimeType === "application/json" || mimeType === "text/xml" || mimeType === "text/plain") {
|
||||
bodyType = mimeType;
|
||||
body = { text };
|
||||
} else {
|
||||
bodyType = "other";
|
||||
body = { text };
|
||||
}
|
||||
.join("&"),
|
||||
};
|
||||
} else if (formDataParams.length) {
|
||||
bodyType = mimeType ?? "multipart/form-data";
|
||||
body = {
|
||||
@@ -526,8 +517,8 @@ function importCommand(parseEntries: string[], workspaceId: string) {
|
||||
// Method
|
||||
let method = getPairValue(flagsByName, "", ["X", "request"]).toUpperCase();
|
||||
|
||||
if (method === "") {
|
||||
method = hasDataBody || hasFormBody ? "POST" : "GET";
|
||||
if (method === "" && body) {
|
||||
method = "text" in body || "form" in body ? "POST" : "GET";
|
||||
}
|
||||
|
||||
const request: ExportResources["httpRequests"][0] = {
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isGraphQLJsonBody, parseGraphQLJsonBody } from "../src/graphql";
|
||||
|
||||
describe("isGraphQLJsonBody", () => {
|
||||
test("detects named query documents without a GraphQL URL", () => {
|
||||
const args = {
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify({
|
||||
query: "query Search($id: ID!) { node(id: $id) { id } }",
|
||||
variables: { id: "123" },
|
||||
operationName: "Search",
|
||||
}),
|
||||
url: "https://api.example.com/search",
|
||||
};
|
||||
|
||||
expect(isGraphQLJsonBody(args)).toBe(true);
|
||||
expect(parseGraphQLJsonBody(args)).toEqual({
|
||||
query: "query Search($id: ID!) { node(id: $id) { id } }",
|
||||
variables: '{\n "id": "123"\n}',
|
||||
operationName: "Search",
|
||||
});
|
||||
});
|
||||
|
||||
test("detects mutation documents", () => {
|
||||
expect(
|
||||
isGraphQLJsonBody({
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify({ query: "mutation Save { saveThing { id } }" }),
|
||||
url: "https://api.example.com",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("detects anonymous selection set documents", () => {
|
||||
expect(
|
||||
isGraphQLJsonBody({
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify({ query: "{ viewer { id email } }" }),
|
||||
url: "https://api.example.com",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("detects document bodies on GraphQL-looking paths", () => {
|
||||
expect(
|
||||
isGraphQLJsonBody({
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify({ query: "query Search { viewer { id } }", operationName: "Search" }),
|
||||
url: "https://api.example.com/v1/graphql",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("does not detect incomplete operation documents even on GraphQL-looking paths", () => {
|
||||
expect(
|
||||
isGraphQLJsonBody({
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify({ query: "query Search", operationName: "Search" }),
|
||||
url: "https://api.example.com/graphql",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("does not detect plain JSON query fields even on GraphQL-looking paths", () => {
|
||||
expect(
|
||||
isGraphQLJsonBody({
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify({ query: "SearchQueryInput!" }),
|
||||
url: "https://api.example.com/graphql",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("does not use variables and operationName alone as enough evidence", () => {
|
||||
expect(
|
||||
isGraphQLJsonBody({
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify({
|
||||
query: "SearchQueryInput!",
|
||||
variables: { id: "123" },
|
||||
operationName: "Search",
|
||||
}),
|
||||
url: "https://api.example.com",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("detects bodies with string variables without parsing them", () => {
|
||||
const args = {
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify({
|
||||
query: "query Search($id: ID!) { node(id: $id) { id } }",
|
||||
variables: '{ "id": "123" }',
|
||||
}),
|
||||
url: "https://api.example.com",
|
||||
};
|
||||
|
||||
expect(isGraphQLJsonBody(args)).toBe(true);
|
||||
expect(parseGraphQLJsonBody(args)).toEqual({
|
||||
query: "query Search($id: ID!) { node(id: $id) { id } }",
|
||||
variables: '{ "id": "123" }',
|
||||
});
|
||||
});
|
||||
|
||||
test("does not detect GraphQL envelopes with extra fields", () => {
|
||||
const args = {
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify({
|
||||
query: "query Search($id: ID!) { node(id: $id) { id } }",
|
||||
variables: { id: "123" },
|
||||
extensions: { persistedQuery: { version: 1, sha256Hash: "abc123" } },
|
||||
}),
|
||||
url: "https://api.example.com/graphql",
|
||||
};
|
||||
|
||||
expect(isGraphQLJsonBody(args)).toBe(false);
|
||||
expect(parseGraphQLJsonBody(args)).toBeNull();
|
||||
});
|
||||
|
||||
test("ignores invalid JSON and non-object JSON", () => {
|
||||
expect(
|
||||
isGraphQLJsonBody({
|
||||
mimeType: "application/json",
|
||||
text: "not json",
|
||||
url: "https://api.example.com/graphql",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isGraphQLJsonBody({
|
||||
mimeType: "application/json",
|
||||
text: "[]",
|
||||
url: "https://api.example.com/graphql",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("ignores non-JSON MIME types", () => {
|
||||
expect(
|
||||
isGraphQLJsonBody({
|
||||
mimeType: "text/plain",
|
||||
text: JSON.stringify({ query: "query Search { viewer { id } }" }),
|
||||
url: "https://api.example.com/graphql",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -562,53 +562,6 @@ describe("importer-curl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("Imports GraphQL JSON data as a GraphQL request", () => {
|
||||
expect(
|
||||
convertCurl(
|
||||
`curl 'https://yaak.app/graphql' -H 'Content-Type: application/json' --data-raw $'{"query":"query Search($id: ID\\u0021) { node(id: $id) { id } }","variables":{"id":"123"}}'`,
|
||||
),
|
||||
).toEqual({
|
||||
resources: {
|
||||
workspaces: [baseWorkspace()],
|
||||
httpRequests: [
|
||||
baseRequest({
|
||||
url: "https://yaak.app/graphql",
|
||||
method: "POST",
|
||||
headers: [{ name: "Content-Type", value: "application/json", enabled: true }],
|
||||
bodyType: "graphql",
|
||||
body: {
|
||||
query: "query Search($id: ID!) { node(id: $id) { id } }",
|
||||
variables: '{\n "id": "123"\n}',
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("Imports GraphQL JSON with extensions as JSON", () => {
|
||||
expect(
|
||||
convertCurl(
|
||||
`curl 'https://yaak.app/graphql' -H 'Content-Type: application/json' --data-raw $'{"query":"query Search($id: ID\\u0021) { node(id: $id) { id } }","extensions":{"persistedQuery":{"version":1,"sha256Hash":"abc123"}}}'`,
|
||||
),
|
||||
).toEqual({
|
||||
resources: {
|
||||
workspaces: [baseWorkspace()],
|
||||
httpRequests: [
|
||||
baseRequest({
|
||||
url: "https://yaak.app/graphql",
|
||||
method: "POST",
|
||||
headers: [{ name: "Content-Type", value: "application/json", enabled: true }],
|
||||
bodyType: "application/json",
|
||||
body: {
|
||||
text: '{"query":"query Search($id: ID!) { node(id: $id) { id } }","extensions":{"persistedQuery":{"version":1,"sha256Hash":"abc123"}}}',
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("Imports data with multiple escape sequences", () => {
|
||||
expect(
|
||||
convertCurl(
|
||||
|
||||
Reference in New Issue
Block a user