mirror of
https://github.com/mountain-loop/yaak.git
synced 2025-12-05 19:17:44 -06:00
Add previewArgs support for template functions and enhance validation logic for form inputs
This commit is contained in:
@@ -1 +1,3 @@
|
||||
export * from './debounce';
|
||||
export * from './formatSize';
|
||||
export * from './templateFunction';
|
||||
|
||||
49
packages/common-lib/templateFunction.ts
Normal file
49
packages/common-lib/templateFunction.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export const plugin: PluginDefinition = {
|
||||
{
|
||||
name: '1password.item',
|
||||
description: 'Get a secret',
|
||||
previewArgs: ['field'],
|
||||
args: [
|
||||
{
|
||||
name: 'token',
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export const plugin: PluginDefinition = {
|
||||
{
|
||||
name: 'json.jsonpath',
|
||||
description: 'Filter JSON-formatted text using JSONPath syntax',
|
||||
previewArgs: ['query'],
|
||||
args: [
|
||||
{
|
||||
type: 'editor',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -11,6 +11,7 @@ export const plugin: PluginDefinition = {
|
||||
{
|
||||
name: 'xml.xpath',
|
||||
description: 'Filter XML-formatted text using XPath syntax',
|
||||
previewArgs: ['query'],
|
||||
args: [
|
||||
{
|
||||
type: 'text',
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user