Files
yaak/apps/yaak-client/components/TemplateFunctionDialog.tsx
Gregory Schier ee69db0f12 Align lint fixes with main and resolve merge conflicts
- Convert biome-ignore to oxlint-disable-next-line across client app
- Fix no-base-to-string with type narrowing instead of suppressions
- Fix no-floating-promises with fireAndForget() in proxy app
- Fix restrict-template-expressions with String() wrapping
- Resolve leftover merge conflict markers in manager.rs
- Remove duplicate cmd_plugin_init_errors from lib.rs
- Add graphql as explicit dependency in yaak-client

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:02:29 -07:00

297 lines
10 KiB
TypeScript

import type { EditorView } from "@codemirror/view";
import type {
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from "@yaakapp-internal/models";
import type { TemplateFunction } from "@yaakapp-internal/plugins";
import type { FnArg, Tokens } from "@yaakapp-internal/templates";
import { parseTemplate } from "@yaakapp-internal/templates";
import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useEffect, useMemo, useState } from "react";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
import {
templateTokensToString,
useTemplateTokensToString,
} from "../hooks/useTemplateTokensToString";
import { useToggle } from "../hooks/useToggle";
import { showDialog } from "../lib/dialog";
import { convertTemplateToInsecure } from "../lib/encryption";
import { jotaiStore } from "../lib/jotai";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { Button } from "./core/Button";
import { collectArgumentValues } from "./core/Editor/twig/util";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from "./DynamicForm";
interface Props {
templateFunction: TemplateFunction;
initialTokens: Tokens;
hide: () => void;
onChange: (insert: string) => void;
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
}
export function TemplateFunctionDialog({ initialTokens, templateFunction, ...props }: Props) {
const [initialArgValues, setInitialArgValues] = useState<Record<string, string | boolean> | null>(
null,
);
useEffect(() => {
if (initialArgValues != null) {
return;
}
(async () => {
const initial = collectArgumentValues(initialTokens, templateFunction);
// HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so
// we can display it in the editor input.
if (templateFunction.name === "secure") {
const template = await templateTokensToString(initialTokens);
initial.value = await convertTemplateToInsecure(template);
}
setInitialArgValues(initial);
})().catch(console.error);
}, [
initialArgValues,
initialTokens,
initialTokens.tokens,
templateFunction,
templateFunction.args,
templateFunction.name,
]);
if (initialArgValues == null) return null;
return (
<InitializedTemplateFunctionDialog
{...props}
templateFunction={templateFunction}
initialArgValues={initialArgValues}
/>
);
}
function InitializedTemplateFunctionDialog({
templateFunction: { name, previewType: ogPreviewType },
initialArgValues,
hide,
onChange,
model,
}: Omit<Props, "initialTokens"> & {
initialArgValues: Record<string, string | boolean>;
}) {
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
const tokens: Tokens = useMemo(() => {
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
name,
value:
argValues[name] === DYNAMIC_FORM_NULL_ARG
? { type: "null" }
: typeof argValues[name] === "boolean"
? { type: "bool", value: argValues[name] === true }
: { type: "str", text: String(argValues[name] ?? "") },
}));
return {
tokens: [
{
type: "tag",
val: {
type: "fn",
name,
args: argTokens,
},
},
],
};
}, [argValues, name]);
const tagText = useTemplateTokensToString(tokens);
const templateFunction = useTemplateFunctionConfig(name, argValues, model);
const handleDone = () => {
if (tagText.data) {
onChange(tagText.data);
}
hide();
};
const debouncedTagText = useDebouncedValue(tagText.data ?? "", 400);
const [renderKey, setRenderKey] = useState<string | null>(null);
const rendered = useRenderTemplate({
template: debouncedTagText,
enabled: previewType !== "none",
purpose: previewType === "click" ? "send" : "preview",
refreshKey: previewType === "live" ? renderKey + debouncedTagText : renderKey,
ignoreError: false,
});
const tooLarge = rendered.data ? rendered.data.length > 10000 : false;
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only update this on rendered data change to keep secrets hidden on input change
const dataContainsSecrets = useMemo(() => {
for (const [name, value] of Object.entries(argValues)) {
const arg = templateFunction.data?.args.find((a) => "name" in a && a.name === name);
const isTextPassword = arg?.type === "text" && arg.password;
if (isTextPassword && typeof value === "string" && value && rendered.data?.includes(value)) {
return true;
}
}
return false;
}, [rendered.data]);
if (templateFunction.data == null || templateFunction.isPending) {
return (
<div className="h-full w-full flex items-center justify-center">
<LoadingIcon size="xl" className="text-text-subtlest" />
</div>
);
}
return (
<form
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
onSubmit={(e) => {
e.preventDefault();
handleDone();
}}
>
<div className="overflow-y-auto h-full px-6">
{name === "secure" ? (
<PlainInput
required
label="Value"
name="value"
type="password"
placeholder="••••••••••••"
defaultValue={String(argValues.value ?? "")}
onChange={(value) => setArgValues({ ...argValues, value })}
/>
) : (
<DynamicForm
autocompleteVariables
autocompleteFunctions
inputs={templateFunction.data.args}
data={argValues}
onChange={setArgValues}
stateKey={`template_function.${templateFunction.data.name}`}
/>
)}
</div>
<div className="px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4">
{previewType !== "none" ? (
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isLoading && <LoadingIcon size="xs" />}
</HStack>
<IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? "lock" : "lock_open"}
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
onClick={toggleShowSecretsInPreview}
className={classNames(
"ml-auto text-text-subtlest",
!dataContainsSecrets && "invisible",
)}
/>
</HStack>
<div className="relative w-full max-h-[10rem]">
<InlineCode
className={classNames(
"block whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-auto hide-scrollbars !border-text-subtlest",
tooLarge && "italic text-danger",
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
"too large to preview"
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
<IconButton
size="xs"
icon="refresh"
className="text-text-subtle"
title="Refresh preview"
spin={rendered.isPending}
onClick={() => {
setRenderKey(new Date().toISOString());
}}
/>
</div>
</div>
</div>
) : (
<span />
)}
<div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1">
{templateFunction.data.name === "secure" && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key
</Button>
)}
<Button type="submit" color="primary">
Save
</Button>
</div>
</div>
</form>
);
}
TemplateFunctionDialog.show = (
fn: TemplateFunction,
tagValue: string,
startPos: number,
view: EditorView,
) => {
const initialTokens = parseTemplate(tagValue);
showDialog({
id: `template-function-${Math.random()}`, // Allow multiple at once
size: "md",
className: "h-[60rem]",
noPadding: true,
title: <InlineCode>{fn.name}()</InlineCode>,
description: fn.description,
render: ({ hide }) => {
const model = jotaiStore.get(activeWorkspaceAtom);
if (model == null) return null;
return (
<TemplateFunctionDialog
templateFunction={fn}
model={model}
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
);
},
});
};