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

7
package-lock.json generated
View File

@@ -19146,12 +19146,7 @@
"name": "@yaak/template-function-response",
"version": "0.1.0",
"dependencies": {
"@xmldom/xmldom": "^0.9.8",
"jsonpath-plus": "^10.3.0",
"xpath": "^0.0.34"
},
"devDependencies": {
"@types/jsonpath": "^0.2.4"
"@yaak/template-function-xml": "*"
}
},
"plugins/template-function-timestamp": {

View File

@@ -30,7 +30,7 @@ export type CallHttpRequestActionArgs = { httpRequest: HttpRequest, };
export type CallHttpRequestActionRequest = { index: number, pluginRefId: string, args: CallHttpRequestActionArgs, };
export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: JsonValue }, };
export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: JsonPrimitive }, };
export type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, };
@@ -74,7 +74,7 @@ export type FindHttpResponsesRequest = { requestId: string, limit?: number, };
export type FindHttpResponsesResponse = { httpResponses: Array<HttpResponse>, };
export type FormInput = { "type": "text" } & FormInputText | { "type": "editor" } & FormInputEditor | { "type": "select" } & FormInputSelect | { "type": "checkbox" } & FormInputCheckbox | { "type": "file" } & FormInputFile | { "type": "http_request" } & FormInputHttpRequest | { "type": "accordion" } & FormInputAccordion | { "type": "banner" } & FormInputBanner | { "type": "markdown" } & FormInputMarkdown;
export type FormInput = { "type": "text" } & FormInputText | { "type": "editor" } & FormInputEditor | { "type": "select" } & FormInputSelect | { "type": "checkbox" } & FormInputCheckbox | { "type": "file" } & FormInputFile | { "type": "http_request" } & FormInputHttpRequest | { "type": "accordion" } & FormInputAccordion | { "type": "h_stack" } & FormInputHStack | { "type": "banner" } & FormInputBanner | { "type": "markdown" } & FormInputMarkdown;
export type FormInputAccordion = { label: string, inputs?: Array<FormInput>, hidden?: boolean, };
@@ -224,6 +224,8 @@ defaultValue?: string, disabled?: boolean,
*/
description?: string, };
export type FormInputHStack = { inputs?: Array<FormInput>, };
export type FormInputHttpRequest = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data

View File

