Add previewArgs support for template functions and enhance validation logic for form inputs

This commit is contained in:
Gregory Schier
2025-11-27 12:55:39 -08:00
parent 0c7034eefc
commit 8d1b17cac1
24 changed files with 340 additions and 92 deletions

View File

@@ -1 +1,3 @@
export * from './debounce';
export * from './formatSize';
export * from './templateFunction';

View File

@@ -0,0 +1,49 @@
import type {
CallTemplateFunctionArgs,
JsonPrimitive,
TemplateFunctionArg,
} from '@yaakapp-internal/plugins';
export function validateTemplateFunctionArgs(
fnName: string,
args: TemplateFunctionArg[],
values: CallTemplateFunctionArgs['values'],
): string | null {
for (const arg of args) {
if ('inputs' in arg && arg.inputs) {
// Recurse down
const err = validateTemplateFunctionArgs(fnName, arg.inputs, values);
if (err) return err;
}
if (!('name' in arg)) continue;
if (arg.optional) continue;
if (arg.defaultValue != null) continue;
if (arg.hidden) continue;
if (values[arg.name] != null) continue;
return `Missing required argument "${arg.label || arg.name}" for template function ${fnName}()`;
}
return null;
}
/** Recursively apply form input defaults to a set of values */
export function applyFormInputDefaults(
inputs: TemplateFunctionArg[],
values: { [p: string]: JsonPrimitive | undefined },
) {
let newValues: { [p: string]: JsonPrimitive | undefined } = { ...values };
for (const input of inputs) {
if ('defaultValue' in input && values[input.name] === undefined) {
newValues[input.name] = input.defaultValue;
}
if (input.type === 'checkbox' && values[input.name] === undefined) {
newValues[input.name] = false;
}
// Recurse down to all child inputs
if ('inputs' in input) {
newValues = applyFormInputDefaults(input.inputs ?? [], newValues);
}
}
return newValues;
}

View File

@@ -450,7 +450,11 @@ export type TemplateFunction = { name: string, previewType?: TemplateFunctionPre
* Also support alternative names. This is useful for not breaking existing
* tags when changing the `name` property
*/
aliases?: Array<string>, args: Array<TemplateFunctionArg>, };
aliases?: Array<string>, args: Array<TemplateFunctionArg>,
/**
* A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons).
*/
previewArgs?: Array<string>, };
/**
* Similar to FormInput, but contains

View File

@@ -1,3 +1,4 @@
import { validateTemplateFunctionArgs } from '@yaakapp-internal/lib/templateFunction';
import {
BootRequest,
DeleteKeyValueResponse,
@@ -28,7 +29,6 @@ import path from 'node:path';
import {
applyDynamicFormInput,
applyFormInputDefaults,
validateTemplateFunctionArgs,
} from './common';
import { EventChannel } from './EventChannel';
import { migrateTemplateFunctionSelectOptions } from './migrations';

View File

@@ -1,31 +1,6 @@
import {
CallHttpAuthenticationActionArgs,
CallTemplateFunctionArgs,
JsonPrimitive,
TemplateFunctionArg,
} from '@yaakapp-internal/plugins';
import { CallHttpAuthenticationActionArgs, CallTemplateFunctionArgs } from '@yaakapp-internal/plugins';
import { Context, DynamicAuthenticationArg, DynamicTemplateFunctionArg } from '@yaakapp/api';
/** Recursively apply form input defaults to a set of values */
export function applyFormInputDefaults(
inputs: TemplateFunctionArg[],
values: { [p: string]: JsonPrimitive | undefined },
) {
let newValues: { [p: string]: JsonPrimitive | undefined } = { ...values };
for (const input of inputs) {
if ('defaultValue' in input && values[input.name] === undefined) {
newValues[input.name] = input.defaultValue;
} else if (input.type === 'checkbox' && values[input.name] === undefined) {
newValues[input.name] = false;
}
// Recurse down to all child inputs
if ('inputs' in input) {
newValues = applyFormInputDefaults(input.inputs ?? [], newValues);
}
}
return newValues;
}
export async function applyDynamicFormInput(
ctx: Context,
args: DynamicTemplateFunctionArg[],
@@ -60,26 +35,3 @@ export async function applyDynamicFormInput(
}
return resolvedArgs;
}
export function validateTemplateFunctionArgs(
fnName: string,
args: TemplateFunctionArg[],
values: CallTemplateFunctionArgs['values'],
): string | null {
for (const arg of args) {
if ('inputs' in arg && arg.inputs) {
// Recurse down
const err = validateTemplateFunctionArgs(fnName, arg.inputs, values);
if (err) return err;
}
if (!('name' in arg)) continue;
if (arg.optional) continue;
if (arg.defaultValue != null) continue;
if (arg.hidden) continue;
if (values[arg.name] != null) continue;
return `Missing required argument "${arg.label || arg.name}" for template function ${fnName}()`;
}
return null;
}

