mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-25 10:51:57 +01:00
Add .oxfmtignore to skip generated bindings and wasm-pack output. Add npm format script, update DEVELOPMENT.md for Vite+ toolchain, and format all non-generated files with oxfmt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
630 lines
18 KiB
TypeScript
630 lines
18 KiB
TypeScript
import type { Folder, HttpRequest } from "@yaakapp-internal/models";
|
|
import { foldersAtom, httpRequestsAtom } from "@yaakapp-internal/models";
|
|
import type {
|
|
FormInput,
|
|
FormInputCheckbox,
|
|
FormInputEditor,
|
|
FormInputFile,
|
|
FormInputHttpRequest,
|
|
FormInputKeyValue,
|
|
FormInputSelect,
|
|
FormInputText,
|
|
JsonPrimitive,
|
|
} from "@yaakapp-internal/plugins";
|
|
import classNames from "classnames";
|
|
import { useAtomValue } from "jotai";
|
|
import { useCallback, useEffect, useMemo } from "react";
|
|
import { useActiveRequest } from "../hooks/useActiveRequest";
|
|
import { useRandomKey } from "../hooks/useRandomKey";
|
|
import { capitalize } from "../lib/capitalize";
|
|
import { showDialog } from "../lib/dialog";
|
|
import { resolvedModelName } from "../lib/resolvedModelName";
|
|
import { Banner } from "./core/Banner";
|
|
import { Checkbox } from "./core/Checkbox";
|
|
import { DetailsBanner } from "./core/DetailsBanner";
|
|
import { Editor } from "./core/Editor/LazyEditor";
|
|
import { IconButton } from "./core/IconButton";
|
|
import type { InputProps } from "./core/Input";
|
|
import { Input } from "./core/Input";
|
|
import { Label } from "./core/Label";
|
|
import type { Pair } from "./core/PairEditor";
|
|
import { PairEditor } from "./core/PairEditor";
|
|
import { PlainInput } from "./core/PlainInput";
|
|
import { Select } from "./core/Select";
|
|
import { VStack } from "./core/Stacks";
|
|
import { Markdown } from "./Markdown";
|
|
import { SelectFile } from "./SelectFile";
|
|
|
|
export const DYNAMIC_FORM_NULL_ARG = "__NULL__";
|
|
const INPUT_SIZE = "sm";
|
|
|
|
interface Props<T> {
|
|
inputs: FormInput[] | undefined | null;
|
|
onChange: (value: T) => void;
|
|
data: T;
|
|
autocompleteFunctions?: boolean;
|
|
autocompleteVariables?: boolean;
|
|
stateKey: string;
|
|
className?: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
|
inputs,
|
|
data,
|
|
onChange,
|
|
autocompleteVariables,
|
|
autocompleteFunctions,
|
|
stateKey,
|
|
className,
|
|
disabled,
|
|
}: Props<T>) {
|
|
const setDataAttr = useCallback(
|
|
(name: string, value: JsonPrimitive) => {
|
|
onChange({ ...data, [name]: value === DYNAMIC_FORM_NULL_ARG ? undefined : value });
|
|
},
|
|
[data, onChange],
|
|
);
|
|
|
|
return (
|
|
<FormInputsStack
|
|
disabled={disabled}
|
|
inputs={inputs}
|
|
setDataAttr={setDataAttr}
|
|
stateKey={stateKey}
|
|
autocompleteFunctions={autocompleteFunctions}
|
|
autocompleteVariables={autocompleteVariables}
|
|
data={data}
|
|
className={classNames(className, "pb-4")} // Pad the bottom to look nice
|
|
/>
|
|
);
|
|
}
|
|
|
|
function FormInputsStack<T extends Record<string, JsonPrimitive>>({
|
|
className,
|
|
...props
|
|
}: FormInputsProps<T> & { className?: string }) {
|
|
return (
|
|
<VStack
|
|
space={3}
|
|
className={classNames(
|
|
className,
|
|
"h-full overflow-auto",
|
|
"pr-1", // A bit of space between inputs and scrollbar
|
|
)}
|
|
>
|
|
<FormInputs {...props} />
|
|
</VStack>
|
|
);
|
|
}
|
|
|
|
type FormInputsProps<T> = Pick<
|
|
Props<T>,
|
|
"inputs" | "autocompleteFunctions" | "autocompleteVariables" | "stateKey" | "data"
|
|
> & {
|
|
setDataAttr: (name: string, value: JsonPrimitive) => void;
|
|
disabled?: boolean;
|
|
};
|
|
|
|
function FormInputs<T extends Record<string, JsonPrimitive>>({
|
|
inputs,
|
|
autocompleteFunctions,
|
|
autocompleteVariables,
|
|
stateKey,
|
|
setDataAttr,
|
|
data,
|
|
disabled,
|
|
}: FormInputsProps<T>) {
|
|
return (
|
|
<>
|
|
{inputs?.map((input, i) => {
|
|
if ("hidden" in input && input.hidden) {
|
|
return null;
|
|
}
|
|
|
|
if ("disabled" in input && disabled != null) {
|
|
input.disabled = disabled;
|
|
}
|
|
|
|
switch (input.type) {
|
|
case "select":
|
|
return (
|
|
<SelectArg
|
|
key={i + stateKey}
|
|
arg={input}
|
|
onChange={(v) => setDataAttr(input.name, v)}
|
|
value={
|
|
data[input.name]
|
|
? String(data[input.name])
|
|
: (input.defaultValue ?? DYNAMIC_FORM_NULL_ARG)
|
|
}
|
|
/>
|
|
);
|
|
case "text":
|
|
return (
|
|
<TextArg
|
|
key={i + stateKey}
|
|
stateKey={stateKey}
|
|
arg={input}
|
|
autocompleteFunctions={autocompleteFunctions || false}
|
|
autocompleteVariables={autocompleteVariables || false}
|
|
onChange={(v) => setDataAttr(input.name, v)}
|
|
value={
|
|
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
|
|
}
|
|
/>
|
|
);
|
|
case "editor":
|
|
return (
|
|
<EditorArg
|
|
key={i + stateKey}
|
|
stateKey={stateKey}
|
|
arg={input}
|
|
autocompleteFunctions={autocompleteFunctions || false}
|
|
autocompleteVariables={autocompleteVariables || false}
|
|
onChange={(v) => setDataAttr(input.name, v)}
|
|
value={
|
|
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
|
|
}
|
|
/>
|
|
);
|
|
case "checkbox":
|
|
return (
|
|
<CheckboxArg
|
|
key={i + stateKey}
|
|
arg={input}
|
|
onChange={(v) => setDataAttr(input.name, v)}
|
|
value={data[input.name] != null ? data[input.name] === true : false}
|
|
/>
|
|
);
|
|
case "http_request":
|
|
return (
|
|
<HttpRequestArg
|
|
key={i + stateKey}
|
|
arg={input}
|
|
onChange={(v) => setDataAttr(input.name, v)}
|
|
value={data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG}
|
|
/>
|
|
);
|
|
case "file":
|
|
return (
|
|
<FileArg
|
|
key={i + stateKey}
|
|
arg={input}
|
|
onChange={(v) => setDataAttr(input.name, v)}
|
|
filePath={
|
|
data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG
|
|
}
|
|
/>
|
|
);
|
|
case "accordion":
|
|
if (!hasVisibleInputs(input.inputs)) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div key={i + stateKey}>
|
|
<DetailsBanner
|
|
summary={input.label}
|
|
className={classNames("!mb-auto", disabled && "opacity-disabled")}
|
|
>
|
|
<div className="mt-3">
|
|
<FormInputsStack
|
|
data={data}
|
|
disabled={disabled}
|
|
inputs={input.inputs}
|
|
setDataAttr={setDataAttr}
|
|
stateKey={stateKey}
|
|
autocompleteFunctions={autocompleteFunctions || false}
|
|
autocompleteVariables={autocompleteVariables}
|
|
/>
|
|
</div>
|
|
</DetailsBanner>
|
|
</div>
|
|
);
|
|
case "h_stack":
|
|
if (!hasVisibleInputs(input.inputs)) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div className="flex flex-wrap sm:flex-nowrap gap-3 items-end" key={i + stateKey}>
|
|
<FormInputs
|
|
data={data}
|
|
disabled={disabled}
|
|
inputs={input.inputs}
|
|
setDataAttr={setDataAttr}
|
|
stateKey={stateKey}
|
|
autocompleteFunctions={autocompleteFunctions || false}
|
|
autocompleteVariables={autocompleteVariables}
|
|
/>
|
|
</div>
|
|
);
|
|
case "banner":
|
|
if (!hasVisibleInputs(input.inputs)) {
|
|
return null;
|
|
}
|
|
return (
|
|
<Banner
|
|
key={i + stateKey}
|
|
color={input.color}
|
|
className={classNames(disabled && "opacity-disabled")}
|
|
>
|
|
<FormInputsStack
|
|
data={data}
|
|
disabled={disabled}
|
|
inputs={input.inputs}
|
|
setDataAttr={setDataAttr}
|
|
stateKey={stateKey}
|
|
autocompleteFunctions={autocompleteFunctions || false}
|
|
autocompleteVariables={autocompleteVariables}
|
|
/>
|
|
</Banner>
|
|
);
|
|
case "markdown":
|
|
return <Markdown key={i + stateKey}>{input.content}</Markdown>;
|
|
case "key_value":
|
|
return (
|
|
<KeyValueArg
|
|
key={i + stateKey}
|
|
arg={input}
|
|
stateKey={stateKey}
|
|
onChange={(v) => setDataAttr(input.name, v)}
|
|
value={
|
|
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "[]")
|
|
}
|
|
/>
|
|
);
|
|
default:
|
|
// @ts-expect-error
|
|
throw new Error(`Invalid input type: ${input.type}`);
|
|
}
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TextArg({
|
|
arg,
|
|
onChange,
|
|
value,
|
|
autocompleteFunctions,
|
|
autocompleteVariables,
|
|
stateKey,
|
|
}: {
|
|
arg: FormInputText;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
autocompleteFunctions: boolean;
|
|
autocompleteVariables: boolean;
|
|
stateKey: string;
|
|
}) {
|
|
const props: InputProps = {
|
|
onChange,
|
|
name: arg.name,
|
|
multiLine: arg.multiLine,
|
|
className: arg.multiLine ? "min-h-[4rem]" : undefined,
|
|
defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value,
|
|
required: !arg.optional,
|
|
disabled: arg.disabled,
|
|
help: arg.description,
|
|
type: arg.password ? "password" : "text",
|
|
label: arg.label ?? arg.name,
|
|
size: INPUT_SIZE,
|
|
hideLabel: arg.hideLabel ?? arg.label == null,
|
|
placeholder: arg.placeholder ?? undefined,
|
|
forceUpdateKey: stateKey,
|
|
autocomplete: arg.completionOptions ? { options: arg.completionOptions } : undefined,
|
|
stateKey,
|
|
autocompleteFunctions,
|
|
autocompleteVariables,
|
|
};
|
|
if (autocompleteVariables || autocompleteFunctions || arg.completionOptions) {
|
|
return <Input {...props} />;
|
|
}
|
|
return <PlainInput {...props} />;
|
|
}
|
|
|
|
function EditorArg({
|
|
arg,
|
|
onChange,
|
|
value,
|
|
autocompleteFunctions,
|
|
autocompleteVariables,
|
|
stateKey,
|
|
}: {
|
|
arg: FormInputEditor;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
autocompleteFunctions: boolean;
|
|
autocompleteVariables: boolean;
|
|
stateKey: string;
|
|
}) {
|
|
const id = `input-${arg.name}`;
|
|
|
|
// Read-only editor force refresh for every defaultValue change
|
|
// Should this be built into the <Editor/> component?
|
|
const [popoutKey, regeneratePopoutKey] = useRandomKey();
|
|
const forceUpdateKey = popoutKey + (arg.readOnly ? arg.defaultValue + stateKey : stateKey);
|
|
|
|
return (
|
|
<div className="w-full grid grid-cols-1 grid-rows-[auto_minmax(0,1fr)]">
|
|
<Label
|
|
htmlFor={id}
|
|
required={!arg.optional}
|
|
visuallyHidden={arg.hideLabel}
|
|
help={arg.description}
|
|
tags={arg.language ? [capitalize(arg.language)] : undefined}
|
|
>
|
|
{arg.label}
|
|
</Label>
|
|
<div
|
|
className={classNames(
|
|
"border border-border rounded-md overflow-hidden px-2 py-1",
|
|
"focus-within:border-border-focus",
|
|
!arg.rows && "max-h-[10rem]", // So it doesn't take up too much space
|
|
)}
|
|
style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}
|
|
>
|
|
<Editor
|
|
id={id}
|
|
autocomplete={arg.completionOptions ? { options: arg.completionOptions } : undefined}
|
|
disabled={arg.disabled}
|
|
language={arg.language}
|
|
readOnly={arg.readOnly}
|
|
onChange={onChange}
|
|
hideGutter
|
|
heightMode="auto"
|
|
className="min-h-[3rem]"
|
|
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
|
|
placeholder={arg.placeholder ?? undefined}
|
|
autocompleteFunctions={autocompleteFunctions}
|
|
autocompleteVariables={autocompleteVariables}
|
|
stateKey={stateKey}
|
|
forceUpdateKey={forceUpdateKey}
|
|
actions={
|
|
<div>
|
|
<IconButton
|
|
variant="border"
|
|
size="sm"
|
|
className="my-0.5 opacity-60 group-hover:opacity-100"
|
|
icon="expand"
|
|
title="Pop out to large editor"
|
|
onClick={() => {
|
|
showDialog({
|
|
id: "id",
|
|
size: "full",
|
|
title: arg.readOnly ? "View Value" : "Edit Value",
|
|
className: "!max-w-[50rem] !max-h-[60rem]",
|
|
description: arg.label && (
|
|
<Label
|
|
htmlFor={id}
|
|
required={!arg.optional}
|
|
visuallyHidden={arg.hideLabel}
|
|
help={arg.description}
|
|
tags={arg.language ? [capitalize(arg.language)] : undefined}
|
|
>
|
|
{arg.label}
|
|
</Label>
|
|
),
|
|
onClose() {
|
|
// Force the main editor to update on close
|
|
regeneratePopoutKey();
|
|
},
|
|
render() {
|
|
return (
|
|
<Editor
|
|
id={id}
|
|
autocomplete={
|
|
arg.completionOptions ? { options: arg.completionOptions } : undefined
|
|
}
|
|
disabled={arg.disabled}
|
|
language={arg.language}
|
|
readOnly={arg.readOnly}
|
|
onChange={onChange}
|
|
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
|
|
placeholder={arg.placeholder ?? undefined}
|
|
autocompleteFunctions={autocompleteFunctions}
|
|
autocompleteVariables={autocompleteVariables}
|
|
stateKey={stateKey}
|
|
forceUpdateKey={forceUpdateKey}
|
|
/>
|
|
);
|
|
},
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SelectArg({
|
|
arg,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
arg: FormInputSelect;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
}) {
|
|
return (
|
|
<Select
|
|
label={arg.label ?? arg.name}
|
|
name={arg.name}
|
|
help={arg.description}
|
|
onChange={onChange}
|
|
defaultValue={arg.defaultValue}
|
|
hideLabel={arg.hideLabel}
|
|
value={value}
|
|
size={INPUT_SIZE}
|
|
disabled={arg.disabled}
|
|
options={arg.options}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function FileArg({
|
|
arg,
|
|
filePath,
|
|
onChange,
|
|
}: {
|
|
arg: FormInputFile;
|
|
filePath: string;
|
|
onChange: (v: string | null) => void;
|
|
}) {
|
|
return (
|
|
<SelectFile
|
|
disabled={arg.disabled}
|
|
help={arg.description}
|
|
onChange={({ filePath }) => onChange(filePath)}
|
|
filePath={filePath === DYNAMIC_FORM_NULL_ARG ? null : filePath}
|
|
directory={!!arg.directory}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function HttpRequestArg({
|
|
arg,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
arg: FormInputHttpRequest;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
}) {
|
|
const folders = useAtomValue(foldersAtom);
|
|
const httpRequests = useAtomValue(httpRequestsAtom);
|
|
const activeHttpRequest = useActiveRequest("http_request");
|
|
|
|
useEffect(() => {
|
|
if (value === DYNAMIC_FORM_NULL_ARG && activeHttpRequest) {
|
|
onChange(activeHttpRequest.id);
|
|
}
|
|
}, [activeHttpRequest, onChange, value]);
|
|
|
|
return (
|
|
<Select
|
|
label={arg.label ?? arg.name}
|
|
name={arg.name}
|
|
onChange={onChange}
|
|
help={arg.description}
|
|
value={value}
|
|
disabled={arg.disabled}
|
|
options={httpRequests.map((r) => {
|
|
return {
|
|
label:
|
|
buildRequestBreadcrumbs(r, folders).join(" / ") +
|
|
(r.id === activeHttpRequest?.id ? " (current)" : ""),
|
|
value: r.id,
|
|
};
|
|
})}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function buildRequestBreadcrumbs(request: HttpRequest, folders: Folder[]): string[] {
|
|
const ancestors: (HttpRequest | Folder)[] = [request];
|
|
|
|
const next = () => {
|
|
const latest = ancestors[0];
|
|
if (latest == null) return [];
|
|
|
|
const parent = folders.find((f) => f.id === latest.folderId);
|
|
if (parent == null) return;
|
|
|
|
ancestors.unshift(parent);
|
|
next();
|
|
};
|
|
next();
|
|
|
|
return ancestors.map((a) => (a.model === "folder" ? a.name : resolvedModelName(a)));
|
|
}
|
|
|
|
function CheckboxArg({
|
|
arg,
|
|
onChange,
|
|
value,
|
|
}: {
|
|
arg: FormInputCheckbox;
|
|
value: boolean;
|
|
onChange: (v: boolean) => void;
|
|
}) {
|
|
return (
|
|
<Checkbox
|
|
onChange={onChange}
|
|
checked={value}
|
|
help={arg.description}
|
|
disabled={arg.disabled}
|
|
title={arg.label ?? arg.name}
|
|
hideLabel={arg.label == null}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function KeyValueArg({
|
|
arg,
|
|
onChange,
|
|
value,
|
|
stateKey,
|
|
}: {
|
|
arg: FormInputKeyValue;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
stateKey: string;
|
|
}) {
|
|
const pairs: Pair[] = useMemo(() => {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}, [value]);
|
|
|
|
const handleChange = useCallback(
|
|
(newPairs: Pair[]) => {
|
|
onChange(JSON.stringify(newPairs));
|
|
},
|
|
[onChange],
|
|
);
|
|
|
|
return (
|
|
<div className="w-full grid grid-cols-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden">
|
|
<Label
|
|
htmlFor={`input-${arg.name}`}
|
|
required={!arg.optional}
|
|
visuallyHidden={arg.hideLabel}
|
|
help={arg.description}
|
|
>
|
|
{arg.label ?? arg.name}
|
|
</Label>
|
|
<PairEditor
|
|
pairs={pairs}
|
|
onChange={handleChange}
|
|
stateKey={stateKey}
|
|
namePlaceholder="name"
|
|
valuePlaceholder="value"
|
|
noScroll
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
|
|
if (!inputs) return false;
|
|
|
|
for (const input of inputs) {
|
|
if ("inputs" in input && !hasVisibleInputs(input.inputs)) {
|
|
// Has children, but none are visible
|
|
return false;
|
|
}
|
|
if (!input.hidden) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|