Add an option to allow jsonpath/xpath to return as array (#297)

Co-authored-by: Gregory Schier <gschier1990@gmail.com>
This commit is contained in:
Gregor Majcen
2025-11-13 14:57:11 +01:00
committed by GitHub
parent a4c4663011
commit 593a7ab7e5
34 changed files with 800 additions and 338 deletions

View File

@@ -26,7 +26,7 @@ import { IconButton } from './core/IconButton';
import { Input } from './core/Input';
import { Label } from './core/Label';
import { Select } from './core/Select';
import { VStack } from './core/Stacks';
import { HStack, VStack } from './core/Stacks';
import { Markdown } from './Markdown';
import { SelectFile } from './SelectFile';
@@ -41,6 +41,7 @@ interface Props<T> {
autocompleteFunctions?: boolean;
autocompleteVariables?: boolean;
stateKey: string;
className?: string;
disabled?: boolean;
}
@@ -51,6 +52,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
autocompleteVariables,
autocompleteFunctions,
stateKey,
className,
disabled,
}: Props<T>) {
const setDataAttr = useCallback(
@@ -61,7 +63,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
);
return (
<FormInputs
<FormInputsStack
disabled={disabled}
inputs={inputs}
setDataAttr={setDataAttr}
@@ -69,28 +71,15 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}
data={data}
className="pb-4" // Pad the bottom to look nice
className={classNames(className, 'pb-4')} // Pad the bottom to look nice
/>
);
}
function FormInputs<T extends Record<string, JsonPrimitive>>({
inputs,
autocompleteFunctions,
autocompleteVariables,
stateKey,
setDataAttr,
data,
disabled,
function FormInputsStack<T extends Record<string, JsonPrimitive>>({
className,
}: Pick<
Props<T>,
'inputs' | 'autocompleteFunctions' | 'autocompleteVariables' | 'stateKey' | 'data'
> & {
setDataAttr: (name: string, value: JsonPrimitive) => void;
disabled?: boolean;
className?: string;
}) {
...props
}: FormInputsProps<T> & { className?: string }) {
return (
<VStack
space={3}
@@ -100,6 +89,30 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
'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;
@@ -113,7 +126,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
case 'select':
return (
<SelectArg
key={i + stateKey}
key={i}
arg={input}
onChange={(v) => setDataAttr(input.name, v)}
value={
@@ -126,7 +139,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
case 'text':
return (
<TextArg
key={i}
key={i + stateKey}
stateKey={stateKey}
arg={input}
autocompleteFunctions={autocompleteFunctions || false}
@@ -140,7 +153,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
case 'editor':
return (
<EditorArg
key={i}
key={i + stateKey}
stateKey={stateKey}
arg={input}
autocompleteFunctions={autocompleteFunctions || false}
@@ -182,13 +195,13 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
);
case 'accordion':
return (
<div key={i}>
<div key={i + stateKey}>
<DetailsBanner
summary={input.label}
className={classNames('!mb-auto', disabled && 'opacity-disabled')}
>
<div className="my-3">
<FormInputs
<FormInputsStack
data={data}
disabled={disabled}
inputs={input.inputs}
@@ -201,14 +214,28 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
</DetailsBanner>
</div>
);
case 'h_stack':
return (
<HStack key={i + stateKey} alignItems="end" space={3}>
<FormInputs
data={data}
disabled={disabled}
inputs={input.inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
autocompleteFunctions={autocompleteFunctions || false}
autocompleteVariables={autocompleteVariables}
/>
</HStack>
);
case 'banner':
return (
<Banner
key={i}
key={i + stateKey}
color={input.color}
className={classNames(disabled && 'opacity-disabled')}
>
<FormInputs
<FormInputsStack
data={data}
disabled={disabled}
inputs={input.inputs}
@@ -220,10 +247,10 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
</Banner>
);
case 'markdown':
return <Markdown>{input.content}</Markdown>;
return <Markdown key={i + stateKey}>{input.content}</Markdown>;
}
})}
</VStack>
</>
);
}
@@ -255,7 +282,7 @@ function TextArg({
type={arg.password ? 'password' : 'text'}
label={arg.label ?? arg.name}
size={INPUT_SIZE}
hideLabel={arg.label == null}
hideLabel={arg.hideLabel ?? arg.label == null}
placeholder={arg.placeholder ?? undefined}
autocomplete={arg.completionOptions ? { options: arg.completionOptions } : undefined}
autocompleteFunctions={autocompleteFunctions}
@@ -313,7 +340,9 @@ function EditorArg({
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}
@@ -374,7 +403,6 @@ function EditorArg({
/>
</div>
}
hideGutter
/>
</div>
</div>
@@ -396,6 +424,7 @@ function SelectArg({
name={arg.name}
help={arg.description}
onChange={onChange}
defaultValue={arg.defaultValue}
hideLabel={arg.hideLabel}
value={value}
size={INPUT_SIZE}

View File

@@ -5,7 +5,7 @@ import type {
WebsocketRequest,
Workspace,
} from '@yaakapp-internal/models';
import type { TemplateFunction } from '@yaakapp-internal/plugins';
import type { FormInput, TemplateFunction } from '@yaakapp-internal/plugins';
import type { FnArg, Tokens } from '@yaakapp-internal/templates';
import classNames from 'classnames';
import { useEffect, useMemo, useState } from 'react';
@@ -45,24 +45,7 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
}
(async function () {
const initial: Record<string, string> = {};
const initialArgs =
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn'
? initialTokens.tokens[0]?.val.args
: [];
for (const arg of templateFunction.args) {
if (!('name' in arg)) {
// Skip visual-only args
continue;
}
const initialArg = initialArgs.find((a) => a.name === arg.name);
const initialArgValue =
initialArg?.value.type === 'str'
? initialArg?.value.text
: // TODO: Implement variable-based args
undefined;
initial[arg.name] = initialArgValue ?? arg.defaultValue ?? DYNAMIC_FORM_NULL_ARG;
}
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.
@@ -71,12 +54,14 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
initial.value = await convertTemplateToInsecure(template);
}
console.log('INITIAL', initial);
setInitialArgValues(initial);
})().catch(console.error);
}, [
initialArgValues,
initialTokens,
initialTokens.tokens,
templateFunction,
templateFunction.args,
templateFunction.name,
]);
@@ -159,84 +144,117 @@ function InitializedTemplateFunctionDialog({
if (templateFunction == null) return null;
return (
<VStack
as="form"
className="pb-3"
space={4}
<form
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
onSubmit={(e) => {
e.preventDefault();
handleDone();
}}
>
{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.args}
data={argValues}
onChange={setArgValues}
stateKey={`template_function.${templateFunction.name}`}
/>
)}
{enablePreview && (
<VStack className="w-full" space={1}>
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isPending && <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>
<InlineCode
className={classNames(
'whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-y-auto hide-scrollbars',
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>
</VStack>
)}
<div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1">
{templateFunction.name === 'secure' && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key
</Button>
<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.args}
data={argValues}
onChange={setArgValues}
stateKey={`template_function.${templateFunction.name}`}
/>
)}
<Button type="submit" color="primary">
Save
</Button>
</div>
</VStack>
<div className="px-6 border-t border-t-border py-3 bg-surface-highlight w-full flex flex-col gap-4">
{enablePreview ? (
<VStack className="w-full">
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isPending && <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>
<InlineCode
className={classNames(
'whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-y-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>
</VStack>
) : (
<span />
)}
<div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1">
{templateFunction.name === 'secure' && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key
</Button>
)}
<Button type="submit" color="primary">
Save
</Button>
</div>
</div>
</form>
);
}
/**
* Process the initial tokens from the template and merge those with the default values pulled from
* the template function definition.
*/
function collectArgumentValues(initialTokens: Tokens, templateFunction: TemplateFunction) {
const initial: Record<string, string | boolean> = {};
const initialArgs =
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn'
? initialTokens.tokens[0]?.val.args
: [];
const processArg = (arg: FormInput) => {
if ('inputs' in arg && arg.inputs) {
arg.inputs.forEach(processArg);
}
if (!('name' in arg)) return;
const initialArg = initialArgs.find((a) => a.name === arg.name);
const initialArgValue = initialArg?.value.type === 'str' ? initialArg?.value.text : undefined;
initial[arg.name] = initialArgValue ?? arg.defaultValue ?? DYNAMIC_FORM_NULL_ARG;
};
templateFunction.args.forEach(processArg);
return initial;
}

View File

@@ -655,7 +655,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
className,
'h-xs', // More compact
'min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap',
'focus:bg-surface-highlight focus:text rounded',
'focus:bg-surface-highlight focus:text rounded focus:outline-none focus-visible:outline-1',
item.color === 'danger' && '!text-danger',
item.color === 'primary' && '!text-primary',
item.color === 'success' && '!text-success',

View File

@@ -78,9 +78,19 @@
@apply cursor-default;
}
}
.cm-gutter-lint {
@apply w-auto !important;
.cm-gutterElement {
@apply px-0;
}
.cm-lint-marker {
@apply cursor-default opacity-80 hover:opacity-100 transition-opacity;
@apply rounded-full w-[0.9em] h-[0.9em];
content: '';
&.cm-lint-marker-error {

View File

@@ -290,6 +290,8 @@ export function Editor({
showDialog({
id: 'template-function-' + Math.random(), // Allow multiple at once
size: 'md',
className: 'h-[90vh]',
noPadding: true,
title: <InlineCode>{fn.name}()</InlineCode>,
description: fn.description,
render: ({ hide }) => {
@@ -354,6 +356,7 @@ export function Editor({
const ext = getLanguageExtension({
useTemplating,
language,
hideGutter,
environmentVariables,
autocomplete,
completionOptions,
@@ -374,6 +377,7 @@ export function Editor({
completionOptions,
useTemplating,
graphQLSchema,
hideGutter,
]);
// Initialize the editor when ref mounts

View File

@@ -105,6 +105,7 @@ export function getLanguageExtension({
language = 'text',
environmentVariables,
autocomplete,
hideGutter,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
@@ -118,7 +119,7 @@ export function getLanguageExtension({
onClickPathParameter: (name: string) => void;
completionOptions: TwigCompletionOption[];
graphQLSchema: GraphQLSchema | null;
} & Pick<EditorProps, 'language' | 'autocomplete'>) {
} & Pick<EditorProps, 'language' | 'autocomplete' | 'hideGutter'>) {
const extraExtensions: Extension[] = [];
if (language === 'url') {
@@ -155,7 +156,10 @@ export function getLanguageExtension({
}
if (language === 'json') {
extraExtensions.push(linter(jsonParseLinter()), lintGutter());
extraExtensions.push(linter(jsonParseLinter()));
if (!hideGutter) {
extraExtensions.push(lintGutter());
}
}
const maybeBase = language ? syntaxExtensions[language] : null;

View File

@@ -47,7 +47,7 @@ export function useHttpAuthenticationConfig(
],
placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: async () => {
if (authName == null) return null;
if (authName == null || authName === 'inherit') return null;
const config = await invokeCmd<GetHttpAuthenticationConfigResponse>(
'cmd_get_http_authentication_config',
{

View File

@@ -45,16 +45,25 @@ export function useTemplateFunctionConfig(
placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: async () => {
if (functionName == null) return null;
const config = await invokeCmd<GetTemplateFunctionConfigResponse>(
'cmd_template_function_config',
{
functionName: functionName,
values,
model,
environmentId,
},
);
return config.function;
return getTemplateFunctionConfig(functionName, values, model, environmentId);
},
});
}
export async function getTemplateFunctionConfig(
functionName: string,
values: Record<string, JsonPrimitive>,
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,
environmentId: string | undefined,
) {
const config = await invokeCmd<GetTemplateFunctionConfigResponse>(
'cmd_template_function_config',
{
functionName,
values,
model,
environmentId,
},
);
return config.function;
}

View File

@@ -45,7 +45,7 @@ export async function editEnvironment(
id: 'environment-editor',
noPadding: true,
size: 'lg',
className: 'h-[80vh]',
className: 'h-[90vh]',
render: () => (
<EnvironmentEditDialog
initialEnvironmentId={environment?.id ?? null}