View File

@@ -28,6 +28,7 @@ export const plugin: PluginDefinition = {
{
name: '1password.item',
description: 'Get a secret',
previewArgs: ['field'],
args: [
{
name: 'token',

View File

@@ -5,15 +5,18 @@ export const plugin: PluginDefinition = {
{
name: 'cookie.value',
description: 'Read the value of a cookie in the jar, by name',
previewArgs: ['name'],
args: [
{
type: 'text',
name: 'cookie_name',
name: 'name',
label: 'Cookie Name',
},
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
return ctx.cookies.getValue({ name: String(args.values.cookie_name) });
// The legacy name was cookie_name, but we changed it
const name = args.values.cookie_name ?? args.values.name;
return ctx.cookies.getValue({ name: String(name) });
},
},
],

View File

@@ -16,6 +16,7 @@ export const plugin: PluginDefinition = {
{
name: 'fs.readFile',
description: 'Read the contents of a file as utf-8',
previewArgs: ['encoding'],
args: [
{ title: 'Select File', type: 'file', name: 'path', label: 'File' },
{
@@ -26,14 +27,21 @@ export const plugin: PluginDefinition = {
description: "Specifies how the file's bytes are decoded into text when read",
options,
},
{
type: 'checkbox',
name: 'trim',
label: 'Trim Whitespace',
description: 'Remove leading and trailing whitespace from the file contents',
},
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.path || !args.values.encoding) return null;
try {
return fs.promises.readFile(String(args.values.path ?? ''), {
const v = await fs.promises.readFile(String(args.values.path ?? ''), {
encoding: String(args.values.encoding ?? 'utf-8') as BufferEncoding,
});
return args.values.trim ? v.trim() : v;
} catch {
return null;
}

View File

@@ -11,6 +11,7 @@ export const plugin: PluginDefinition = {
{
name: 'json.jsonpath',
description: 'Filter JSON-formatted text using JSONPath syntax',
previewArgs: ['query'],
args: [
{
type: 'editor',

View File

@@ -16,8 +16,22 @@ export const plugin: PluginDefinition = {
name: 'prompt.text',
description: 'Prompt the user for input when sending a request',
previewType: 'click',
previewArgs: ['label'],
args: [
{ type: 'text', name: 'label', label: 'Label', optional: true },
{
type: 'text',
name: 'label',
label: 'Label',
optional: true,
dynamic(_ctx, args) {
if (
args.values.store === STORE_EXPIRE ||
(args.values.store === STORE_FOREVER && !args.values.key)
) {
return { optional: false };
}
},
},
{
type: 'select',
name: 'store',
@@ -68,21 +82,24 @@ export const plugin: PluginDefinition = {
{
type: 'banner',
color: 'info',
inputs: [],
dynamic(_ctx, args) {
return { hidden: args.values.store === STORE_NONE };
},
inputs: [
{
type: 'markdown',
content: '',
async dynamic(_ctx, args) {
const key = buildKey(args);
return {
let key: string;
try {
key = buildKey(args);
} catch (err) {
return { color: 'danger', inputs: [{ type: 'markdown', content: String(err) }] };
}
return {
hidden: args.values.store === STORE_NONE,
inputs: [
{
type: 'markdown',
content: [`Value will be saved under: \`${key}\``].join('\n\n'),
};
},
},
],
},
],
};
},
},
{
type: 'accordion',
@@ -139,7 +156,7 @@ export const plugin: PluginDefinition = {
function buildKey(args: CallTemplateFunctionArgs) {
if (!args.values.key && !args.values.label) {
throw new Error('Key or Label is required when storing values');
throw new Error('A label or key is required when storing values');
}
return [args.values.namespace, args.values.key || args.values.label]
.filter((v) => !!v)

View File

@@ -5,6 +5,7 @@ export const plugin: PluginDefinition = {
{
name: 'random.range',
description: 'Generate a random number between two values',
previewArgs: ['min', 'max'],
args: [
{
type: 'text',

View File

@@ -24,6 +24,7 @@ export const plugin: PluginDefinition = {
name: 'regex.match',
description: 'Extract text using a regular expression',
args: [inputArg, regexArg],
previewArgs: [regexArg.name],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const input = String(args.values.input ?? '');
const regex = new RegExp(String(args.values.regex ?? ''));
@@ -37,6 +38,7 @@ export const plugin: PluginDefinition = {
{
name: 'regex.replace',
description: 'Replace text using a regular expression',
previewArgs: [regexArg.name],
args: [
inputArg,
regexArg,

View File

@@ -1,11 +1,20 @@
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import type { AnyModel, HttpUrlParameter } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import type { JSONPathResult } from '../../template-function-json';
import { filterJSONPath } from '../../template-function-json';
import type { XPathResult } from '../../template-function-xml';
import { filterXPath } from '../../template-function-xml';
const RETURN_FIRST = 'first';
const RETURN_ALL = 'all';
const RETURN_JOIN = 'join';
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'request.body',
name: 'request.body.raw',
aliases: ['request.body'],
args: [
{
name: 'requestId',
@@ -25,8 +34,115 @@ export const plugin: PluginDefinition = {
);
},
},
{
name: 'request.body.path',
previewArgs: ['path'],
args: [
{ name: 'requestId', label: 'Http Request', type: 'http_request' },
{
type: 'h_stack',
inputs: [
{
type: 'select',
name: 'result',
label: 'Return Format',
defaultValue: RETURN_FIRST,
options: [
{ label: 'First result', value: RETURN_FIRST },
{ label: 'All results', value: RETURN_ALL },
{ label: 'Join with separator', value: RETURN_JOIN },
],
},
{
name: 'join',
type: 'text',
label: 'Separator',
optional: true,
defaultValue: ', ',
dynamic(_ctx, args) {
return { hidden: args.values.result !== RETURN_JOIN };
},
},
],
},
{
type: 'text',
name: 'path',
label: 'JSONPath or XPath',
placeholder: '$.books[0].id or /books[0]/id',
dynamic: async (ctx, args) => {
const requestId = String(args.values.requestId ?? 'n/a');
const httpRequest = await ctx.httpRequest.getById({ id: requestId });
if (httpRequest == null) return null;
const contentType =
httpRequest.headers
.find((h) => h.name.toLowerCase() === 'content-type')
?.value.toLowerCase() ?? '';
if (contentType.includes('xml') || contentType?.includes('html')) {
return {
label: 'XPath',
placeholder: '/books[0]/id',
description: 'Enter an XPath expression used to filter the results',
};
}
return {
label: 'JSONPath',
placeholder: '$.books[0].id',
description: 'Enter a JSONPath expression used to filter the results',
};
},
},
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const requestId = String(args.values.requestId ?? 'n/a');
const httpRequest = await ctx.httpRequest.getById({ id: requestId });
if (httpRequest == null) return null;
const body = httpRequest.body?.text ?? '';
try {
const result: JSONPathResult =
args.values.result === RETURN_ALL
? 'all'
: args.values.result === RETURN_JOIN
? 'join'
: 'first';
return filterJSONPath(
body,
String(args.values.path || ''),
result,
args.values.join == null ? null : String(args.values.join),
);
} catch {
// Probably not JSON, try XPath
}
try {
const result: XPathResult =
args.values.result === RETURN_ALL
? 'all'
: args.values.result === RETURN_JOIN
? 'join'
: 'first';
return filterXPath(
body,
String(args.values.path || ''),
result,
args.values.join == null ? null : String(args.values.join),
);
} catch {
// Probably not XML
}
return null; // Bail out
},
},
{
name: 'request.header',
description: 'Read the value of a request header, by name',
previewArgs: ['header'],
args: [
{
name: 'requestId',

View File

@@ -62,6 +62,7 @@ export const plugin: PluginDefinition = {
{
name: 'response.header',
description: 'Read the value of a response header, by name',
previewArgs: ['header'],
args: [
requestArg,
behaviorArgs,
@@ -108,6 +109,7 @@ export const plugin: PluginDefinition = {
name: 'response.body.path',
description: 'Access a field of the response body using JsonPath or XPath',
aliases: ['response'],
previewArgs: ['path'],
args: [
requestArg,
behaviorArgs,
@@ -155,7 +157,9 @@ export const plugin: PluginDefinition = {
}
const contentType =
resp?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? '';
resp?.headers
.find((h) => h.name.toLowerCase() === 'content-type')
?.value.toLowerCase() ?? '';
if (contentType.includes('xml') || contentType?.includes('html')) {
return {
label: 'XPath',
@@ -187,9 +191,10 @@ export const plugin: PluginDefinition = {
return null;
}
const BOM = '\ufeff';
let body: string;
try {
body = readFileSync(response.bodyPath, 'utf-8');
body = readFileSync(response.bodyPath, 'utf-8').replace(BOM, '');
} catch {
return null;
}

View File

@@ -81,12 +81,14 @@ export const plugin: PluginDefinition = {
name: 'timestamp.format',
description: 'Format a date using a dayjs-compatible format string',
args: [dateArg, formatArg],
previewArgs: [formatArg.name],
onRender: async (_ctx, args) => formatDatetime(args.values),
},
{
name: 'timestamp.offset',
description: 'Get the offset of a date based on an expression',
args: [dateArg, expressionArg],
previewArgs: [expressionArg.name],
onRender: async (_ctx, args) => calculateDatetime(args.values),
},
],

View File

@@ -11,6 +11,7 @@ export const plugin: PluginDefinition = {
{
name: 'xml.xpath',
description: 'Filter XML-formatted text using XPath syntax',
previewArgs: ['query'],
args: [
{
type: 'text',

View File

@@ -4,7 +4,7 @@ const Downloader = require('nodejs-file-downloader');
const { rmSync, cpSync, mkdirSync, existsSync } = require('node:fs');
const { execSync } = require('node:child_process');
const NODE_VERSION = 'v24.4.0';
const NODE_VERSION = 'v24.11.1';
// `${process.platform}_${process.arch}`
const MAC_ARM = 'darwin_arm64';
@@ -80,7 +80,7 @@ rmSync(tmpDir, { recursive: true, force: true });
function tryExecSync(cmd) {
try {
return execSync(cmd, { stdio: 'inherit' }).toString('utf-8');
return execSync(cmd, { stdio: 'pipe' }).toString('utf-8');
} catch (_) {
return '';
}

View File

@@ -4,7 +4,7 @@ const path = require('node:path');
const { rmSync, mkdirSync, cpSync, existsSync, statSync, chmodSync } = require('node:fs');
const { execSync } = require('node:child_process');
const VERSION = '28.3';
const VERSION = '33.1';
// `${process.platform}_${process.arch}`
const MAC_ARM = 'darwin_arm64';
@@ -81,7 +81,7 @@ mkdirSync(dstDir, { recursive: true });
function tryExecSync(cmd) {
try {
return execSync(cmd, { stdio: 'inherit' }).toString('utf-8');
return execSync(cmd, { stdio: 'pipe' }).toString('utf-8');
} catch (_) {
return '';
}

View File

@@ -450,7 +450,11 @@ export type TemplateFunction = { name: string, previewType?: TemplateFunctionPre
* Also support alternative names. This is useful for not breaking existing
* tags when changing the `name` property
*/
aliases?: Array<string>, args: Array<TemplateFunctionArg>, };
aliases?: Array<string>, args: Array<TemplateFunctionArg>,
/**
* A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons).
*/
previewArgs?: Array<string>, };
/**
* Similar to FormInput, but contains

View File

@@ -758,6 +758,10 @@ pub struct TemplateFunction {
#[ts(optional)]
pub aliases: Option<Vec<String>>,
pub args: Vec<TemplateFunctionArg>,
/// A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons).
#[ts(optional)]
pub preview_args: Option<Vec<String>>,
}
/// Similar to FormInput, but contains

View File

@@ -22,6 +22,7 @@ pub(crate) fn template_function_secure() -> TemplateFunction {
preview_type: Some(TemplateFunctionPreviewType::None),
description: Some("Securely store encrypted text".to_string()),
aliases: None,
preview_args: None,
args: vec![TemplateFunctionArg::FormInput(FormInput::Text(
FormInputText {
multi_line: Some(true),
@@ -68,6 +69,7 @@ pub(crate) fn template_function_keyring() -> TemplateFunction {
preview_type: Some(TemplateFunctionPreviewType::Live),
description: Some(meta.description),
aliases: Some(vec!["keyring".to_string()]),
preview_args: Some(vec!["service".to_string(), "account".to_string()]),
args: vec![
TemplateFunctionArg::FormInput(FormInput::Banner(FormInputBanner {
inputs: Some(vec![FormInput::Markdown(FormInputMarkdown {

View File

@@ -101,12 +101,33 @@
.template-tag {
/* Colors */
@apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap;
@apply bg-surface text-text border-border-subtle whitespace-nowrap;
@apply hover:border-border-subtle hover:text-text hover:bg-surface-highlight;
@apply inline border px-1 mx-[0.5px] rounded cursor-default dark:shadow;
@apply inline border px-1 mx-[0.5px] rounded dark:shadow;
-webkit-text-security: none;
* {
@apply cursor-default;
}
.fn {
@apply inline-block;
.fn-inner {
@apply text-text-subtle max-w-[40em] italic inline-flex items-end whitespace-pre text-[0.9em];
}
.fn-arg-name {
/* Nothing yet */
@apply opacity-60;
}
.fn-arg-value {
@apply inline-block truncate;
}
.fn-bracket {
@apply text-text-subtle opacity-30;
}
}
}
.hyperlink-widget {

View File

@@ -23,7 +23,7 @@ export type TwigCompletionOption = (
| TwigCompletionOptionNamespace
) & {
name: string;
label: string;
label: string | HTMLElement;
description?: string;
onClick: (rawTag: string, startPos: number) => void;
value: string | null;
@@ -34,7 +34,7 @@ export interface TwigCompletionConfig {
options: TwigCompletionOption[];
}
const MIN_MATCH_NAME = 2;
const MIN_MATCH_NAME = 1;
export function twigCompletion({ options }: TwigCompletionConfig) {
return function completions(context: CompletionContext) {
@@ -44,7 +44,7 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
if (toMatch === null) return null;
const matchLen = toMatch.to - toMatch.from;
if (toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
if (!context.explicit && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
return null;
}

View File

@@ -3,6 +3,8 @@ import type { Range } from '@codemirror/state';
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
import type { SyntaxNodeRef } from '@lezer/common';
import { applyFormInputDefaults, validateTemplateFunctionArgs } from '@yaakapp-internal/lib';
import type { FormInput, JsonPrimitive, TemplateFunction } from '@yaakapp-internal/plugins';
import { parseTemplate } from '@yaakapp-internal/templates';
import type { TwigCompletionOption } from './completion';
import { collectArgumentValues } from './util';
@@ -42,7 +44,8 @@ class TemplateTagWidget extends WidgetType {
}`;
elt.title = this.option.invalid ? 'Not Found' : (this.option.value ?? '');
elt.setAttribute('data-tag-type', this.option.type);
elt.textContent = this.option.label;
if (typeof this.option.label === 'string') elt.textContent = this.option.label;
else elt.appendChild(this.option.label);
elt.addEventListener('click', this.#clickListenerCallback);
return elt;
}
@@ -109,15 +112,11 @@ function templateTags(
if (option.type === 'function') {
const tokens = parseTemplate(rawTag);
const values = collectArgumentValues(tokens, option);
for (const arg of option.args) {
if (!('optional' in arg)) continue;
if (!arg.optional && values[arg.name] == null) {
// Clone so we don't mutate the original
option = { ...option, invalid: true };
break;
}
}
const rawValues = collectArgumentValues(tokens, option);
const values = applyFormInputDefaults(option.args, rawValues);
const label = makeFunctionLabel(option, values);
const validationErr = validateTemplateFunctionArgs(option.name, option.args, values);
option = { ...option, label, invalid: !!validationErr }; // Clone so we don't mutate the original
}
const widget = new TemplateTagWidget(option, rawTag, node.from);
@@ -169,3 +168,57 @@ function isSelectionInsideNode(view: EditorView, node: SyntaxNodeRef) {
}
return false;
}
function makeFunctionLabel(
fn: TemplateFunction,
values: { [p: string]: JsonPrimitive | undefined },
): HTMLElement | string {
if (fn.args.length === 0) return fn.name;
const $outer = document.createElement('span');
$outer.className = 'fn';
const $bOpen = document.createElement('span');
$bOpen.className = 'fn-bracket';
$bOpen.textContent = '(';
$outer.appendChild(document.createTextNode(fn.name));
$outer.appendChild($bOpen);
const $inner = document.createElement('span');
$inner.className = 'fn-inner';
$inner.title = '';
fn.previewArgs?.forEach((name: string, i: number, all: string[]) => {
const v = String(values[name] || '');
if (!v) return;
if (all.length > 1) {
const $c = document.createElement('span');
$c.className = 'fn-arg-name';
$c.textContent = i > 0 ? `, ${name}=` : `${name}=`;
$inner.appendChild($c);
}
const $v = document.createElement('span');
$v.className = 'fn-arg-value';
$v.textContent = v.includes(' ') ? `'${v}'` : v;
$inner.appendChild($v);
});
fn.args.forEach((a: FormInput, i: number) => {
if (!('name' in a)) return;
const v = values[a.name];
if (v == null) return;
if (i > 0) $inner.title += '\n';
$inner.title += `${a.name} = ${JSON.stringify(v)}`;
});
if ($inner.childNodes.length === 0) {
$inner.appendChild(document.createTextNode('…'));
}
$outer.appendChild($inner);
const $bClose = document.createElement('span');
$bClose.className = 'fn-bracket';
$bClose.textContent = ')';
$outer.appendChild($bClose);
return $outer;
}