From 593a7ab7e52c259ad13c0d7fd8bb41f66c10f292 Mon Sep 17 00:00:00 2001 From: Gregor Majcen Date: Thu, 13 Nov 2025 14:57:11 +0100 Subject: [PATCH] Add an option to allow jsonpath/xpath to return as array (#297) Co-authored-by: Gregory Schier --- package-lock.json | 7 +- .../src/bindings/gen_events.ts | 6 +- .../src/plugins/AuthenticationPlugin.ts | 27 ++- .../src/plugins/TemplateFunctionPlugin.ts | 36 ++-- .../plugin-runtime-types/src/plugins/index.ts | 7 +- packages/plugin-runtime/src/PluginInstance.ts | 61 ++---- packages/plugin-runtime/src/common.ts | 56 +++++ packages/plugin-runtime/src/migrations.ts | 7 +- packages/plugin-runtime/tests/common.test.ts | 150 +++++++++++++ plugins/auth-aws/src/index.ts | 10 +- plugins/auth-oauth2/src/index.ts | 2 + plugins/template-function-fs/src/index.ts | 8 +- plugins/template-function-json/package.json | 1 + plugins/template-function-json/src/index.ts | 106 ++++++++-- .../template-function-response/package.json | 7 +- .../template-function-response/src/index.ts | 183 ++++++++++------ plugins/template-function-xml/package.json | 1 + plugins/template-function-xml/src/index.ts | 66 +++++- src-tauri/src/plugin_events.rs | 2 +- src-tauri/yaak-models/src/db_context.rs | 6 +- src-tauri/yaak-models/src/lib.rs | 1 - src-tauri/yaak-plugins/bindings/gen_events.ts | 6 +- src-tauri/yaak-plugins/src/events.rs | 25 ++- src-tauri/yaak-plugins/src/manager.rs | 2 +- .../yaak-plugins/src/template_callback.rs | 9 +- src-web/components/DynamicForm.tsx | 89 +++++--- src-web/components/TemplateFunctionDialog.tsx | 200 ++++++++++-------- src-web/components/core/Dropdown.tsx | 2 +- src-web/components/core/Editor/Editor.css | 10 + src-web/components/core/Editor/Editor.tsx | 4 + src-web/components/core/Editor/extensions.ts | 8 +- src-web/hooks/useHttpAuthenticationConfig.ts | 2 +- src-web/hooks/useTemplateFunctionConfig.ts | 29 ++- src-web/lib/editEnvironment.tsx | 2 +- 34 files changed, 800 insertions(+), 338 deletions(-) create mode 100644 packages/plugin-runtime/src/common.ts create mode 100644 packages/plugin-runtime/tests/common.test.ts diff --git a/package-lock.json b/package-lock.json index d4c1a5af..4edb44c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/packages/plugin-runtime-types/src/bindings/gen_events.ts b/packages/plugin-runtime-types/src/bindings/gen_events.ts index b6cbe26e..cbb99501 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_events.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_events.ts @@ -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, }; -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, hidden?: boolean, }; @@ -224,6 +224,8 @@ defaultValue?: string, disabled?: boolean, */ description?: string, }; +export type FormInputHStack = { inputs?: Array, }; + export type FormInputHttpRequest = { /** * The name of the input. The value will be stored at this object attribute in the resulting data diff --git a/packages/plugin-runtime-types/src/plugins/AuthenticationPlugin.ts b/packages/plugin-runtime-types/src/plugins/AuthenticationPlugin.ts index 52b19cac..8e420b93 100644 --- a/packages/plugin-runtime-types/src/plugins/AuthenticationPlugin.ts +++ b/packages/plugin-runtime-types/src/plugins/AuthenticationPlugin.ts @@ -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 = { + dynamic?: ( ctx: Context, - args: GetHttpAuthenticationConfigRequest, - ): MaybePromise | undefined | null>; + args: CallHttpAuthenticationActionArgs, + ) => MaybePromise | null | undefined>; }; +type AddDynamic = T extends any + ? T extends { inputs?: FormInput[] } + ? Omit & { + inputs: Array>; + dynamic?: ( + ctx: Context, + args: CallHttpAuthenticationActionArgs, + ) => MaybePromise< + Partial & { inputs: Array> }> | null | undefined + >; + } + : T & AddDynamicMethod + : never; + +export type DynamicAuthenticationArg = AddDynamic; + export type AuthenticationPlugin = GetHttpAuthenticationSummaryResponse & { - args: (FormInput | DynamicFormInput)[]; + args: DynamicAuthenticationArg[]; onApply( ctx: Context, args: CallHttpAuthenticationRequest, diff --git a/packages/plugin-runtime-types/src/plugins/TemplateFunctionPlugin.ts b/packages/plugin-runtime-types/src/plugins/TemplateFunctionPlugin.ts index 7dabdb1c..e6482a2d 100644 --- a/packages/plugin-runtime-types/src/plugins/TemplateFunctionPlugin.ts +++ b/packages/plugin-runtime-types/src/plugins/TemplateFunctionPlugin.ts @@ -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 = { + dynamic?: ( ctx: Context, - args: GetHttpAuthenticationConfigRequest, - ): MaybePromise | undefined | null>; + args: CallTemplateFunctionArgs, + ) => MaybePromise | null | undefined>; }; -export type TemplateFunctionPlugin = TemplateFunction & { - args: (TemplateFunctionArg | DynamicTemplateFunctionArg)[]; +type AddDynamic = T extends any + ? T extends { inputs?: FormInput[] } + ? Omit & { + inputs: Array>; + dynamic?: ( + ctx: Context, + args: CallTemplateFunctionArgs, + ) => MaybePromise< + Partial & { inputs: Array> }> | null | undefined + >; + } + : T & AddDynamicMethod + : never; + +export type DynamicTemplateFunctionArg = AddDynamic; + +export type TemplateFunctionPlugin = Omit & { + args: DynamicTemplateFunctionArg[]; onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise; }; diff --git a/packages/plugin-runtime-types/src/plugins/index.ts b/packages/plugin-runtime-types/src/plugins/index.ts index ebd1dabf..4e8fed2f 100644 --- a/packages/plugin-runtime-types/src/plugins/index.ts +++ b/packages/plugin-runtime-types/src/plugins/index.ts @@ -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 diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index d9519dec..1e60b9af 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -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 = {}; /** diff --git a/packages/plugin-runtime/src/common.ts b/packages/plugin-runtime/src/common.ts new file mode 100644 index 00000000..be2929fb --- /dev/null +++ b/packages/plugin-runtime/src/common.ts @@ -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; + +export async function applyDynamicFormInput( + ctx: Context, + args: DynamicAuthenticationArg[], + callArgs: CallHttpAuthenticationActionArgs, +): Promise; + +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; +} diff --git a/packages/plugin-runtime/src/migrations.ts b/packages/plugin-runtime/src/migrations.ts index 830c6f84..0cde0eab 100644 --- a/packages/plugin-runtime/src/migrations.ts +++ b/packages/plugin-runtime/src/migrations.ts @@ -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 }; } diff --git a/packages/plugin-runtime/tests/common.test.ts b/packages/plugin-runtime/tests/common.test.ts new file mode 100644 index 00000000..0b371cbb --- /dev/null +++ b/packages/plugin-runtime/tests/common.test.ts @@ -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, + }, + ], + }, + ]); + }); +}); diff --git a/plugins/auth-aws/src/index.ts b/plugins/auth-aws/src/index.ts index efe3f4cf..e6f1faec 100644 --- a/plugins/auth-aws/src/index.ts +++ b/plugins/auth-aws/src/index.ts @@ -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 {}; } diff --git a/plugins/auth-oauth2/src/index.ts b/plugins/auth-oauth2/src/index.ts index b2834d77..66169ae0 100644 --- a/plugins/auth-oauth2/src/index.ts +++ b/plugins/auth-oauth2/src/index.ts @@ -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, diff --git a/plugins/template-function-fs/src/index.ts b/plugins/template-function-fs/src/index.ts index ce200818..31e58900 100644 --- a/plugins/template-function-fs/src/index.ts +++ b/plugins/template-function-fs/src/index.ts @@ -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 file’s bytes are decoded into text when read', + defaultValue: UTF8, + description: "Specifies how the file's bytes are decoded into text when read", options, }, ], diff --git a/plugins/template-function-json/package.json b/plugins/template-function-json/package.json index e417cb4f..5b8026b5 100755 --- a/plugins/template-function-json/package.json +++ b/plugins/template-function-json/package.json @@ -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", diff --git a/plugins/template-function-json/src/index.ts b/plugins/template-function-json/src/index.ts index 5fb30e12..8c4e5949 100755 --- a/plugins/template-function-json/src/index.ts +++ b/plugins/template-function-json/src/index.ts @@ -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 { 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); + } +} diff --git a/plugins/template-function-response/package.json b/plugins/template-function-response/package.json index 46c0f3de..0b92c0b0 100644 --- a/plugins/template-function-response/package.json +++ b/plugins/template-function-response/package.json @@ -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": "*" } } diff --git a/plugins/template-function-response/src/index.ts b/plugins/template-function-response/src/index.ts index 8b0f0eda..6470f39d 100644 --- a/plugins/template-function-response/src/index.ts +++ b/plugins/template-function-response/src/index.ts @@ -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 { 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 { 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 { 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; diff --git a/plugins/template-function-xml/package.json b/plugins/template-function-xml/package.json index f2b9b238..706a6748 100755 --- a/plugins/template-function-xml/package.json +++ b/plugins/template-function-xml/package.json @@ -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", diff --git a/plugins/template-function-xml/src/index.ts b/plugins/template-function-xml/src/index.ts index 38945b9d..00288995 100755 --- a/plugins/template-function-xml/src/index.ts +++ b/plugins/template-function-xml/src/index.ts @@ -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: '', }, + { + 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 { 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); + } +} diff --git a/src-tauri/src/plugin_events.rs b/src-tauri/src/plugin_events.rs index ef3de607..1c90c964 100644 --- a/src-tauri/src/plugin_events.rs +++ b/src-tauri/src/plugin_events.rs @@ -32,7 +32,7 @@ pub(crate) async fn handle_plugin_event( event: &InternalEvent, plugin_handle: &PluginHandle, ) -> Result> { - // 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) => { diff --git a/src-tauri/yaak-models/src/db_context.rs b/src-tauri/yaak-models/src/db_context.rs index 4b7d2970..f20795ea 100644 --- a/src-tauri/yaak-models/src/db_context.rs +++ b/src-tauri/yaak-models/src/db_context.rs @@ -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; diff --git a/src-tauri/yaak-models/src/lib.rs b/src-tauri/yaak-models/src/lib.rs index ecd0d970..b41fb4d5 100644 --- a/src-tauri/yaak-models/src/lib.rs +++ b/src-tauri/yaak-models/src/lib.rs @@ -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; diff --git a/src-tauri/yaak-plugins/bindings/gen_events.ts b/src-tauri/yaak-plugins/bindings/gen_events.ts index b6cbe26e..cbb99501 100644 --- a/src-tauri/yaak-plugins/bindings/gen_events.ts +++ b/src-tauri/yaak-plugins/bindings/gen_events.ts @@ -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, }; -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, hidden?: boolean, }; @@ -224,6 +224,8 @@ defaultValue?: string, disabled?: boolean, */ description?: string, }; +export type FormInputHStack = { inputs?: Array, }; + export type FormInputHttpRequest = { /** * The name of the input. The value will be stored at this object attribute in the resulting data diff --git a/src-tauri/yaak-plugins/src/events.rs b/src-tauri/yaak-plugins/src/events.rs index 9be5e795..d04affd8 100644 --- a/src-tauri/yaak-plugins/src/events.rs +++ b/src-tauri/yaak-plugins/src/events.rs @@ -658,6 +658,18 @@ pub enum JsonPrimitive { Null, } +impl From 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, - // Default file path for selection dialog + // Default file path for the selection dialog #[ts(optional)] pub default_path: Option, @@ -953,6 +966,14 @@ pub struct FormInputAccordion { pub hidden: Option, } +#[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>, +} + #[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, + pub values: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] diff --git a/src-tauri/yaak-plugins/src/manager.rs b/src-tauri/yaak-plugins/src/manager.rs index a68fbf73..9cee0b52 100644 --- a/src-tauri/yaak-plugins/src/manager.rs +++ b/src-tauri/yaak-plugins/src/manager.rs @@ -786,7 +786,7 @@ impl PluginManager { &self, plugin_context: &PluginContext, fn_name: &str, - values: HashMap, + values: HashMap, purpose: RenderPurpose, ) -> TemplateResult { let req = CallTemplateFunctionRequest { diff --git a/src-tauri/yaak-plugins/src/template_callback.rs b/src-tauri/yaak-plugins/src/template_callback.rs index a3158b8d..15016222 100644 --- a/src-tauri/yaak-plugins/src/template_callback.rs +++ b/src-tauri/yaak-plugins/src/template_callback.rs @@ -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 TemplateCallback for PluginTemplateCallback { 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::(); let resp = plugin_manager .call_template_function( &self.plugin_context, fn_name, - args, + primitive_args, self.render_purpose.to_owned(), ) .await?; diff --git a/src-web/components/DynamicForm.tsx b/src-web/components/DynamicForm.tsx index 9313318e..73c18a26 100644 --- a/src-web/components/DynamicForm.tsx +++ b/src-web/components/DynamicForm.tsx @@ -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 { autocompleteFunctions?: boolean; autocompleteVariables?: boolean; stateKey: string; + className?: string; disabled?: boolean; } @@ -51,6 +52,7 @@ export function DynamicForm>({ autocompleteVariables, autocompleteFunctions, stateKey, + className, disabled, }: Props) { const setDataAttr = useCallback( @@ -61,7 +63,7 @@ export function DynamicForm>({ ); return ( - >({ 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>({ - inputs, - autocompleteFunctions, - autocompleteVariables, - stateKey, - setDataAttr, - data, - disabled, +function FormInputsStack>({ className, -}: Pick< - Props, - 'inputs' | 'autocompleteFunctions' | 'autocompleteVariables' | 'stateKey' | 'data' -> & { - setDataAttr: (name: string, value: JsonPrimitive) => void; - disabled?: boolean; - className?: string; -}) { + ...props +}: FormInputsProps & { className?: string }) { return ( >({ 'pr-1', // A bit of space between inputs and scrollbar )} > + + + ); +} + +type FormInputsProps = Pick< + Props, + 'inputs' | 'autocompleteFunctions' | 'autocompleteVariables' | 'stateKey' | 'data' +> & { + setDataAttr: (name: string, value: JsonPrimitive) => void; + disabled?: boolean; +}; + +function FormInputs>({ + inputs, + autocompleteFunctions, + autocompleteVariables, + stateKey, + setDataAttr, + data, + disabled, +}: FormInputsProps) { + return ( + <> {inputs?.map((input, i) => { if ('hidden' in input && input.hidden) { return null; @@ -113,7 +126,7 @@ function FormInputs>({ case 'select': return ( setDataAttr(input.name, v)} value={ @@ -126,7 +139,7 @@ function FormInputs>({ case 'text': return ( >({ case 'editor': return ( >({ ); case 'accordion': return ( -
+
- >({
); + case 'h_stack': + return ( + + + + ); case 'banner': return ( - >({ ); case 'markdown': - return {input.content}; + return {input.content}; } })} - + ); } @@ -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({ />
} - hideGutter />
@@ -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} diff --git a/src-web/components/TemplateFunctionDialog.tsx b/src-web/components/TemplateFunctionDialog.tsx index 98005289..d9006fc4 100644 --- a/src-web/components/TemplateFunctionDialog.tsx +++ b/src-web/components/TemplateFunctionDialog.tsx @@ -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 = {}; - 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 ( - { e.preventDefault(); handleDone(); }} > - {name === 'secure' ? ( - setArgValues({ ...argValues, value })} - /> - ) : ( - - )} - {enablePreview && ( - - - - Rendered Preview - {rendered.isPending && } - - - - - {rendered.error || tagText.error ? ( - - {`${rendered.error || tagText.error}`.replace(/^Render Error: /, '')} - - ) : dataContainsSecrets && !showSecretsInPreview ? ( - ------ sensitive values hidden ------ - ) : tooLarge ? ( - 'too large to preview' - ) : ( - rendered.data || <>  - )} - - - )} -
- {templateFunction.name === 'secure' && ( - +
+ {name === 'secure' ? ( + setArgValues({ ...argValues, value })} + /> + ) : ( + )} -
- +
+ {enablePreview ? ( + + + + Rendered Preview + {rendered.isPending && } + + + + + {rendered.error || tagText.error ? ( + + {`${rendered.error || tagText.error}`.replace(/^Render Error: /, '')} + + ) : dataContainsSecrets && !showSecretsInPreview ? ( + + ------ sensitive values hidden ------ + + ) : tooLarge ? ( + 'too large to preview' + ) : ( + rendered.data || <>  + )} + + + ) : ( + + )} +
+ {templateFunction.name === 'secure' && ( + + )} + +
+
+ ); } + +/** + * 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 = {}; + 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; +} diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index 88120c39..ce892753 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -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', diff --git a/src-web/components/core/Editor/Editor.css b/src-web/components/core/Editor/Editor.css index 5798b654..30e22bf7 100644 --- a/src-web/components/core/Editor/Editor.css +++ b/src-web/components/core/Editor/Editor.css @@ -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 { diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index cd1192ef..f4d7310c 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -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: {fn.name}(…), 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 diff --git a/src-web/components/core/Editor/extensions.ts b/src-web/components/core/Editor/extensions.ts index 142a2fee..a145e27d 100644 --- a/src-web/components/core/Editor/extensions.ts +++ b/src-web/components/core/Editor/extensions.ts @@ -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) { +} & Pick) { 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; diff --git a/src-web/hooks/useHttpAuthenticationConfig.ts b/src-web/hooks/useHttpAuthenticationConfig.ts index 3ccad040..b21ac8cb 100644 --- a/src-web/hooks/useHttpAuthenticationConfig.ts +++ b/src-web/hooks/useHttpAuthenticationConfig.ts @@ -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( 'cmd_get_http_authentication_config', { diff --git a/src-web/hooks/useTemplateFunctionConfig.ts b/src-web/hooks/useTemplateFunctionConfig.ts index a858ae3b..fdc11a9e 100644 --- a/src-web/hooks/useTemplateFunctionConfig.ts +++ b/src-web/hooks/useTemplateFunctionConfig.ts @@ -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( - '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, + model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace, + environmentId: string | undefined, +) { + const config = await invokeCmd( + 'cmd_template_function_config', + { + functionName, + values, + model, + environmentId, + }, + ); + return config.function; +} diff --git a/src-web/lib/editEnvironment.tsx b/src-web/lib/editEnvironment.tsx index aa75b7fe..0ac7692a 100644 --- a/src-web/lib/editEnvironment.tsx +++ b/src-web/lib/editEnvironment.tsx @@ -45,7 +45,7 @@ export async function editEnvironment( id: 'environment-editor', noPadding: true, size: 'lg', - className: 'h-[80vh]', + className: 'h-[90vh]', render: () => (