mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-17 14:39:41 +02:00
Refactor desktop app into separate client and proxy apps
This commit is contained in:
631
apps/yaak-client/components/DynamicForm.tsx
Normal file
631
apps/yaak-client/components/DynamicForm.tsx
Normal file
@@ -0,0 +1,631 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user