From aa85ecb6182dd4db0cf13cbe5e250c85c1ba1b7b Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 16 Aug 2024 08:31:19 -0700 Subject: [PATCH] Template Tag Function Editor (#67) ![CleanShot 2024-08-15 at 16 53 09@2x](https://github.com/user-attachments/assets/8c0eb655-1daf-4dc8-811f-f606c770f7dc) --- .eslintrc.cjs | 1 + package-lock.json | 21 + package.json | 3 +- plugin-runtime-types/package-lock.json | 4 +- plugin-runtime-types/src/plugins/index.ts | 2 +- src-tauri/Cargo.lock | 19 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 38 +- src-tauri/src/render.rs | 11 +- src-tauri/yaak_templates/Cargo.toml | 3 + src-tauri/yaak_templates/build.rs | 6 + src-tauri/yaak_templates/src/lib.rs | 4 +- src-tauri/yaak_templates/src/parser.rs | 442 +++++++++++++----- src-tauri/yaak_templates/src/renderer.rs | 32 +- src-web/components/GlobalHooks.tsx | 84 ++-- src-web/components/IsDev.tsx | 2 +- src-web/components/MoveToWorkspaceDialog.tsx | 12 +- .../components/Settings/SettingsGeneral.tsx | 6 +- src-web/components/SettingsDropdown.tsx | 4 +- src-web/components/Sidebar.tsx | 10 +- src-web/components/TemplateFunctionDialog.tsx | 211 +++++++++ src-web/components/TemplateVariableDialog.tsx | 69 +++ src-web/components/core/Editor/Editor.css | 6 +- src-web/components/core/Editor/Editor.tsx | 89 +++- src-web/components/core/Editor/extensions.ts | 28 +- .../core/Editor/hyperlink/extension.ts | 10 +- .../components/core/Editor/twig/completion.ts | 22 +- .../components/core/Editor/twig/extension.ts | 52 ++- .../core/Editor/twig/placeholder.ts | 91 ---- .../core/Editor/twig/templateTags.ts | 115 +++++ src-web/components/core/Select.tsx | 2 +- src-web/gen/FnArg.ts | 4 + src-web/gen/Token.ts | 4 + src-web/gen/Tokens.ts | 4 + src-web/gen/Val.ts | 4 + src-web/hooks/useActiveEnvironment.ts | 2 +- .../hooks/useActiveEnvironmentVariables.ts | 24 + src-web/hooks/useActiveWorkspace.ts | 3 +- src-web/hooks/useAppInfo.ts | 11 +- src-web/hooks/useCheckForUpdates.tsx | 2 +- src-web/hooks/useDuplicateGrpcRequest.ts | 10 +- src-web/hooks/useEnvironments.ts | 28 +- src-web/hooks/useGrpcConnections.ts | 1 + src-web/hooks/useGrpcProtoFiles.ts | 9 + src-web/hooks/useGrpcRequests.ts | 30 +- src-web/hooks/useHttpRequestActions.ts | 1 + src-web/hooks/useHttpRequests.ts | 29 +- src-web/hooks/useHttpResponses.ts | 1 + src-web/hooks/useParseTemplate.ts | 14 + src-web/hooks/useRenderTemplate.ts | 26 ++ src-web/hooks/useSyncWorkspaceRequestTitle.ts | 4 +- src-web/hooks/useTemplateFunctions.ts | 88 ++++ src-web/hooks/useTemplateTokensToString.ts | 15 + src-web/hooks/useWorkspaces.ts | 20 +- src-web/lib/store.ts | 5 + src-web/lib/tauri.ts | 7 +- src-web/lib/theme/window.ts | 10 +- src-web/lib/truncate.ts | 4 + src-web/main.css | 7 + src-web/main.tsx | 2 +- tailwind.config.cjs | 3 +- tsconfig.json | 3 +- 62 files changed, 1339 insertions(+), 437 deletions(-) create mode 100644 src-tauri/yaak_templates/build.rs create mode 100644 src-web/components/TemplateFunctionDialog.tsx create mode 100644 src-web/components/TemplateVariableDialog.tsx delete mode 100644 src-web/components/core/Editor/twig/placeholder.ts create mode 100644 src-web/components/core/Editor/twig/templateTags.ts create mode 100644 src-web/gen/FnArg.ts create mode 100644 src-web/gen/Token.ts create mode 100644 src-web/gen/Tokens.ts create mode 100644 src-web/gen/Val.ts create mode 100644 src-web/hooks/useActiveEnvironmentVariables.ts create mode 100644 src-web/hooks/useParseTemplate.ts create mode 100644 src-web/hooks/useRenderTemplate.ts create mode 100644 src-web/hooks/useTemplateFunctions.ts create mode 100644 src-web/hooks/useTemplateTokensToString.ts create mode 100644 src-web/lib/truncate.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index a88c2109..f0b4a0e4 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -18,6 +18,7 @@ module.exports = { 'plugin-runtime-types/**/*', 'src-tauri/**/*', 'plugins/**/*', + 'tailwind.config.cjs', ], settings: { react: { diff --git a/package-lock.json b/package-lock.json index 49f9fb68..a46edb70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "focus-trap-react": "^10.1.1", "format-graphql": "^1.4.0", "framer-motion": "^9.0.4", + "jotai": "^2.9.3", "lucide-react": "^0.309.0", "mime": "^4.0.1", "papaparse": "^5.4.1", @@ -7423,6 +7424,26 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jotai": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.9.3.tgz", + "integrity": "sha512-IqMWKoXuEzWSShjd9UhalNsRGbdju5G2FrqNLQJT+Ih6p41VNYe2sav5hnwQx4HJr25jq9wRqvGSWGviGG6Gjw==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/js-base64": { "version": "3.7.7", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", diff --git a/package.json b/package.json index eb192631..946929c9 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,9 @@ "@tauri-apps/plugin-clipboard-manager": "^2.0.0-rc.0", "@tauri-apps/plugin-dialog": "^2.0.0-rc.0", "@tauri-apps/plugin-fs": "^2.0.0-rc.0", + "@tauri-apps/plugin-log": "^2.0.0-rc.0", "@tauri-apps/plugin-os": "^2.0.0-rc.0", "@tauri-apps/plugin-shell": "^2.0.0-rc.0", - "@tauri-apps/plugin-log": "^2.0.0-rc.0", "@yaakapp/api": "^0.1.6", "buffer": "^6.0.3", "classnames": "^2.3.2", @@ -53,6 +53,7 @@ "focus-trap-react": "^10.1.1", "format-graphql": "^1.4.0", "framer-motion": "^9.0.4", + "jotai": "^2.9.3", "lucide-react": "^0.309.0", "mime": "^4.0.1", "papaparse": "^5.4.1", diff --git a/plugin-runtime-types/package-lock.json b/plugin-runtime-types/package-lock.json index 63983cd2..03a3cfc4 100644 --- a/plugin-runtime-types/package-lock.json +++ b/plugin-runtime-types/package-lock.json @@ -1,12 +1,12 @@ { "name": "@yaakapp/api", - "version": "0.1.0-beta.4", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@yaakapp/api", - "version": "0.1.0-beta.4", + "version": "0.1.6", "dependencies": { "@types/node": "^22.0.0" }, diff --git a/plugin-runtime-types/src/plugins/index.ts b/plugin-runtime-types/src/plugins/index.ts index 5621e2a9..c3587414 100644 --- a/plugin-runtime-types/src/plugins/index.ts +++ b/plugin-runtime-types/src/plugins/index.ts @@ -3,7 +3,7 @@ import { HttpRequestActionPlugin } from './httpRequestAction'; import { ImporterPlugin } from './import'; import { ThemePlugin } from './theme'; -export { YaakContext } from './context'; +export type { YaakContext } from './context'; /** * The global structure of a Yaak plugin diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index eed43487..aa1f301a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4919,9 +4919,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.205" +version = "1.0.208" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e33aedb1a7135da52b7c21791455563facbbcc43d0f0f66165b42c21b3dfb150" +checksum = "cff085d2cb684faa248efb494c39b68e522822ac0de72ccf08109abde717cfb2" dependencies = [ "serde_derive", ] @@ -4949,9 +4949,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.205" +version = "1.0.208" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692d6f5ac90220161d6774db30c662202721e64aed9058d2c394f451261420c1" +checksum = "24008e81ff7613ed8e5ba0cfaf24e2c2f1e5b8a0495711e44fcd4882fca62bcf" dependencies = [ "proc-macro2", "quote", @@ -4971,9 +4971,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.122" +version = "1.0.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784b6203951c57ff748476b126ccb5e8e2959a5c19e5c617ab1956be3dbc68da" +checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed" dependencies = [ "itoa 1.0.11", "memchr", @@ -5844,9 +5844,9 @@ dependencies = [ [[package]] name = "tauri-plugin-clipboard-manager" -version = "2.0.0-rc.0" +version = "2.1.0-beta.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a26868f7e05a09673e4172d23acb82cd48911cca092f0e8d06179a69e5024c" +checksum = "becbc5a692e842f8d6a7ab5e490c3c36d267b5c3d5bf4b6a0cdd039d7df25569" dependencies = [ "arboard", "image 0.24.9", @@ -7649,6 +7649,9 @@ name = "yaak_templates" version = "0.1.0" dependencies = [ "log", + "serde", + "serde_json", + "ts-rs", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 706c0e02..cae2dfb8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -45,7 +45,7 @@ serde_json = { version = "1.0.116", features = ["raw_value"] } serde_yaml = "0.9.34" tauri = { workspace = true, features = ["unstable"] } tauri-plugin-shell = { workspace = true } -tauri-plugin-clipboard-manager = "2.0.0-rc.0" +tauri-plugin-clipboard-manager = "2.1.0-beta.7" tauri-plugin-dialog = "2.0.0-rc.0" tauri-plugin-fs = "2.0.0-rc.0" tauri-plugin-log = { version = "2.0.0-rc.0", features = ["colored"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f5b41112..91360a1d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -35,7 +35,7 @@ use crate::export_resources::{get_workspace_export_resources, WorkspaceExportRes use crate::grpc::metadata_to_map; use crate::http_request::send_http_request; use crate::notifications::YaakNotifier; -use crate::render::{render_request, variables_from_environment}; +use crate::render::{render_request, render_template, variables_from_environment}; use crate::updates::{UpdateMode, YaakUpdater}; use crate::window_menu::app_menu; use yaak_models::models::{ @@ -60,6 +60,7 @@ use yaak_plugin_runtime::events::{ GetHttpRequestByIdResponse, InternalEvent, InternalEventPayload, RenderHttpRequestResponse, SendHttpRequestResponse, }; +use yaak_templates::{parse_and_render, Parser, Tokens}; mod analytics; mod export_resources; @@ -99,6 +100,38 @@ async fn cmd_metadata(app_handle: AppHandle) -> Result { }) } +#[tauri::command] +async fn cmd_parse_template(template: &str) -> Result { + Ok(Parser::new(template).parse()) +} + +#[tauri::command] +async fn cmd_template_tokens_to_string(tokens: Tokens) -> Result { + Ok(tokens.to_string()) +} + +#[tauri::command] +async fn cmd_render_template( + window: WebviewWindow, + template: &str, + workspace_id: &str, + environment_id: Option<&str>, +) -> Result { + let environment = match environment_id { + Some(id) => Some( + get_environment(&window, id) + .await + .map_err(|e| e.to_string())?, + ), + None => None, + }; + let workspace = get_workspace(&window, &workspace_id) + .await + .map_err(|e| e.to_string())?; + let rendered = render_template(template, &workspace, environment.as_ref()); + Ok(rendered) +} + #[tauri::command] async fn cmd_dismiss_notification( window: WebviewWindow, @@ -1641,6 +1674,9 @@ pub fn run() { cmd_delete_http_response, cmd_delete_workspace, cmd_dismiss_notification, + cmd_parse_template, + cmd_template_tokens_to_string, + cmd_render_template, cmd_duplicate_grpc_request, cmd_duplicate_http_request, cmd_export_data, diff --git a/src-tauri/src/render.rs b/src-tauri/src/render.rs index e432c231..b2713f03 100644 --- a/src-tauri/src/render.rs +++ b/src-tauri/src/render.rs @@ -1,10 +1,15 @@ -use std::collections::HashMap; -use serde_json::Value; use crate::template_fns::timestamp; -use yaak_templates::parse_and_render; +use serde_json::Value; +use std::collections::HashMap; use yaak_models::models::{ Environment, EnvironmentVariable, HttpRequest, HttpRequestHeader, HttpUrlParameter, Workspace, }; +use yaak_templates::parse_and_render; + +pub fn render_template(template: &str, w: &Workspace, e: Option<&Environment>) -> String { + let vars = &variables_from_environment(w, e); + render(template, vars) +} pub fn render_request(r: &HttpRequest, w: &Workspace, e: Option<&Environment>) -> HttpRequest { let r = r.clone(); diff --git a/src-tauri/yaak_templates/Cargo.toml b/src-tauri/yaak_templates/Cargo.toml index b8c9bb70..d01dda6c 100644 --- a/src-tauri/yaak_templates/Cargo.toml +++ b/src-tauri/yaak_templates/Cargo.toml @@ -5,3 +5,6 @@ edition = "2021" [dependencies] log = "0.4.22" +serde = { version = "1.0.208", features = ["derive"] } +serde_json = "1.0.125" +ts-rs = { version = "9.0.1" } diff --git a/src-tauri/yaak_templates/build.rs b/src-tauri/yaak_templates/build.rs new file mode 100644 index 00000000..fb916c22 --- /dev/null +++ b/src-tauri/yaak_templates/build.rs @@ -0,0 +1,6 @@ +fn main() -> Result<(), Box> { + // Tell ts-rs where to generate types to + println!("cargo:rustc-env=TS_RS_EXPORT_DIR=../../src-web/gen"); + + Ok(()) +} diff --git a/src-tauri/yaak_templates/src/lib.rs b/src-tauri/yaak_templates/src/lib.rs index 36496725..43b4e37f 100644 --- a/src-tauri/yaak_templates/src/lib.rs +++ b/src-tauri/yaak_templates/src/lib.rs @@ -2,6 +2,4 @@ pub mod parser; pub mod renderer; pub use parser::*; -pub use renderer::*; - -pub fn template_foo() {} +pub use renderer::*; \ No newline at end of file diff --git a/src-tauri/yaak_templates/src/parser.rs b/src-tauri/yaak_templates/src/parser.rs index 7970e3c7..72162c7b 100644 --- a/src-tauri/yaak_templates/src/parser.rs +++ b/src-tauri/yaak_templates/src/parser.rs @@ -1,23 +1,92 @@ -#[derive(Clone, PartialEq, Debug)] +use serde::{Deserialize, Serialize}; +use std::fmt::Display; +use ts_rs::TS; + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct Tokens { + pub tokens: Vec, +} + +impl Display for Tokens { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = self + .tokens + .iter() + .map(|t| t.to_string()) + .collect::>() + .join(""); + write!(f, "{}", str) + } +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, TS)] +#[ts(export)] pub struct FnArg { pub name: String, pub value: Val, } -#[derive(Clone, PartialEq, Debug)] -pub enum Val { - Str(String), - Var(String), - Fn { name: String, args: Vec }, +impl Display for FnArg { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = format!("{}={}", self.name, self.value); + write!(f, "{}", str) + } } -#[derive(Clone, PartialEq, Debug)] +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case", tag = "type")] +#[ts(export)] +pub enum Val { + Str { text: String }, + Var { name: String }, + Fn { name: String, args: Vec }, + Null, +} + +impl Display for Val { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = match self { + Val::Str { text } => format!(r#""{}""#, text.to_string().replace(r#"""#, r#"\""#)), + Val::Var { name } => name.to_string(), + Val::Fn { name, args } => { + format!( + "{name}({})", + args.iter() + .filter_map(|a| match a.value.clone() { + Val::Null => None, + _ => Some(a.to_string()), + }) + .collect::>() + .join(", ") + ) + } + Val::Null => "null".to_string(), + }; + write!(f, "{}", str) + } +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case", tag = "type")] +#[ts(export)] pub enum Token { - Raw(String), - Tag(Val), + Raw { text: String }, + Tag { val: Val }, Eof, } +impl Display for Token { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = match self { + Token::Raw { text } => text.to_string(), + Token::Tag { val } => format!("${{[ {} ]}}", val.to_string()), + Token::Eof => "".to_string(), + }; + write!(f, "{}", str) + } +} + // Template Syntax // // ${[ my_var ]} @@ -42,7 +111,7 @@ impl Parser { } } - pub fn parse(&mut self) -> Vec { + pub fn parse(&mut self) -> Tokens { let start_pos = self.pos; while self.pos < self.chars.len() { @@ -65,7 +134,9 @@ impl Parser { } self.push_token(Token::Eof); - self.tokens.clone() + Tokens { + tokens: self.tokens.clone(), + } } fn parse_tag(&mut self) -> Option { @@ -85,7 +156,7 @@ impl Parser { return None; } - Some(Token::Tag(val)) + Some(Token::Tag { val }) } #[allow(dead_code)] @@ -103,9 +174,13 @@ impl Parser { if let Some((name, args)) = self.parse_fn() { Some(Val::Fn { name, args }) } else if let Some(v) = self.parse_ident() { - Some(Val::Var(v)) + if v == "null" { + Some(Val::Null) + } else { + Some(Val::Var { name: v }) + } } else if let Some(v) = self.parse_string() { - Some(Val::Str(v)) + Some(Val::Str { text: v }) } else { None } @@ -145,7 +220,7 @@ impl Parser { // Fn closed immediately self.skip_whitespace(); if self.match_str(")") { - return Some(args) + return Some(args); } while self.pos < self.chars.len() { @@ -183,7 +258,7 @@ impl Parser { } } - return Some(args); + Some(args) } fn parse_ident(&mut self) -> Option { @@ -209,7 +284,7 @@ impl Parser { return None; } - return Some(text); + Some(text) } fn parse_string(&mut self) -> Option { @@ -246,7 +321,7 @@ impl Parser { return None; } - return Some(text); + Some(text) } fn skip_whitespace(&mut self) { @@ -274,7 +349,9 @@ impl Parser { fn push_token(&mut self, token: Token) { // Push any text we've accumulated if !self.curr_text.is_empty() { - let text_token = Token::Raw(self.curr_text.clone()); + let text_token = Token::Raw { + text: self.curr_text.clone(), + }; self.tokens.push(text_token); self.curr_text.clear(); } @@ -303,14 +380,20 @@ impl Parser { #[cfg(test)] mod tests { + use crate::Val::Null; use crate::*; #[test] fn var_simple() { let mut p = Parser::new("${[ foo ]}"); assert_eq!( - p.parse(), - vec![Token::Tag(Val::Var("foo".into())), Token::Eof] + p.parse().tokens, + vec![ + Token::Tag { + val: Val::Var { name: "foo".into() } + }, + Token::Eof + ] ); } @@ -318,8 +401,13 @@ mod tests { fn var_multiple_names_invalid() { let mut p = Parser::new("${[ foo bar ]}"); assert_eq!( - p.parse(), - vec![Token::Raw("${[ foo bar ]}".into()), Token::Eof] + p.parse().tokens, + vec![ + Token::Raw { + text: "${[ foo bar ]}".into() + }, + Token::Eof + ] ); } @@ -327,8 +415,15 @@ mod tests { fn tag_string() { let mut p = Parser::new(r#"${[ "foo \"bar\" baz" ]}"#); assert_eq!( - p.parse(), - vec![Token::Tag(Val::Str(r#"foo "bar" baz"#.into())), Token::Eof] + p.parse().tokens, + vec![ + Token::Tag { + val: Val::Str { + text: r#"foo "bar" baz"#.into() + } + }, + Token::Eof + ] ); } @@ -336,11 +431,17 @@ mod tests { fn var_surrounded() { let mut p = Parser::new("Hello ${[ foo ]}!"); assert_eq!( - p.parse(), + p.parse().tokens, vec![ - Token::Raw("Hello ".to_string()), - Token::Tag(Val::Var("foo".into())), - Token::Raw("!".to_string()), + Token::Raw { + text: "Hello ".to_string() + }, + Token::Tag { + val: Val::Var { name: "foo".into() } + }, + Token::Raw { + text: "!".to_string() + }, Token::Eof, ] ); @@ -350,12 +451,14 @@ mod tests { fn fn_simple() { let mut p = Parser::new("${[ foo() ]}"); assert_eq!( - p.parse(), + p.parse().tokens, vec![ - Token::Tag(Val::Fn { - name: "foo".into(), - args: Vec::new(), - }), + Token::Tag { + val: Val::Fn { + name: "foo".into(), + args: Vec::new(), + } + }, Token::Eof ] ); @@ -365,15 +468,17 @@ mod tests { fn fn_ident_arg() { let mut p = Parser::new("${[ foo(a=bar) ]}"); assert_eq!( - p.parse(), + p.parse().tokens, vec![ - Token::Tag(Val::Fn { - name: "foo".into(), - args: vec![FnArg { - name: "a".into(), - value: Val::Var("bar".into()) - }], - }), + Token::Tag { + val: Val::Fn { + name: "foo".into(), + args: vec![FnArg { + name: "a".into(), + value: Val::Var { name: "bar".into() } + }], + } + }, Token::Eof ] ); @@ -383,25 +488,27 @@ mod tests { fn fn_ident_args() { let mut p = Parser::new("${[ foo(a=bar,b = baz, c =qux ) ]}"); assert_eq!( - p.parse(), + p.parse().tokens, vec![ - Token::Tag(Val::Fn { - name: "foo".into(), - args: vec![ - FnArg { - name: "a".into(), - value: Val::Var("bar".into()) - }, - FnArg { - name: "b".into(), - value: Val::Var("baz".into()) - }, - FnArg { - name: "c".into(), - value: Val::Var("qux".into()) - }, - ], - }), + Token::Tag { + val: Val::Fn { + name: "foo".into(), + args: vec![ + FnArg { + name: "a".into(), + value: Val::Var { name: "bar".into() } + }, + FnArg { + name: "b".into(), + value: Val::Var { name: "baz".into() } + }, + FnArg { + name: "c".into(), + value: Val::Var { name: "qux".into() } + }, + ], + } + }, Token::Eof ] ); @@ -411,25 +518,29 @@ mod tests { fn fn_mixed_args() { let mut p = Parser::new(r#"${[ foo(aaa=bar,bb="baz \"hi\"", c=qux ) ]}"#); assert_eq!( - p.parse(), + p.parse().tokens, vec![ - Token::Tag(Val::Fn { - name: "foo".into(), - args: vec![ - FnArg { - name: "aaa".into(), - value: Val::Var("bar".into()) - }, - FnArg { - name: "bb".into(), - value: Val::Str(r#"baz "hi""#.into()) - }, - FnArg { - name: "c".into(), - value: Val::Var("qux".into()) - }, - ], - }), + Token::Tag { + val: Val::Fn { + name: "foo".into(), + args: vec![ + FnArg { + name: "aaa".into(), + value: Val::Var { name: "bar".into() } + }, + FnArg { + name: "bb".into(), + value: Val::Str { + text: r#"baz "hi""#.into() + } + }, + FnArg { + name: "c".into(), + value: Val::Var { name: "qux".into() } + }, + ], + } + }, Token::Eof ] ); @@ -439,18 +550,20 @@ mod tests { fn fn_nested() { let mut p = Parser::new("${[ foo(b=bar()) ]}"); assert_eq!( - p.parse(), + p.parse().tokens, vec![ - Token::Tag(Val::Fn { - name: "foo".into(), - args: vec![FnArg { - name: "b".into(), - value: Val::Fn { - name: "bar".into(), - args: vec![], - } - }], - }), + Token::Tag { + val: Val::Fn { + name: "foo".into(), + args: vec![FnArg { + name: "b".into(), + value: Val::Fn { + name: "bar".into(), + args: vec![], + } + }], + } + }, Token::Eof ] ); @@ -460,35 +573,134 @@ mod tests { fn fn_nested_args() { let mut p = Parser::new(r#"${[ outer(a=inner(a=foo, b="i"), c="o") ]}"#); assert_eq!( - p.parse(), + p.parse().tokens, vec![ - Token::Tag(Val::Fn { - name: "outer".into(), - args: vec![ - FnArg { - name: "a".into(), - value: Val::Fn { - name: "inner".into(), - args: vec![ - FnArg { - name: "a".into(), - value: Val::Var("foo".into()) - }, - FnArg { - name: "b".into(), - value: Val::Str("i".into()), - }, - ], - } - }, - FnArg { - name: "c".into(), - value: Val::Str("o".into()) - }, - ], - }), + Token::Tag { + val: Val::Fn { + name: "outer".into(), + args: vec![ + FnArg { + name: "a".into(), + value: Val::Fn { + name: "inner".into(), + args: vec![ + FnArg { + name: "a".into(), + value: Val::Var { name: "foo".into() } + }, + FnArg { + name: "b".into(), + value: Val::Str { text: "i".into() }, + }, + ], + } + }, + FnArg { + name: "c".into(), + value: Val::Str { text: "o".into() } + }, + ], + } + }, Token::Eof ] ); } + + #[test] + fn token_display_var() { + assert_eq!( + Val::Var { + name: "foo".to_string() + } + .to_string(), + "foo" + ); + } + + #[test] + fn token_display_str() { + assert_eq!( + Val::Str { + text: r#"Hello "You""#.to_string() + } + .to_string(), + r#""Hello \"You\"""# + ); + } + + #[test] + fn token_null_fn_arg() { + assert_eq!( + Val::Fn { + name: "fn".to_string(), + args: vec![ + FnArg { + name: "n".to_string(), + value: Null, + }, + FnArg { + name: "a".to_string(), + value: Val::Str { + text: "aaa".to_string() + } + } + ] + } + .to_string(), + r#"fn(a="aaa")"# + ); + } + + #[test] + fn token_display_fn() { + assert_eq!( + Token::Tag { + val: Val::Fn { + name: "foo".to_string(), + args: vec![ + FnArg { + name: "arg".to_string(), + value: Val::Str { + text: "v".to_string() + } + }, + FnArg { + name: "arg2".to_string(), + value: Val::Var { + name: "my_var".to_string() + } + } + ] + } + } + .to_string(), + r#"${[ foo(arg="v", arg2=my_var) ]}"# + ); + } + + #[test] + fn tokens_display() { + assert_eq!( + Tokens { + tokens: vec![ + Token::Tag { + val: Val::Var { + name: "my_var".to_string() + } + }, + Token::Raw { + text: " Some cool text ".to_string(), + }, + Token::Tag { + val: Val::Str { + text: "Hello World".to_string() + } + } + ] + } + .to_string(), + r#"${[ my_var ]} Some cool text ${[ "Hello World" ]}"# + ); + } } diff --git a/src-tauri/yaak_templates/src/renderer.rs b/src-tauri/yaak_templates/src/renderer.rs index a0b767c7..ce7b079b 100644 --- a/src-tauri/yaak_templates/src/renderer.rs +++ b/src-tauri/yaak_templates/src/renderer.rs @@ -1,4 +1,4 @@ -use crate::{FnArg, Parser, Token, Val}; +use crate::{FnArg, Parser, Token, Tokens, Val}; use log::warn; use std::collections::HashMap; @@ -15,27 +15,27 @@ pub fn parse_and_render( } pub fn render( - tokens: Vec, + tokens: Tokens, vars: &HashMap, cb: Option, ) -> String { let mut doc_str: Vec = Vec::new(); - for t in tokens { + for t in tokens.tokens { match t { - Token::Raw(s) => doc_str.push(s), - Token::Tag(val) => doc_str.push(render_tag(val, &vars, cb)), + Token::Raw { text } => doc_str.push(text), + Token::Tag { val } => doc_str.push(render_tag(val, &vars, cb)), Token::Eof => {} } } - return doc_str.join(""); + doc_str.join("") } fn render_tag(val: Val, vars: &HashMap, cb: Option) -> String { match val { - Val::Str(s) => s.into(), - Val::Var(name) => match vars.get(name.as_str()) { + Val::Str { text } => text.into(), + Val::Var { name } => match vars.get(name.as_str()) { Some(v) => v.to_string(), None => "".into(), }, @@ -46,14 +46,14 @@ fn render_tag(val: Val, vars: &HashMap, cb: Option (name.to_string(), s.to_string()), + value: Val::Str { text }, + } => (name.to_string(), text.to_string()), FnArg { name, - value: Val::Var(i), + value: Val::Var { name: var_name }, } => ( name.to_string(), - vars.get(i.as_str()).unwrap_or(&empty).to_string(), + vars.get(var_name.as_str()).unwrap_or(&empty).to_string(), ), FnArg { name, value: val } => { (name.to_string(), render_tag(val.clone(), vars, cb)) @@ -64,13 +64,17 @@ fn render_tag(val: Val, vars: &HashMap, cb: Option match cb(name.as_str(), resolved_args.clone()) { Ok(s) => s, Err(e) => { - warn!("Failed to run template callback {}({:?}): {}", name, resolved_args, e); + warn!( + "Failed to run template callback {}({:?}): {}", + name, resolved_args, e + ); "".to_string() } }, None => "".into(), } } + Val::Null => "".into() } } @@ -147,7 +151,7 @@ mod tests { result.to_string() ); } - + #[test] fn render_fn_err() { let vars = HashMap::new(); diff --git a/src-web/components/GlobalHooks.tsx b/src-web/components/GlobalHooks.tsx index b948cb1b..13c8a3ae 100644 --- a/src-web/components/GlobalHooks.tsx +++ b/src-web/components/GlobalHooks.tsx @@ -1,18 +1,19 @@ import { useQueryClient } from '@tanstack/react-query'; import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; import type { Model } from '@yaakapp/api'; +import { useSetAtom } from 'jotai'; import { useEffect } from 'react'; import { useEnsureActiveCookieJar, useMigrateActiveCookieJarId } from '../hooks/useActiveCookieJar'; import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast'; import { cookieJarsQueryKey } from '../hooks/useCookieJars'; import { useCopy } from '../hooks/useCopy'; -import { environmentsQueryKey } from '../hooks/useEnvironments'; +import { environmentsAtom } from '../hooks/useEnvironments'; import { foldersQueryKey } from '../hooks/useFolders'; import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections'; import { grpcEventsQueryKey } from '../hooks/useGrpcEvents'; -import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests'; +import { grpcRequestsAtom } from '../hooks/useGrpcRequests'; import { useHotKey } from '../hooks/useHotKey'; -import { httpRequestsQueryKey } from '../hooks/useHttpRequests'; +import { httpRequestsAtom } from '../hooks/useHttpRequests'; import { httpResponsesQueryKey } from '../hooks/useHttpResponses'; import { keyValueQueryKey } from '../hooks/useKeyValue'; import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent'; @@ -25,7 +26,7 @@ import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; import { settingsQueryKey, useSettings } from '../hooks/useSettings'; import { useSyncThemeToDocument } from '../hooks/useSyncThemeToDocument'; import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette'; -import { workspacesQueryKey } from '../hooks/useWorkspaces'; +import { workspacesAtom } from '../hooks/useWorkspaces'; import { useZoom } from '../hooks/useZoom'; import { extractKeyValue } from '../lib/keyValueStore'; import { modelsEq } from '../lib/models'; @@ -64,25 +65,22 @@ export function GlobalHooks() { windowLabel: string; } + const setWorkspaces = useSetAtom(workspacesAtom); + const setHttpRequests = useSetAtom(httpRequestsAtom); + const setGrpcRequests = useSetAtom(grpcRequestsAtom); + const setEnvironments = useSetAtom(environmentsAtom); + useListenToTauriEvent('upserted_model', ({ payload }) => { const { model, windowLabel } = payload; const queryKey = - model.model === 'http_request' - ? httpRequestsQueryKey(model) - : model.model === 'http_response' + model.model === 'http_response' ? httpResponsesQueryKey(model) : model.model === 'folder' ? foldersQueryKey(model) - : model.model === 'environment' - ? environmentsQueryKey(model) : model.model === 'grpc_connection' ? grpcConnectionsQueryKey(model) : model.model === 'grpc_event' ? grpcEventsQueryKey(model) - : model.model === 'grpc_request' - ? grpcRequestsQueryKey(model) - : model.model === 'workspace' - ? workspacesQueryKey(model) : model.model === 'key_value' ? keyValueQueryKey(model) : model.model === 'cookie_jar' @@ -91,11 +89,6 @@ export function GlobalHooks() { ? settingsQueryKey() : null; - if (queryKey === null) { - console.log('Unrecognized updated model:', model); - return; - } - if (model.model === 'http_request' && windowLabel !== getCurrentWebviewWindow().label) { wasUpdatedExternally(model.id); } @@ -106,21 +99,27 @@ export function GlobalHooks() { if (shouldIgnoreModel(model, windowLabel)) return; - queryClient.setQueryData(queryKey, (current: unknown) => { - if (model.model === 'key_value') { - // Special-case for KeyValue - return extractKeyValue(model); - } - - if (Array.isArray(current)) { - const index = current.findIndex((v) => modelsEq(v, model)) ?? -1; - if (index >= 0) { - return [...current.slice(0, index), model, ...current.slice(index + 1)]; - } else { - return pushToFront ? [model, ...(current ?? [])] : [...(current ?? []), model]; + if (model.model === 'workspace') { + setWorkspaces(updateModelList(model, pushToFront)); + } else if (model.model === 'http_request') { + setHttpRequests(updateModelList(model, pushToFront)); + } else if (model.model === 'grpc_request') { + setGrpcRequests(updateModelList(model, pushToFront)); + } else if (model.model === 'environment') { + setEnvironments(updateModelList(model, pushToFront)); + } else if (queryKey != null) { + // TODO: Convert all models to use Jotai + queryClient.setQueryData(queryKey, (current: unknown) => { + if (model.model === 'key_value') { + // Special-case for KeyValue + return extractKeyValue(model); } - } - }); + + if (Array.isArray(current)) { + return updateModelList(model, pushToFront)(current); + } + }); + } }); useListenToTauriEvent('deleted_model', ({ payload }) => { @@ -128,17 +127,17 @@ export function GlobalHooks() { if (shouldIgnoreModel(model, windowLabel)) return; if (model.model === 'workspace') { - queryClient.setQueryData(workspacesQueryKey(), removeById(model)); + setWorkspaces(removeById(model)); } else if (model.model === 'http_request') { - queryClient.setQueryData(httpRequestsQueryKey(model), removeById(model)); + setHttpRequests(removeById(model)); } else if (model.model === 'http_response') { queryClient.setQueryData(httpResponsesQueryKey(model), removeById(model)); } else if (model.model === 'folder') { queryClient.setQueryData(foldersQueryKey(model), removeById(model)); } else if (model.model === 'environment') { - queryClient.setQueryData(environmentsQueryKey(model), removeById(model)); + setEnvironments(removeById(model)); } else if (model.model === 'grpc_request') { - queryClient.setQueryData(grpcRequestsQueryKey(model), removeById(model)); + setGrpcRequests(removeById(model)); } else if (model.model === 'grpc_connection') { queryClient.setQueryData(grpcConnectionsQueryKey(model), removeById(model)); } else if (model.model === 'grpc_event') { @@ -192,8 +191,19 @@ export function GlobalHooks() { return null; } +function updateModelList(model: T, pushToFront: boolean) { + return (current: T[]): T[] => { + const index = current.findIndex((v) => modelsEq(v, model)) ?? -1; + if (index >= 0) { + return [...current.slice(0, index), model, ...current.slice(index + 1)]; + } else { + return pushToFront ? [model, ...(current ?? [])] : [...(current ?? []), model]; + } + }; +} + function removeById(model: T) { - return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id); + return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id) ?? []; } const shouldIgnoreModel = (payload: Model, windowLabel: string) => { diff --git a/src-web/components/IsDev.tsx b/src-web/components/IsDev.tsx index f50bb5f0..3c380688 100644 --- a/src-web/components/IsDev.tsx +++ b/src-web/components/IsDev.tsx @@ -7,7 +7,7 @@ interface Props { export function IsDev({ children }: Props) { const appInfo = useAppInfo(); - if (!appInfo?.isDev) { + if (!appInfo.isDev) { return null; } diff --git a/src-web/components/MoveToWorkspaceDialog.tsx b/src-web/components/MoveToWorkspaceDialog.tsx index 82b92f8f..6a88daec 100644 --- a/src-web/components/MoveToWorkspaceDialog.tsx +++ b/src-web/components/MoveToWorkspaceDialog.tsx @@ -1,13 +1,10 @@ -import { useQueryClient } from '@tanstack/react-query'; +import type { GrpcRequest, HttpRequest } from '@yaakapp/api'; import React, { useState } from 'react'; import { useAppRoutes } from '../hooks/useAppRoutes'; -import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests'; -import { httpRequestsQueryKey } from '../hooks/useHttpRequests'; import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest'; import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest'; import { useWorkspaces } from '../hooks/useWorkspaces'; import { fallbackRequestName } from '../lib/fallbackRequestName'; -import type { GrpcRequest, HttpRequest } from '@yaakapp/api'; import { Button } from './core/Button'; import { InlineCode } from './core/InlineCode'; import { Select } from './core/Select'; @@ -22,7 +19,6 @@ interface Props { export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Props) { const workspaces = useWorkspaces(); - const queryClient = useQueryClient(); const updateHttpRequest = useUpdateAnyHttpRequest(); const updateGrpcRequest = useUpdateAnyGrpcRequest(); const toast = useToast(); @@ -52,14 +48,8 @@ export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Pr if (request.model === 'http_request') { await updateHttpRequest.mutateAsync(args); - await queryClient.invalidateQueries({ - queryKey: httpRequestsQueryKey({ workspaceId: activeWorkspaceId }), - }); } else if (request.model === 'grpc_request') { await updateGrpcRequest.mutateAsync(args); - await queryClient.invalidateQueries({ - queryKey: grpcRequestsQueryKey({ workspaceId: activeWorkspaceId }), - }); } // Hide after a moment, to give time for request to disappear diff --git a/src-web/components/Settings/SettingsGeneral.tsx b/src-web/components/Settings/SettingsGeneral.tsx index 38612d76..aab2b240 100644 --- a/src-web/components/Settings/SettingsGeneral.tsx +++ b/src-web/components/Settings/SettingsGeneral.tsx @@ -117,9 +117,9 @@ export function SettingsGeneral() { App Info - - - + + + ); diff --git a/src-web/components/SettingsDropdown.tsx b/src-web/components/SettingsDropdown.tsx index 306ac1be..932e22da 100644 --- a/src-web/components/SettingsDropdown.tsx +++ b/src-web/components/SettingsDropdown.tsx @@ -73,7 +73,7 @@ export function SettingsDropdown() { leftSlot: , onSelect: () => exportData.mutate(), }, - { type: 'separator', label: `Yaak v${appInfo?.version}` }, + { type: 'separator', label: `Yaak v${appInfo.version}` }, { key: 'update-check', label: 'Check for Updates', @@ -92,7 +92,7 @@ export function SettingsDropdown() { label: 'Changelog', leftSlot: , rightSlot: , - onSelect: () => open(`https://yaak.app/changelog/${appInfo?.version}`), + onSelect: () => open(`https://yaak.app/changelog/${appInfo.version}`), }, ]} > diff --git a/src-web/components/Sidebar.tsx b/src-web/components/Sidebar.tsx index c22a3d8c..a4df33ad 100644 --- a/src-web/components/Sidebar.tsx +++ b/src-web/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import type { Folder, GrpcRequest, HttpRequest, Workspace } from '@yaakapp/api'; +import type { Folder, GrpcRequest, HttpRequest, Model, Workspace } from '@yaakapp/api'; import classNames from 'classnames'; import type { ReactNode } from 'react'; import React, { Fragment, useCallback, useMemo, useRef, useState } from 'react'; @@ -576,7 +576,7 @@ type SidebarItemProps = { itemId: string; itemName: string; itemFallbackName: string; - itemModel: string; + itemModel: Model['model']; itemPrefix: ReactNode; useProminentStyles?: boolean; selected: boolean; @@ -658,8 +658,10 @@ function SidebarItem({ const sendRequest = useSendAnyHttpRequest(); const moveToWorkspace = useMoveToWorkspace(itemId); const sendManyRequests = useSendManyRequests(); - const latestHttpResponse = useLatestHttpResponse(itemId); - const latestGrpcConnection = useLatestGrpcConnection(itemId); + const latestHttpResponse = useLatestHttpResponse(itemModel === 'http_request' ? itemId : null); + const latestGrpcConnection = useLatestGrpcConnection( + itemModel === 'grpc_request' ? itemId : null, + ); const updateHttpRequest = useUpdateAnyHttpRequest(); const workspaces = useWorkspaces(); const updateGrpcRequest = useUpdateAnyGrpcRequest(); diff --git a/src-web/components/TemplateFunctionDialog.tsx b/src-web/components/TemplateFunctionDialog.tsx new file mode 100644 index 00000000..65787292 --- /dev/null +++ b/src-web/components/TemplateFunctionDialog.tsx @@ -0,0 +1,211 @@ +import { useCallback, useMemo, useState } from 'react'; +import type { FnArg } from '../gen/FnArg'; +import type { Tokens } from '../gen/Tokens'; +import { useHttpRequests } from '../hooks/useHttpRequests'; +import { useRenderTemplate } from '../hooks/useRenderTemplate'; +import type { + TemplateFunction, + TemplateFunctionArg, + TemplateFunctionHttpRequestArg, + TemplateFunctionSelectArg, + TemplateFunctionTextArg, +} from '../hooks/useTemplateFunctions'; +import { useTemplateTokensToString } from '../hooks/useTemplateTokensToString'; +import { fallbackRequestName } from '../lib/fallbackRequestName'; +import { Button } from './core/Button'; +import { InlineCode } from './core/InlineCode'; +import { PlainInput } from './core/PlainInput'; +import { Select } from './core/Select'; +import { VStack } from './core/Stacks'; + +const NULL_ARG = '__NULL__'; + +interface Props { + templateFunction: TemplateFunction; + initialTokens: Tokens; + hide: () => void; + onChange: (insert: string) => void; +} + +export function TemplateFunctionDialog({ templateFunction, hide, initialTokens, onChange }: Props) { + const [argValues, setArgValues] = useState>(() => { + 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) { + const initialArg = initialArgs.find((a) => a.name === arg.name); + const initialArgValue = + initialArg?.value.type === 'str' + ? initialArg?.value.text + : // TODO: Implement variable-based args + '__NULL__'; + initial[arg.name] = initialArgValue ?? NULL_ARG; + } + + return initial; + }); + + const setArgValue = useCallback((name: string, value: string) => { + setArgValues((v) => ({ ...v, [name]: value })); + }, []); + + const tokens: Tokens = useMemo(() => { + const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({ + name, + value: + argValues[name] === NULL_ARG + ? { type: 'null' } + : { + type: 'str', + text: argValues[name] ?? '', + }, + })); + + return { + tokens: [ + { + type: 'tag', + val: { + type: 'fn', + name: templateFunction.name, + args: argTokens, + }, + }, + ], + }; + }, [argValues, templateFunction.name]); + + const tagText = useTemplateTokensToString(tokens); + + const handleDone = () => { + if (tagText.data) { + onChange(tagText.data); + } + hide(); + }; + + const rendered = useRenderTemplate(tagText.data ?? ''); + + return ( + + + {templateFunction.args.map((a: TemplateFunctionArg, i: number) => { + switch (a.type) { + case 'select': + return ( + setArgValue(a.name, v)} + value={argValues[a.name] ?? '__ERROR__'} + /> + ); + case 'text': + return ( + setArgValue(a.name, v)} + value={argValues[a.name] ?? '__ERROR__'} + /> + ); + case 'http_request': + return ( + setArgValue(a.name, v)} + value={argValues[a.name] ?? '__ERROR__'} + /> + ); + } + })} + + {rendered.data} + + + ); +} + +function TextArg({ + arg, + onChange, + value, +}: { + arg: TemplateFunctionTextArg; + value: string; + onChange: (v: string) => void; +}) { + const handleChange = useCallback( + (value: string) => { + onChange(value === '' ? NULL_ARG : value); + }, + [onChange], + ); + + return ( + + ); +} + +function SelectArg({ + arg, + value, + onChange, +}: { + arg: TemplateFunctionSelectArg; + value: string; + onChange: (v: string) => void; +}) { + return ( + ({ + label: fallbackRequestName(r), + value: r.id, + })), + ]} + /> + ); +} diff --git a/src-web/components/TemplateVariableDialog.tsx b/src-web/components/TemplateVariableDialog.tsx new file mode 100644 index 00000000..e80a4580 --- /dev/null +++ b/src-web/components/TemplateVariableDialog.tsx @@ -0,0 +1,69 @@ +import type { EnvironmentVariable } from '@yaakapp/api'; +import { useCallback, useMemo, useState } from 'react'; +import type { Tokens } from '../gen/Tokens'; +import { useActiveEnvironmentVariables } from '../hooks/useActiveEnvironmentVariables'; +import { useRenderTemplate } from '../hooks/useRenderTemplate'; +import { useTemplateTokensToString } from '../hooks/useTemplateTokensToString'; +import { Button } from './core/Button'; +import { InlineCode } from './core/InlineCode'; +import { Select } from './core/Select'; +import { VStack } from './core/Stacks'; + +interface Props { + definition: EnvironmentVariable; + initialTokens: Tokens; + hide: () => void; + onChange: (rawTag: string) => void; +} + +export function TemplateVariableDialog({ hide, onChange, initialTokens }: Props) { + const variables = useActiveEnvironmentVariables(); + const [selectedVariableName, setSelectedVariableName] = useState(() => { + return initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'var' + ? initialTokens.tokens[0]?.val.name + : ''; // Should never happen + }); + + const tokens: Tokens = useMemo(() => { + const selectedVariable = variables.find((v) => v.name === selectedVariableName); + return { + tokens: [ + { + type: 'tag', + val: { + type: 'var', + name: selectedVariable?.name ?? '', + }, + }, + ], + }; + }, [selectedVariableName, variables]); + + const tagText = useTemplateTokensToString(tokens); + const handleDone = useCallback(async () => { + if (tagText.data != null) { + onChange(tagText.data); + } + hide(); + }, [hide, onChange, tagText.data]); + + const rendered = useRenderTemplate(tagText.data ?? ''); + + return ( + + +