@@ -3,22 +3,37 @@ import {
CallHttpAuthenticationRequest,
CallHttpAuthenticationResponse,
FormInput,
GetHttpAuthenticationConfigRequest,
GetHttpAuthenticationSummaryResponse,
HttpAuthenticationAction,
} from '../bindings/gen_events';
import { MaybePromise } from '../helpers';
import { Context } from './Context';
type DynamicFormInput = FormInput & {
dynamic(
type AddDynamicMethod<T> = {
dynamic?: (
ctx: Context,
args: GetHttpAuthenticationConfigRequest,
): MaybePromise<Partial<FormInput> | undefined | null>;
args: CallHttpAuthenticationActionArgs,
) => MaybePromise<Partial<T> | null | undefined>;
};
type AddDynamic<T> = T extends any
? T extends { inputs?: FormInput[] }
? Omit<T, 'inputs'> & {
inputs: Array<AddDynamic<FormInput>>;
dynamic?: (
ctx: Context,
args: CallHttpAuthenticationActionArgs,
) => MaybePromise<
Partial<Omit<T, 'inputs'> & { inputs: Array<AddDynamic<FormInput>> }> | null | undefined
>;
}
: T & AddDynamicMethod<T>
: never;
export type DynamicAuthenticationArg = AddDynamic<FormInput>;
export type AuthenticationPlugin = GetHttpAuthenticationSummaryResponse & {
args: (FormInput | DynamicFormInput)[];
args: DynamicAuthenticationArg[];
onApply(
ctx: Context,
args: CallHttpAuthenticationRequest,

View File

@@ -1,21 +1,31 @@
import {
CallTemplateFunctionArgs,
FormInput,
GetHttpAuthenticationConfigRequest,
TemplateFunction,
TemplateFunctionArg,
} from '../bindings/gen_events';
import { CallTemplateFunctionArgs, FormInput, TemplateFunction } from '../bindings/gen_events';
import { MaybePromise } from '../helpers';
import { Context } from './Context';
export type DynamicTemplateFunctionArg = FormInput & {
dynamic(
type AddDynamicMethod<T> = {
dynamic?: (
ctx: Context,
args: GetHttpAuthenticationConfigRequest,
): MaybePromise<Partial<FormInput> | undefined | null>;
args: CallTemplateFunctionArgs,
) => MaybePromise<Partial<T> | null | undefined>;
};
export type TemplateFunctionPlugin = TemplateFunction & {
args: (TemplateFunctionArg | DynamicTemplateFunctionArg)[];
type AddDynamic<T> = T extends any
? T extends { inputs?: FormInput[] }
? Omit<T, 'inputs'> & {
inputs: Array<AddDynamic<FormInput>>;
dynamic?: (
ctx: Context,
args: CallTemplateFunctionArgs,
) => MaybePromise<
Partial<Omit<T, 'inputs'> & { inputs: Array<AddDynamic<FormInput>> }> | null | undefined
>;
}
: T & AddDynamicMethod<T>
: never;
export type DynamicTemplateFunctionArg = AddDynamic<FormInput>;
export type TemplateFunctionPlugin = Omit<TemplateFunction, 'args'> & {
args: DynamicTemplateFunctionArg[];
onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null>;
};

View File

@@ -1,4 +1,6 @@
import { AuthenticationPlugin } from './AuthenticationPlugin';
import type { Context } from './Context';
import type { FilterPlugin } from './FilterPlugin';
import { GrpcRequestActionPlugin } from './GrpcRequestActionPlugin';
import type { HttpRequestActionPlugin } from './HttpRequestActionPlugin';
@@ -6,9 +8,10 @@ import type { ImporterPlugin } from './ImporterPlugin';
import type { TemplateFunctionPlugin } from './TemplateFunctionPlugin';
import type { ThemePlugin } from './ThemePlugin';
import type { Context } from './Context';
export type { Context };
export type { DynamicTemplateFunctionArg } from './TemplateFunctionPlugin';
export type { DynamicAuthenticationArg } from './AuthenticationPlugin';
export type { TemplateFunctionPlugin };
/**
* The global structure of a Yaak plugin

View File

@@ -2,7 +2,6 @@ import {
BootRequest,
DeleteKeyValueResponse,
FindHttpResponsesResponse,
FormInput,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdResponse,
@@ -19,14 +18,13 @@ import {
RenderHttpRequestResponse,
SendHttpRequestResponse,
TemplateFunction,
TemplateFunctionArg,
TemplateRenderResponse,
} from '@yaakapp-internal/plugins';
import { Context, PluginDefinition } from '@yaakapp/api';
import { JsonValue } from '@yaakapp/api/lib/bindings/serde_json/JsonValue';
import console from 'node:console';
import { type Stats, statSync, watch } from 'node:fs';
import path from 'node:path';
import { applyDynamicFormInput, applyFormInputDefaults } from './common';
import { EventChannel } from './EventChannel';
import { migrateTemplateFunctionSelectOptions } from './migrations';
@@ -213,24 +211,19 @@ export class PluginInstance {
return;
}
templateFunction = migrateTemplateFunctionSelectOptions(templateFunction);
// @ts-ignore
delete templateFunction.onRender;
const resolvedArgs: TemplateFunctionArg[] = [];
for (const arg of templateFunction.args) {
if (arg && 'dynamic' in arg) {
const dynamicAttrs = await arg.dynamic(ctx, payload);
const { dynamic, ...other } = arg;
resolvedArgs.push({ ...other, ...dynamicAttrs } as TemplateFunctionArg);
} else if (arg) {
resolvedArgs.push(arg);
}
templateFunction.args = resolvedArgs;
}
const fn = {
...migrateTemplateFunctionSelectOptions(templateFunction),
onRender: undefined,
};
payload.values = applyFormInputDefaults(fn.args, payload.values);
const p = { ...payload, purpose: 'preview' } as const;
const resolvedArgs = await applyDynamicFormInput(ctx, fn.args, p);
const replyPayload: InternalEventPayload = {
type: 'get_template_function_config_response',
pluginRefId: this.#workerData.pluginRefId,
function: templateFunction,
function: { ...fn, args: resolvedArgs },
};
this.#sendPayload(context, replyPayload, replyId);
return;
@@ -248,16 +241,8 @@ export class PluginInstance {
if (payload.type === 'get_http_authentication_config_request' && this.#mod?.authentication) {
const { args, actions } = this.#mod.authentication;
const resolvedArgs: FormInput[] = [];
for (const v of args) {
if (v && 'dynamic' in v) {
const dynamicAttrs = await v.dynamic(ctx, payload);
const { dynamic, ...other } = v;
resolvedArgs.push({ ...other, ...dynamicAttrs } as FormInput);
} else if (v) {
resolvedArgs.push(v);
}
}
payload.values = applyFormInputDefaults(args, payload.values);
const resolvedArgs = await applyDynamicFormInput(ctx, args, payload);
const resolvedActions: HttpAuthenticationAction[] = [];
for (const { onSelect, ...action } of actions ?? []) {
resolvedActions.push(action);
@@ -277,7 +262,8 @@ export class PluginInstance {
if (payload.type === 'call_http_authentication_request' && this.#mod?.authentication) {
const auth = this.#mod.authentication;
if (typeof auth?.onApply === 'function') {
applyFormInputDefaults(auth.args, payload.values);
auth.args = await applyDynamicFormInput(ctx, auth.args, payload);
payload.values = applyFormInputDefaults(auth.args, payload.values);
this.#sendPayload(
context,
{
@@ -332,7 +318,8 @@ export class PluginInstance {
) {
const fn = this.#mod.templateFunctions.find((a) => a.name === payload.name);
if (typeof fn?.onRender === 'function') {
applyFormInputDefaults(fn.args, payload.args.values);
const resolvedArgs = await applyDynamicFormInput(ctx, fn.args, payload.args);
payload.args.values = applyFormInputDefaults(resolvedArgs, payload.args.values);
try {
const result = await fn.onRender(ctx, payload.args);
this.#sendPayload(
@@ -652,20 +639,6 @@ function genId(len = 5): string {
return id;
}
/** Recursively apply form input defaults to a set of values */
function applyFormInputDefaults(
inputs: TemplateFunctionArg[],
values: { [p: string]: JsonValue | undefined },
) {
for (const input of inputs) {
if ('inputs' in input) {
applyFormInputDefaults(input.inputs ?? [], values);
} else if ('defaultValue' in input && values[input.name] === undefined) {
values[input.name] = input.defaultValue;
}
}
}
const watchedFiles: Record<string, Stats | null> = {};
/**

View File

@@ -0,0 +1,56 @@
import {
CallHttpAuthenticationActionArgs,
CallTemplateFunctionArgs,
JsonPrimitive,
TemplateFunctionArg,
} 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;
}
// 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[],
callArgs: CallTemplateFunctionArgs,
): Promise<DynamicTemplateFunctionArg[]>;
export async function applyDynamicFormInput(
ctx: Context,
args: DynamicAuthenticationArg[],
callArgs: CallHttpAuthenticationActionArgs,
): Promise<DynamicAuthenticationArg[]>;
export async function applyDynamicFormInput(
ctx: Context,
args: (DynamicTemplateFunctionArg | DynamicAuthenticationArg)[],
callArgs: CallTemplateFunctionArgs | CallHttpAuthenticationActionArgs,
): Promise<(DynamicTemplateFunctionArg | DynamicAuthenticationArg)[]> {
const resolvedArgs: any[] = [];
for (const { dynamic, ...arg } of args) {
const newArg: any = {
...arg,
...(typeof dynamic === 'function' ? await dynamic(ctx, callArgs as any) : undefined),
};
if ('inputs' in newArg && Array.isArray(newArg.inputs)) {
newArg.inputs = await applyDynamicFormInput(ctx, newArg.inputs, callArgs as any);
}
resolvedArgs.push(newArg);
}
return resolvedArgs;
}

View File

@@ -1,4 +1,4 @@
import { TemplateFunctionPlugin } from '@yaakapp/api/lib/plugins/TemplateFunctionPlugin';
import type { TemplateFunctionPlugin } from '@yaakapp/api';
export function migrateTemplateFunctionSelectOptions(
f: TemplateFunctionPlugin,
@@ -13,8 +13,5 @@ export function migrateTemplateFunctionSelectOptions(
return a;
});
return {
...f,
args: migratedArgs,
};
return { ...f, args: migratedArgs };
}

View File

@@ -0,0 +1,150 @@
import { CallTemplateFunctionArgs } from '@yaakapp-internal/plugins';
import { Context, DynamicTemplateFunctionArg } from '@yaakapp/api';
import { describe, expect, test } from 'vitest';
import { applyDynamicFormInput, applyFormInputDefaults } from '../src/common';
describe('applyFormInputDefaults', () => {
test('Works with top-level select', () => {
const args: DynamicTemplateFunctionArg[] = [
{
type: 'select',
name: 'test',
options: [{ label: 'Option 1', value: 'one' }],
defaultValue: 'one',
},
];
expect(applyFormInputDefaults(args, {})).toEqual({
test: 'one',
});
});
test('Works with existing value', () => {
const args: DynamicTemplateFunctionArg[] = [
{
type: 'select',
name: 'test',
options: [{ label: 'Option 1', value: 'one' }],
defaultValue: 'one',
},
];
expect(applyFormInputDefaults(args, { test: 'explicit' })).toEqual({
test: 'explicit',
});
});
test('Works with recursive select', () => {
const args: DynamicTemplateFunctionArg[] = [
{ type: 'text', name: 'dummy', defaultValue: 'top' },
{
type: 'accordion',
label: 'Test',
inputs: [
{ type: 'text', name: 'name', defaultValue: 'hello' },
{
type: 'select',
name: 'test',
options: [{ label: 'Option 1', value: 'one' }],
defaultValue: 'one',
},
],
},
];
expect(applyFormInputDefaults(args, {})).toEqual({
dummy: 'top',
test: 'one',
name: 'hello',
});
});
test('Works with dynamic options', () => {
const args: DynamicTemplateFunctionArg[] = [
{
type: 'select',
name: 'test',
defaultValue: 'one',
options: [],
dynamic() {
return { options: [{ label: 'Option 1', value: 'one' }] };
},
},
];
expect(applyFormInputDefaults(args, {})).toEqual({
test: 'one',
});
expect(applyFormInputDefaults(args, {})).toEqual({
test: 'one',
});
});
});
describe('applyDynamicFormInput', () => {
test('Works with plain input', async () => {
const ctx = {} as Context;
const args: DynamicTemplateFunctionArg[] = [
{ type: 'text', name: 'name' },
{ type: 'checkbox', name: 'checked' },
];
const callArgs: CallTemplateFunctionArgs = {
values: {},
purpose: 'preview',
};
expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([
{ type: 'text', name: 'name' },
{ type: 'checkbox', name: 'checked' },
]);
});
test('Works with dynamic input', async () => {
const ctx = {} as Context;
const args: DynamicTemplateFunctionArg[] = [
{
type: 'text',
name: 'name',
async dynamic(_ctx, _args) {
return { hidden: true };
},
},
];
const callArgs: CallTemplateFunctionArgs = {
values: {},
purpose: 'preview',
};
expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([
{ type: 'text', name: 'name', hidden: true },
]);
});
test('Works with recursive dynamic input', async () => {
const ctx = {} as Context;
const callArgs: CallTemplateFunctionArgs = {
values: { hello: 'world' },
purpose: 'preview',
};
const args: DynamicTemplateFunctionArg[] = [
{
type: 'banner',
inputs: [
{
type: 'text',
name: 'name',
async dynamic(_ctx, args) {
return { hidden: args.values.hello === 'world' };
},
},
],
},
];
expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([
{
type: 'banner',
inputs: [
{
type: 'text',
name: 'name',
hidden: true,
},
],
},
]);
});
});

View File

@@ -57,10 +57,6 @@ export const plugin: PluginDefinition = {
}
}
if (args.method !== 'GET') {
headers['x-amz-content-sha256'] = 'UNSIGNED-PAYLOAD';
}
const signature = aws4.sign(
{
host: url.host,
@@ -68,6 +64,7 @@ export const plugin: PluginDefinition = {
path: url.pathname + (url.search || ''),
service: String(values.service || 'sts'),
region: values.region ? String(values.region) : undefined,
body: values.body ? String(values.body) : undefined,
headers,
},
{
@@ -77,11 +74,6 @@ export const plugin: PluginDefinition = {
},
);
// After signing, aws4 will set:
// - opts.headers["Authorization"]
// - opts.headers["X-Amz-Date"]
// - optionally content sha256 header etc
if (signature.headers == null) {
return {};
}

View File

@@ -288,6 +288,7 @@ export const plugin: PluginDefinition = {
{
type: 'accordion',
label: 'Access Token Response',
inputs: [],
async dynamic(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
@@ -304,6 +305,7 @@ export const plugin: PluginDefinition = {
inputs: [
{
type: 'editor',
name: 'response',
defaultValue: JSON.stringify(token.response, null, 2),
hideLabel: true,
readOnly: true,

View File

@@ -1,9 +1,10 @@
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import fs from 'node:fs';
const UTF8 = 'utf8';
const options = [
{ label: 'ASCII', value: 'ascii' },
{ label: 'UTF-8', value: 'utf8' },
{ label: 'UTF-8', value: UTF8 },
{ label: 'UTF-16 LE', value: 'utf16le' },
{ label: 'Base64', value: 'base64' },
{ label: 'Base64 URL-safe', value: 'base64url' },
@@ -18,12 +19,11 @@ export const plugin: PluginDefinition = {
args: [
{ title: 'Select File', type: 'file', name: 'path', label: 'File' },
{
title: 'Select encoding',
type: 'select',
name: 'encoding',
label: 'Encoding',
defaultValue: 'utf8',
description: 'Specifies how the files bytes are decoded into text when read',
defaultValue: UTF8,
description: "Specifies how the file's bytes are decoded into text when read",
options,
},
],

View File

@@ -4,6 +4,7 @@
"description": "Template functions for working with JSON data",
"private": true,
"version": "0.1.0",
"main": "src/index.ts",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",

View File

@@ -1,6 +1,11 @@
import type { XPathResult } from '@yaak/template-function-xml';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import { JSONPath } from 'jsonpath-plus';
const RETURN_FIRST = 'first';
const RETURN_ALL = 'all';
const RETURN_JOIN = 'join';
export const plugin: PluginDefinition = {
templateFunctions: [
{
@@ -8,32 +13,59 @@ export const plugin: PluginDefinition = {
description: 'Filter JSON-formatted text using JSONPath syntax',
args: [
{
type: 'text',
type: 'editor',
name: 'input',
label: 'Input',
multiLine: true,
language: 'json',
placeholder: '{ "foo": "bar" }',
},
{
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: 'checkbox',
name: 'formatted',
label: 'Pretty Print',
description: 'Format the output as JSON',
dynamic(_ctx, args) {
return { hidden: args.values.result === RETURN_JOIN };
},
},
{ type: 'text', name: 'query', label: 'Query', placeholder: '$..foo' },
{ type: 'checkbox', name: 'formatted', label: 'Format Output' },
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
try {
const parsed = JSON.parse(String(args.values.input));
const query = String(args.values.query ?? '$').trim();
let filtered = JSONPath({ path: query, json: parsed });
if (Array.isArray(filtered)) {
filtered = filtered[0];
}
if (typeof filtered === 'string') {
return filtered;
}
if (args.values.formatted) {
return JSON.stringify(filtered, null, 2);
} else {
return JSON.stringify(filtered);
}
console.log('formatted', args.values.formatted);
return filterJSONPath(
String(args.values.input),
String(args.values.query),
(args.values.result || RETURN_FIRST) as XPathResult,
args.values.join == null ? null : String(args.values.join),
Boolean(args.values.formatted),
);
} catch {
return null;
}
@@ -79,3 +111,41 @@ export const plugin: PluginDefinition = {
},
],
};
export type JSONPathResult = 'first' | 'join' | 'all';
export function filterJSONPath(
body: string,
path: string,
result: JSONPathResult,
join: string | null,
formatted: boolean = false,
): string {
const parsed = JSON.parse(body);
let items = JSONPath({ path, json: parsed });
if (items == null) {
return '';
}
if (!Array.isArray(items)) {
// Already good
} else if (result === 'first') {
items = items[0] ?? '';
} else if (result === 'join') {
items = items.map((i) => objToStr(i, false)).join(join ?? '');
}
return objToStr(items, formatted);
}
function objToStr(o: unknown, formatted: boolean = false): string {
if (
Object.prototype.toString.call(o) === '[object Array]' ||
Object.prototype.toString.call(o) === '[object Object]'
) {
return formatted ? JSON.stringify(o, null, 2) : JSON.stringify(o);
} else {
return String(o);
}
}

View File

@@ -10,11 +10,6 @@
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
},
"dependencies": {
"jsonpath-plus": "^10.3.0",
"xpath": "^0.0.34",
"@xmldom/xmldom": "^0.9.8"
},
"devDependencies": {
"@types/jsonpath": "^0.2.4"
"@yaak/template-function-xml": "*"
}
}

View File

@@ -1,44 +1,53 @@
import { DOMParser } from '@xmldom/xmldom';
import type { JSONPathResult } from '@yaak/template-function-json';
import { filterJSONPath } from '@yaak/template-function-json';
import type { XPathResult } from '@yaak/template-function-xml';
import { filterXPath } from '@yaak/template-function-xml';
import type {
CallTemplateFunctionArgs,
Context,
DynamicTemplateFunctionArg,
FormInput,
GetHttpAuthenticationConfigRequest,
HttpResponse,
PluginDefinition,
RenderPurpose,
} from '@yaakapp/api';
import type { DynamicTemplateFunctionArg } from '@yaakapp/api/lib/plugins/TemplateFunctionPlugin';
import { JSONPath } from 'jsonpath-plus';
import { readFileSync } from 'node:fs';
import xpath from 'xpath';
const BEHAVIOR_TTL = 'ttl';
const BEHAVIOR_ALWAYS = 'always';
const BEHAVIOR_SMART = 'smart';
const behaviorArg: FormInput = {
type: 'select',
name: 'behavior',
label: 'Sending Behavior',
defaultValue: 'smart',
options: [
{ label: 'When no responses', value: BEHAVIOR_SMART },
{ label: 'Always', value: BEHAVIOR_ALWAYS },
{ label: 'When expired', value: BEHAVIOR_TTL },
],
};
const RETURN_FIRST = 'first';
const RETURN_ALL = 'all';
const RETURN_JOIN = 'join';
const ttlArg: DynamicTemplateFunctionArg = {
type: 'text',
name: 'ttl',
label: 'Expiration Time (seconds)',
placeholder: '0',
description: 'Resend the request when the latest response is older than this many seconds, or if there are no responses yet.',
dynamic(_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) {
const show = values.behavior === BEHAVIOR_TTL;
return { hidden: !show };
},
const behaviorArgs: DynamicTemplateFunctionArg = {
type: 'h_stack',
inputs: [
{
type: 'select',
name: 'behavior',
label: 'Sending Behavior',
defaultValue: BEHAVIOR_SMART,
options: [
{ label: 'When no responses', value: BEHAVIOR_SMART },
{ label: 'Always', value: BEHAVIOR_ALWAYS },
{ label: 'When expired', value: BEHAVIOR_TTL },
],
},
{
type: 'text',
name: 'ttl',
label: 'TTL (seconds)',
placeholder: '0',
defaultValue: '0',
description:
'Resend the request when the latest response is older than this many seconds, or if there are no responses yet. "0" means never expires',
dynamic(_ctx, args) {
return { hidden: args.values.behavior !== BEHAVIOR_TTL };
},
},
],
};
const requestArg: FormInput = {
@@ -54,14 +63,13 @@ export const plugin: PluginDefinition = {
description: 'Read the value of a response header, by name',
args: [
requestArg,
behaviorArgs,
{
type: 'text',
name: 'header',
label: 'Header Name',
placeholder: 'Content-Type',
},
behaviorArg,
ttlArg,
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.request || !args.values.header) return null;
@@ -86,14 +94,67 @@ export const plugin: PluginDefinition = {
aliases: ['response'],
args: [
requestArg,
behaviorArgs,
{
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 resp = await getResponse(ctx, {
requestId: String(args.values.request || ''),
purpose: 'preview',
behavior: args.values.behavior ? String(args.values.behavior) : null,
ttl: String(args.values.ttl || ''),
});
if (resp == null) {
return null;
}
const contentType =
resp?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? '';
if (contentType.includes('xml') || contentType?.includes('html')) {
return {
label: 'XPath',
placeholder: '/books[0]/id',
description: 'Enter an XPath expression used to filter the results',
};
} else {
return {
label: 'JSONPath',
placeholder: '$.books[0].id',
description: 'Enter a JSONPath expression used to filter the results',
};
}
},
},
behaviorArg,
ttlArg,
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.request || !args.values.path) return null;
@@ -118,13 +179,35 @@ export const plugin: PluginDefinition = {
}
try {
return filterJSONPath(body, String(args.values.path || ''));
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 {
return filterXPath(body, String(args.values.path || ''));
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
}
@@ -136,7 +219,7 @@ export const plugin: PluginDefinition = {
name: 'response.body.raw',
description: 'Access the entire response body, as text',
aliases: ['response'],
args: [requestArg, behaviorArg, ttlArg],
args: [requestArg, behaviorArgs],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.request) return null;
@@ -165,36 +248,6 @@ export const plugin: PluginDefinition = {
],
};
function filterJSONPath(body: string, path: string): string {
const parsed = JSON.parse(body);
const items = JSONPath({ path, json: parsed })[0];
if (items == null) {
return '';
}
if (
Object.prototype.toString.call(items) === '[object Array]' ||
Object.prototype.toString.call(items) === '[object Object]'
) {
return JSON.stringify(items);
} else {
return String(items);
}
}
function filterXPath(body: string, path: string): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const doc: any = new DOMParser().parseFromString(body, 'text/xml');
const items = xpath.select(path, doc, false);
if (Array.isArray(items)) {
return items[0] != null ? String(items[0].firstChild ?? '') : '';
} else {
// Not sure what cases this happens in (?)
return String(items);
}
}
async function getResponse(
ctx: Context,
{
@@ -244,8 +297,8 @@ async function getResponse(
function shouldSendExpired(response: HttpResponse | null, ttl: string | null): boolean {
if (response == null) return true;
const ttlSeconds = parseInt(ttl || '0');
if (isNaN(ttlSeconds)) throw new Error(`Invalid TTL "${ttl}"`);
const ttlSeconds = parseInt(ttl || '0') || 0;
if (ttlSeconds === 0) return false;
const nowMillis = Date.now();
const respMillis = new Date(response.createdAt + 'Z').getTime();
return respMillis + ttlSeconds * 1000 < nowMillis;

View File

@@ -4,6 +4,7 @@
"description": "Template functions for working with XML data",
"private": true,
"version": "0.1.0",
"main": "src/index.ts",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",

View File

@@ -2,6 +2,10 @@ import { DOMParser } from '@xmldom/xmldom';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import xpath from 'xpath';
const RETURN_FIRST = 'first';
const RETURN_ALL = 'all';
const RETURN_JOIN = 'join';
export const plugin: PluginDefinition = {
templateFunctions: [
{
@@ -15,20 +19,39 @@ export const plugin: PluginDefinition = {
multiLine: true,
placeholder: '<foo></foo>',
},
{
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: 'query', label: 'Query', placeholder: '//foo' },
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const doc: any = new DOMParser().parseFromString(String(args.values.input), 'text/xml');
const result = xpath.select(String(args.values.query), doc, false);
if (Array.isArray(result)) {
return String(result.map((c) => String(c.firstChild))[0] ?? '');
} else if (result instanceof Node) {
return String(result.firstChild);
} else {
return String(result);
}
const result = (args.values.result || RETURN_FIRST) as XPathResult;
const join = args.values.join == null ? null : String(args.values.join);
return filterXPath(String(args.values.input), String(args.values.query), result, join);
} catch {
return null;
}
@@ -36,3 +59,26 @@ export const plugin: PluginDefinition = {
},
],
};
export type XPathResult = 'first' | 'join' | 'all';
export function filterXPath(
body: string,
path: string,
result: XPathResult,
join: string | null,
): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const doc: any = new DOMParser().parseFromString(body, 'text/xml');
const items = xpath.select(path, doc, false);
if (!Array.isArray(items)) {
return String(items);
} else if (!Array.isArray(items) || result === 'first') {
return items[0] != null ? String(items[0].firstChild ?? '') : '';
} else if (result === 'join') {
return items.map((item) => String(item.firstChild ?? '')).join(join ?? '');
} else {
// Not sure what cases this happens in (?)
return String(items);
}
}

View File

@@ -32,7 +32,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
event: &InternalEvent,
plugin_handle: &PluginHandle,
) -> Result<Option<InternalEventPayload>> {
// debug!("Got event to app {event:?}");
// log::debug!("Got event to app {event:?}");
let plugin_context = event.context.to_owned();
match event.clone().payload {
InternalEventPayload::CopyTextRequest(req) => {

View File

@@ -3,11 +3,11 @@ use crate::error::Error::ModelNotFound;
use crate::error::Result;
use crate::models::{AnyModel, UpsertModelInfo};
use crate::util::{ModelChangeEvent, ModelPayload, UpdateSource};
use log::{error, warn};
use log::error;
use rusqlite::OptionalExtension;
use sea_query::{
Alias, Asterisk, Expr, Func, IntoColumnRef, IntoIden, IntoTableRef, OnConflict, Query,
ReturningClause, SimpleExpr, SqliteQueryBuilder,
Asterisk, Expr, Func, IntoColumnRef, IntoIden, IntoTableRef, OnConflict, Query, SimpleExpr,
SqliteQueryBuilder,
};
use sea_query_rusqlite::RusqliteBinder;
use std::fmt::Debug;

View File

@@ -1,7 +1,6 @@
use crate::commands::*;
use crate::migrate::migrate_db;
use crate::query_manager::QueryManager;
use crate::util::ModelChangeEvent;
use log::error;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;

View File

@@ -30,7 +30,7 @@ export type CallHttpRequestActionArgs = { httpRequest: HttpRequest, };
export type CallHttpRequestActionRequest = { index: number, pluginRefId: string, args: CallHttpRequestActionArgs, };
export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: JsonValue }, };
export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: JsonPrimitive }, };
export type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, };
@@ -74,7 +74,7 @@ export type FindHttpResponsesRequest = { requestId: string, limit?: number, };
export type FindHttpResponsesResponse = { httpResponses: Array<HttpResponse>, };
export type FormInput = { "type": "text" } & FormInputText | { "type": "editor" } & FormInputEditor | { "type": "select" } & FormInputSelect | { "type": "checkbox" } & FormInputCheckbox | { "type": "file" } & FormInputFile | { "type": "http_request" } & FormInputHttpRequest | { "type": "accordion" } & FormInputAccordion | { "type": "banner" } & FormInputBanner | { "type": "markdown" } & FormInputMarkdown;
export type FormInput = { "type": "text" } & FormInputText | { "type": "editor" } & FormInputEditor | { "type": "select" } & FormInputSelect | { "type": "checkbox" } & FormInputCheckbox | { "type": "file" } & FormInputFile | { "type": "http_request" } & FormInputHttpRequest | { "type": "accordion" } & FormInputAccordion | { "type": "h_stack" } & FormInputHStack | { "type": "banner" } & FormInputBanner | { "type": "markdown" } & FormInputMarkdown;
export type FormInputAccordion = { label: string, inputs?: Array<FormInput>, hidden?: boolean, };
@@ -224,6 +224,8 @@ defaultValue?: string, disabled?: boolean,
*/
description?: string, };
export type FormInputHStack = { inputs?: Array<FormInput>, };
export type FormInputHttpRequest = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data

View File

@@ -658,6 +658,18 @@ pub enum JsonPrimitive {
Null,
}
impl From<serde_json::Value> for JsonPrimitive {
fn from(value: serde_json::Value) -> Self {
match value {
serde_json::Value::Null => JsonPrimitive::Null,
serde_json::Value::Bool(b) => JsonPrimitive::Boolean(b),
serde_json::Value::Number(n) => JsonPrimitive::Number(n.as_f64().unwrap()),
serde_json::Value::String(s) => JsonPrimitive::String(s),
v => panic!("Unsupported JSON primitive type {:?}", v),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
@@ -733,6 +745,7 @@ pub enum FormInput {
File(FormInputFile),
HttpRequest(FormInputHttpRequest),
Accordion(FormInputAccordion),
HStack(FormInputHStack),
Banner(FormInputBanner),
Markdown(FormInputMarkdown),
}
@@ -895,7 +908,7 @@ pub struct FormInputFile {
#[ts(optional)]
pub directory: Option<bool>,
// Default file path for selection dialog
// Default file path for the selection dialog
#[ts(optional)]
pub default_path: Option<String>,
@@ -953,6 +966,14 @@ pub struct FormInputAccordion {
pub hidden: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct FormInputHStack {
#[ts(optional)]
pub inputs: Option<Vec<FormInput>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
@@ -1015,7 +1036,7 @@ pub struct CallTemplateFunctionResponse {
#[ts(export, export_to = "gen_events.ts")]
pub struct CallTemplateFunctionArgs {
pub purpose: RenderPurpose,
pub values: HashMap<String, serde_json::Value>,
pub values: HashMap<String, JsonPrimitive>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]

View File

@@ -786,7 +786,7 @@ impl PluginManager {
&self,
plugin_context: &PluginContext,
fn_name: &str,
values: HashMap<String, serde_json::Value>,
values: HashMap<String, JsonPrimitive>,
purpose: RenderPurpose,
) -> TemplateResult<String> {
let req = CallTemplateFunctionRequest {

View File

@@ -1,4 +1,4 @@
use crate::events::{PluginContext, RenderPurpose};
use crate::events::{JsonPrimitive, PluginContext, RenderPurpose};
use crate::manager::PluginManager;
use crate::native_template_functions::{
template_function_keychain_run, template_function_secure_run,
@@ -42,12 +42,17 @@ impl<R: Runtime> TemplateCallback for PluginTemplateCallback<R> {
return template_function_keychain_run(args);
}
let mut primitive_args = HashMap::new();
for (key, value) in args {
primitive_args.insert(key, JsonPrimitive::from(value));
}
let plugin_manager = &*self.app_handle.state::<PluginManager>();
let resp = plugin_manager
.call_template_function(
&self.plugin_context,
fn_name,
args,
primitive_args,
self.render_purpose.to_owned(),
)
.await?;

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}