Files
yaak/apps/yaak-client/components/DynamicForm.tsx
2026-03-13 13:20:06 -07:00

629 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 { Banner, VStack } from "@yaakapp-internal/ui";
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 { 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 { 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;
}