mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
fix: workflow UX improvements (#13929)
Signed-off-by: Yashodhan Joshi <yjdoc2@gmail.com> Co-authored-by: Yashodhan Joshi <yjdoc2@gmail.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Yashodhan Joshi
github-actions[bot]
parent
e74d5ea210
commit
3e33b0e262
@@ -7483,7 +7483,8 @@
|
||||
"destination": {
|
||||
"createNew": "1d3633323c8f0077",
|
||||
"loadError": "c16f9b31b93a10fe",
|
||||
"select": "293d404a500f5f9a"
|
||||
"select": "293d404a500f5f9a",
|
||||
"unsupportedType": "e621e27b8dbececc"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "b608eeb53fcc68bd",
|
||||
@@ -7495,7 +7496,10 @@
|
||||
"rafDesc": "1a2371e8a8288336",
|
||||
"rbf": "49d2a6cb57aa0a5d",
|
||||
"rbfDesc": "85b0d4dffb8da524",
|
||||
"select": "91e946d9c38e8cc0"
|
||||
"saveError": "f3c121cdbebe8a9e",
|
||||
"saved": "294fa2f6e1e6930a",
|
||||
"select": "91e946d9c38e8cc0",
|
||||
"updated": "2842652ab6f4de5e"
|
||||
},
|
||||
"stepPicker": {
|
||||
"noMatch": "dc5fd200953aaea5",
|
||||
@@ -16009,8 +16013,7 @@
|
||||
"actions": "ff8059dc6752afdd",
|
||||
"addStepRequired": "6792f09c3a1457f4",
|
||||
"canvas": {
|
||||
"deleteEdge": "b8d070f14e6a3f75",
|
||||
"insertStep": "4920f14b3361eba2",
|
||||
"edgeDeleteHint": "3dc4596d874740ea",
|
||||
"tidyUp": "ccb75b518c626e67",
|
||||
"undo": "a8283ade31856f71"
|
||||
},
|
||||
@@ -16034,7 +16037,7 @@
|
||||
"draftSaveSuccess": "9a8b645b5b805984",
|
||||
"edit": "464c4ffd019e1e96",
|
||||
"editMode": "e7945ee25eb63c57",
|
||||
"finishStepsBeforePublish": "53e9da9d81f56b62",
|
||||
"finishStepsBeforePublish": "a5fc67e0214af8f6",
|
||||
"header": "e2073645cb0dbbdd",
|
||||
"history": {
|
||||
"button": "848f54e89660cea4",
|
||||
@@ -16049,6 +16052,7 @@
|
||||
"loadError": "8760fa8bffc6db1e",
|
||||
"loadRunError": "8a811f7af06daad1",
|
||||
"open": "0e76960093379060",
|
||||
"openFullView": "fd43711ac463dacb",
|
||||
"started": "ecbc89cd37a03734",
|
||||
"status": "920e413c7d411b61",
|
||||
"success": "c88a0b907419a70c",
|
||||
@@ -16077,6 +16081,39 @@
|
||||
"name": "43d40c5c912ac933",
|
||||
"namePlaceholder": "942ce282b041e034",
|
||||
"nameRequired": "d9485c0343d2208f",
|
||||
"ndv": {
|
||||
"collapseSteps": "8e27273ad3edae3f",
|
||||
"config": "b332c3492d5eb10a",
|
||||
"editStep": "0b36814508f01168",
|
||||
"executeStep": "3835a6edeef58d4b",
|
||||
"expandSteps": "d5deb3a53554c0b5",
|
||||
"input": "36ecb4f8669133ce",
|
||||
"inputTag": "36ecb4f8669133ce",
|
||||
"itemN": "446bc9b826d4ef6e",
|
||||
"itemsCount": "23d3c9ed2692ec54",
|
||||
"nextStep": "d04049749d1556c5",
|
||||
"nextStepBtn": "226366c3301a2d1f",
|
||||
"noBranchItems": "7b46b1c0de3baef7",
|
||||
"noInput": "f43a93940fcdf323",
|
||||
"noItems": "9e2909c61c5af49b",
|
||||
"noOutput": "e4e80b1a04844da0",
|
||||
"noRunYet": "4b04cc4a2d55011d",
|
||||
"noRunsList": "dc67471b3ab6175c",
|
||||
"noUpstream": "9b19447b3c82b3f1",
|
||||
"notInRun": "9e790df5c0e4e65a",
|
||||
"openStep": "f687d18152a5b541",
|
||||
"output": "b2439bcb8dee14b6",
|
||||
"prevStep": "165a9b6293bb0ec5",
|
||||
"prevStepBtn": "f1fe8694fccd3947",
|
||||
"runLabel": "00d60e31a4e6b834",
|
||||
"runTest": "f3c5e08fb592ef7b",
|
||||
"runToView": "b5cadca1291d4f47",
|
||||
"sample": "95a2f1edb08a7612",
|
||||
"switchRun": "998597a30b6bd22f",
|
||||
"testInputHint": "8194a23c7c24941c",
|
||||
"viewJson": "db1a21a0bc2ef8fb",
|
||||
"viewTable": "16d1c9050a0b32e8"
|
||||
},
|
||||
"node": {
|
||||
"commentAdd": "e596e6256a50ea78",
|
||||
"commentLabel": "0a64e30504e36b7b",
|
||||
@@ -16097,9 +16134,6 @@
|
||||
"destinationLoadError": "71d36dc93bcbc3f2",
|
||||
"destinationRequired": "c8e1c09e97aee2b2",
|
||||
"destinationSelect": "293d404a500f5f9a",
|
||||
"destinationSetUpLater": "eb5e104944c17e59",
|
||||
"destinationSetUpLaterActive": "604d2ea3c5c6b18f",
|
||||
"destinationSetUpLaterPrompt": "f7f6a34ee3238bc7",
|
||||
"disable": "0dcb89a146396e1a",
|
||||
"disabledBadge": "75081b593d15cf6e",
|
||||
"enable": "2cb515be8c376129",
|
||||
@@ -16108,12 +16142,10 @@
|
||||
"functionBodySet": "5933fc01fb4ca2d0",
|
||||
"functionCreateNew": "b608eeb53fcc68bd",
|
||||
"functionDesc": "4ced4a80ca5617e7",
|
||||
"functionFlattenTitle": "716ac2fcdbf0dcc6",
|
||||
"functionLoadError": "8cdd38319d8c63f3",
|
||||
"functionRequired": "fc91c369d3852cdb",
|
||||
"functionSelect": "91e946d9c38e8cc0",
|
||||
"functionSetUpLater": "eb5e104944c17e59",
|
||||
"functionSetUpLaterActive": "9ddd8f4caa9a49b5",
|
||||
"functionSetUpLaterPrompt": "c3862cfb10e9929e",
|
||||
"incidentCommonTitle": "baa999e86c583ac2",
|
||||
"incidentEventTypeLabel": "9c85b75fdf0bf48c",
|
||||
"incidentNoExtraFields": "7a50b4db5cb51c41",
|
||||
@@ -16129,7 +16161,12 @@
|
||||
"stepNoMatch": "b617336f2394e926",
|
||||
"stepSearchPlaceholder": "0e01c94a0a506dda",
|
||||
"triggerBody": "9beedd43f580d986",
|
||||
"triggerDataExampleNote": "85f8d73cb6774334"
|
||||
"triggerDataExampleNote": "85f8d73cb6774334",
|
||||
"unsavedFnDiscard": "e07e053c2e10b3f1",
|
||||
"unsavedFnKeep": "624a83c761415dc9",
|
||||
"unsavedFnMessage": "a0dd222d86ad8302",
|
||||
"unsavedFnSave": "738fd39e529fc714",
|
||||
"unsavedFnTitle": "627d4344eeac32a1"
|
||||
},
|
||||
"pauseSuccess": "91e07e6cc429f4b1",
|
||||
"publish": "859390eb495b2ead",
|
||||
@@ -16140,13 +16177,33 @@
|
||||
"results": {
|
||||
"close": "9de7725d62abd9b7",
|
||||
"collapse": "be6eb1fc3b05bf9d",
|
||||
"destinationLabel": "293d404a500f5f9a",
|
||||
"destinationMissing": "2058c56fd4ba3f10",
|
||||
"destinationUnsupported": "68263fb831c819ae",
|
||||
"empty": "7f201223918e9b11",
|
||||
"expand": "07548c2c6511d4d1",
|
||||
"functionLabel": "c803710302d5769d",
|
||||
"headersLabel": "194e9fe656a1bcab",
|
||||
"hideConfig": "e01fa663d5f71a8e",
|
||||
"noConfig": "ff20686abbd903ae",
|
||||
"nodesTitle": "1de3df70ddf4faab",
|
||||
"title": "9930295f98218365"
|
||||
"openNode": "f75325ca06d612ea",
|
||||
"outputFormatLabel": "ef22a51c869f0983",
|
||||
"templateLabel": "0575f29df888a27e",
|
||||
"title": "9930295f98218365",
|
||||
"tlsDisabled": "75081b593d15cf6e",
|
||||
"tlsVerifyLabel": "2294e8ee95e587ae",
|
||||
"viewConfig": "a711fc6c69c8c4a7"
|
||||
},
|
||||
"resumeSuccess": "e4002848f1307954",
|
||||
"runOverlay": {
|
||||
"banner": "b8f29597b17a2b8f",
|
||||
"clear": "9cd21f6f5000f6c4",
|
||||
"ghostNodes": "c60de049dae5dd7d",
|
||||
"missingNode": "c3f9cfedd833eba2"
|
||||
},
|
||||
"runs": {
|
||||
"debugInEditor": "521fb552f4ddc5d7",
|
||||
"edit": "e7945ee25eb63c57",
|
||||
"staleGraphWarning": "e954ce70f907b190",
|
||||
"title": "a444842d2c72b82f"
|
||||
@@ -16154,6 +16211,11 @@
|
||||
"saveDraft": "6c462142d3681836",
|
||||
"saveError": "2be35bc8ec574ba7",
|
||||
"search": "a5b9f624c06b9981",
|
||||
"start": {
|
||||
"actionHint": "3ebd524d3730efcc",
|
||||
"chooseAction": "f3009e224e14fc87",
|
||||
"triggerHint": "b7cc692397818e23"
|
||||
},
|
||||
"subtitle": "42349f7319e8d8fe",
|
||||
"test": {
|
||||
"button": "532eaabd9574880d",
|
||||
|
||||
@@ -58,6 +58,7 @@ pub struct WorkflowTestInput {
|
||||
pub struct WorkflowTestResult {
|
||||
errors: HashMap<String, NodeErrors>,
|
||||
inputs: HashMap<String, Vec<Value>>,
|
||||
outputs: HashMap<String, Vec<Value>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -557,6 +558,7 @@ pub async fn test_workflow(
|
||||
Ok(v) => MetaHttpResponse::json(WorkflowTestResult {
|
||||
errors: v.errors,
|
||||
inputs: v.inputs,
|
||||
outputs: v.outputs,
|
||||
}),
|
||||
Err(e) => MetaHttpResponse::bad_request(e),
|
||||
}
|
||||
@@ -701,6 +703,7 @@ pub async fn retry_workflow(
|
||||
Ok(v) => MetaHttpResponse::json(WorkflowTestResult {
|
||||
errors: v.errors,
|
||||
inputs: v.inputs,
|
||||
outputs: v.outputs,
|
||||
}),
|
||||
Err(e) => MetaHttpResponse::bad_request(e),
|
||||
}
|
||||
|
||||
@@ -305,6 +305,8 @@ pub struct FunctionParams {
|
||||
pub after_flatten: bool,
|
||||
#[serde(default)]
|
||||
pub num_args: u8,
|
||||
#[serde(default)]
|
||||
pub raw_fn: Option<String>,
|
||||
}
|
||||
|
||||
impl MemorySize for FunctionParams {
|
||||
@@ -641,6 +643,7 @@ mod tests {
|
||||
after_flatten: false,
|
||||
// params: "row".to_string(),
|
||||
num_args: 0,
|
||||
raw_fn: None,
|
||||
};
|
||||
let func_node = NodeData::Function(func);
|
||||
let payload = json::json!({
|
||||
@@ -908,6 +911,7 @@ mod tests {
|
||||
name: "my_func".to_string(),
|
||||
after_flatten: false,
|
||||
num_args: 0,
|
||||
raw_fn: None,
|
||||
});
|
||||
let node = Node::new(
|
||||
"func-1".to_string(),
|
||||
@@ -1042,6 +1046,7 @@ mod tests {
|
||||
name: "my_fn".to_string(),
|
||||
after_flatten: false,
|
||||
num_args: 0,
|
||||
raw_fn: None,
|
||||
});
|
||||
assert!(data.mem_size() > 0);
|
||||
}
|
||||
@@ -1052,6 +1057,7 @@ mod tests {
|
||||
name: "fn".to_string(),
|
||||
after_flatten: true,
|
||||
num_args: 2,
|
||||
raw_fn: None,
|
||||
};
|
||||
assert!(p.mem_size() > 0);
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ pub fn validate_nodes_edges(
|
||||
edges: &[Edge],
|
||||
is_draft: bool,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
if nodes.len() < 2 || (!is_draft && edges.is_empty()) {
|
||||
if !is_draft && (nodes.len() < 2 || edges.is_empty()) {
|
||||
return Err(anyhow!(
|
||||
"there must be more than 1 node and at least 1 edge"
|
||||
));
|
||||
@@ -1020,6 +1020,7 @@ mod tests {
|
||||
name: "test_function".to_string(),
|
||||
after_flatten: false,
|
||||
num_args: 0,
|
||||
raw_fn: None,
|
||||
}),
|
||||
200.0,
|
||||
100.0,
|
||||
@@ -1031,6 +1032,7 @@ mod tests {
|
||||
name: "test_function2".to_string(),
|
||||
after_flatten: true,
|
||||
num_args: 0,
|
||||
raw_fn: None,
|
||||
}),
|
||||
300.0,
|
||||
100.0,
|
||||
@@ -1065,6 +1067,7 @@ mod tests {
|
||||
name: "test_function".to_string(),
|
||||
after_flatten: false,
|
||||
num_args: 0,
|
||||
raw_fn: None,
|
||||
}),
|
||||
100.0,
|
||||
100.0,
|
||||
@@ -1325,6 +1328,7 @@ mod tests {
|
||||
name: "test_function".to_string(),
|
||||
after_flatten: false,
|
||||
num_args: 0,
|
||||
raw_fn: None,
|
||||
}),
|
||||
300.0,
|
||||
100.0,
|
||||
@@ -1435,6 +1439,7 @@ mod tests {
|
||||
name: "test_function1".to_string(),
|
||||
after_flatten: true, // Checked
|
||||
num_args: 0,
|
||||
raw_fn: None,
|
||||
}),
|
||||
200.0,
|
||||
100.0,
|
||||
@@ -1446,6 +1451,7 @@ mod tests {
|
||||
name: "test_function2".to_string(),
|
||||
after_flatten: false, // Unchecked after checked
|
||||
num_args: 0,
|
||||
raw_fn: None,
|
||||
}),
|
||||
300.0,
|
||||
100.0,
|
||||
@@ -1523,6 +1529,7 @@ mod tests {
|
||||
name: "fn1".to_string(),
|
||||
after_flatten: false,
|
||||
num_args: 0,
|
||||
raw_fn: None,
|
||||
}),
|
||||
);
|
||||
node_map.insert(
|
||||
@@ -1587,6 +1594,7 @@ mod tests {
|
||||
name: "fn1".to_string(),
|
||||
after_flatten: false,
|
||||
num_args: 0,
|
||||
raw_fn: None,
|
||||
}),
|
||||
0.0,
|
||||
0.0,
|
||||
@@ -1680,10 +1688,13 @@ mod tests {
|
||||
// partial-graph scenario - still fails validation today.
|
||||
let nodes = vec![trigger_node("t1")];
|
||||
|
||||
let err = validate_nodes_edges(&nodes, &[], true).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
let res = validate_nodes_edges(&nodes, &[], true);
|
||||
assert!(res.is_ok());
|
||||
|
||||
let res = validate_nodes_edges(&nodes, &[], false);
|
||||
assert!(res.is_err_and(|v| {
|
||||
v.to_string()
|
||||
.contains("more than 1 node and at least 1 edge")
|
||||
);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,6 +1052,13 @@ impl ConditionExt for ConditionList {
|
||||
#[async_trait]
|
||||
impl ConditionExt for Condition {
|
||||
async fn evaluate(&self, row: &Map<String, Value>) -> bool {
|
||||
// this is a special case introduced for draft workflows
|
||||
// ideal in other places FE does not allow empty cols,
|
||||
// but on BE we do not have specific restrictions, so when col is empty
|
||||
// it is UB. Thus we use that for draft workflows and cond always evaluates to true
|
||||
if self.column.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let val = match row.get(&self.column) {
|
||||
Some(val) => val,
|
||||
None => {
|
||||
@@ -1297,6 +1304,13 @@ async fn evaluate_condition(
|
||||
condition_value: &Value,
|
||||
ignore_case: bool,
|
||||
) -> bool {
|
||||
// this is a special case introduced for draft workflows
|
||||
// ideal in other places FE does not allow empty cols,
|
||||
// but on BE we do not have specific restrictions, so when col is empty
|
||||
// it is UB. Thus we use that for draft workflows and cond always evaluates to true
|
||||
if column.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let val: &Value = match row.get(column) {
|
||||
Some(val) => val,
|
||||
None => {
|
||||
|
||||
@@ -6291,6 +6291,7 @@ mod tests {
|
||||
name: "my_func".to_string(),
|
||||
after_flatten: false,
|
||||
num_args: 0,
|
||||
raw_fn: None,
|
||||
}),
|
||||
0.0,
|
||||
0.0,
|
||||
|
||||
@@ -71,6 +71,7 @@ pub struct WorkflowResult {
|
||||
pub stream_details: HashMap<StreamParams, Vec<(usize, Value)>>,
|
||||
pub errors: HashMap<String, NodeErrors>,
|
||||
pub inputs: HashMap<String, Vec<Value>>,
|
||||
pub outputs: HashMap<String, Vec<Value>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
@@ -214,35 +215,27 @@ impl PipelineExt for Workflow {
|
||||
for node in &self.nodes {
|
||||
if let NodeData::Function(func_params) = &node.data {
|
||||
// this can be the case where there is dummy node for fn in draft stage
|
||||
if func_params.name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let transform = get_transforms(&self.org_id, &func_params.name).await?;
|
||||
|
||||
// Check if function is JS or VRL
|
||||
let compiled_runtime = if transform.is_js() {
|
||||
// Compile JS function
|
||||
let js_config = compile_js_function(&transform.function, &self.org_id)?;
|
||||
CompiledFunctionRuntime::JS(js_config, transform.is_result_array_js())
|
||||
let (func, res_array) = if func_params.name.is_empty() {
|
||||
// if there is a raw fn provided, then we use that as the fn
|
||||
if let Some(raw) = func_params.raw_fn.as_ref() {
|
||||
(
|
||||
raw.to_owned(),
|
||||
config::meta::function::RESULT_ARRAY.is_match(raw),
|
||||
)
|
||||
} else {
|
||||
// if no fn name and no raw fn, then we cannot do anything, just skip
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Compile VRL function (default)
|
||||
let vrl_runtime_config =
|
||||
compile_vrl_function(&transform.function, &self.org_id)?;
|
||||
let registry = vrl_runtime_config
|
||||
.config
|
||||
.get_custom::<vector_enrichment::TableRegistry>()
|
||||
.unwrap();
|
||||
registry.finish_load();
|
||||
CompiledFunctionRuntime::VRL(
|
||||
Box::new(VRLResultResolver {
|
||||
program: vrl_runtime_config.program,
|
||||
fields: vrl_runtime_config.fields,
|
||||
}),
|
||||
transform.is_result_array_vrl(),
|
||||
)
|
||||
// there is a fn name, so ue that fn
|
||||
let transform = get_transforms(&self.org_id, &func_params.name).await?;
|
||||
let res_arr = transform.is_result_array_js();
|
||||
(transform.function, res_arr)
|
||||
};
|
||||
|
||||
let js_config = compile_js_function(&func, &self.org_id)?;
|
||||
let compiled_runtime = CompiledFunctionRuntime::JS(js_config, res_array);
|
||||
|
||||
function_map.insert(node.get_node_id(), compiled_runtime);
|
||||
}
|
||||
}
|
||||
@@ -581,7 +574,8 @@ impl ExecutablePipeline {
|
||||
child_senders,
|
||||
result_sender: result_sender_cp,
|
||||
error_sender: error_sender_cp,
|
||||
inputs_sender: None, // not applicable for pipelines
|
||||
inputs_sender: None, // not applicable for pipelines
|
||||
outputs_sender: None, // not applicable for pipelines
|
||||
};
|
||||
let task = tokio::spawn(process_node(metadata, node, function_runtime, channels));
|
||||
node_tasks.push(task);
|
||||
@@ -795,6 +789,7 @@ impl ExecutablePipeline {
|
||||
|
||||
// inputs_channel
|
||||
let (inputs_sender, mut inputs_receiver) = channel::<(String, Value)>(batch_size);
|
||||
let (outputs_sender, mut outputs_receiver) = channel::<(String, Value)>(batch_size);
|
||||
|
||||
let mut node_senders = HashMap::new();
|
||||
let mut node_receivers = HashMap::new();
|
||||
@@ -820,6 +815,7 @@ impl ExecutablePipeline {
|
||||
let result_sender_cp = node.children.is_empty().then_some(result_sender.clone());
|
||||
let error_sender_cp = error_sender.clone();
|
||||
let inputs_sender_cp = inputs_sender.clone();
|
||||
let outputs_sender_cp = outputs_sender.clone();
|
||||
let function_runtime: Option<CompiledFunctionRuntime> =
|
||||
self.function_map.get(node_id).cloned();
|
||||
let pipeline_name = pipeline_name.clone();
|
||||
@@ -860,6 +856,7 @@ impl ExecutablePipeline {
|
||||
result_sender: result_sender_cp,
|
||||
error_sender: error_sender_cp,
|
||||
inputs_sender: Some(inputs_sender_cp),
|
||||
outputs_sender: Some(outputs_sender_cp),
|
||||
};
|
||||
let task = tokio::spawn(process_node(metadata, node, function_runtime, channels));
|
||||
node_tasks.push(task);
|
||||
@@ -915,6 +912,14 @@ impl ExecutablePipeline {
|
||||
input_map
|
||||
});
|
||||
|
||||
let outputs_task = tokio::spawn(async move {
|
||||
let mut outputs_map = HashMap::new();
|
||||
while let Some((node_id, val)) = outputs_receiver.recv().await {
|
||||
outputs_map.entry(node_id).or_insert(vec![]).push(val);
|
||||
}
|
||||
outputs_map
|
||||
});
|
||||
|
||||
// Send records to the source node to begin processing
|
||||
let flattened = {
|
||||
let source_node = self.node_map.get(&self.source_node_id).unwrap();
|
||||
@@ -947,6 +952,7 @@ impl ExecutablePipeline {
|
||||
drop(error_sender);
|
||||
drop(node_senders);
|
||||
drop(inputs_sender);
|
||||
drop(outputs_sender);
|
||||
log::debug!(
|
||||
"[Workflow] {pipeline_name} [inv={inv_id}]: All records send into pipeline for processing"
|
||||
);
|
||||
@@ -977,6 +983,12 @@ impl ExecutablePipeline {
|
||||
);
|
||||
anyhow!("[Workflow] input collecting job failed: {}", e)
|
||||
})?;
|
||||
let outputs = outputs_task.await.map_err(|e| {
|
||||
log::error!(
|
||||
"[Workflow] {pipeline_name} [inv={inv_id}]: output collecting job failed: {e}"
|
||||
);
|
||||
anyhow!("[Workflow] output collecting job failed: {}", e)
|
||||
})?;
|
||||
|
||||
let node_errors = errors
|
||||
.as_ref()
|
||||
@@ -1045,6 +1057,7 @@ impl ExecutablePipeline {
|
||||
stream_details: results,
|
||||
errors: node_errors,
|
||||
inputs,
|
||||
outputs,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1193,6 +1206,7 @@ struct ProcessChannels {
|
||||
result_sender: Option<Sender<(usize, StreamParams, Value)>>,
|
||||
error_sender: Sender<(String, String, String, Option<String>, Option<Value>)>,
|
||||
inputs_sender: Option<Sender<(String, Value)>>,
|
||||
outputs_sender: Option<Sender<(String, Value)>>,
|
||||
}
|
||||
|
||||
impl ProcessChannels {
|
||||
@@ -1210,6 +1224,17 @@ impl ProcessChannels {
|
||||
);
|
||||
}
|
||||
}
|
||||
async fn send_output(&mut self, metadata: &ProcessMetadata, id: &str, value: &Value) {
|
||||
if let Some(ref mut channel) = self.outputs_sender
|
||||
&& let Err(e) = channel.send((id.to_string(), value.clone())).await
|
||||
{
|
||||
log::error!(
|
||||
"[Pipeline] {} [inv={}] error sending output via output channel for node {id} : {e}",
|
||||
metadata.pipeline_name,
|
||||
metadata.inv_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_node(
|
||||
@@ -1238,6 +1263,9 @@ async fn process_node(
|
||||
if node.is_disabled && !matches!(node.node_data, NodeData::WorkflowTrigger) {
|
||||
let mut count: usize = 0;
|
||||
while let Some(item) = channels.receiver.recv().await {
|
||||
channels
|
||||
.send_output(&metadata, &node.id, &item.record)
|
||||
.await;
|
||||
send_to_children(&mut channels.child_senders, item, &node.to_string()).await;
|
||||
count += 1;
|
||||
}
|
||||
@@ -1253,6 +1281,9 @@ async fn process_node(
|
||||
let mut count: usize = 0;
|
||||
while let Some(item) = channels.receiver.recv().await {
|
||||
channels.send_input(&metadata, &node.id, &item.record).await;
|
||||
channels
|
||||
.send_output(&metadata, &node.id, &item.record)
|
||||
.await;
|
||||
send_to_children(&mut channels.child_senders, item, "WorkflowTrigger").await;
|
||||
count += 1;
|
||||
}
|
||||
@@ -1790,12 +1821,18 @@ async fn process_function_node(
|
||||
metadata.node_idx
|
||||
);
|
||||
|
||||
if func_params.name.is_empty() {
|
||||
if func_params.name.is_empty()
|
||||
&& (func_params.raw_fn.is_none()
|
||||
|| func_params.raw_fn.as_ref().is_some_and(|v| v.is_empty()))
|
||||
{
|
||||
let mut count = 0;
|
||||
while let Some(pipeline_item) = channels.receiver.recv().await {
|
||||
channels
|
||||
.send_input(&metadata, &node.id, &pipeline_item.record)
|
||||
.await;
|
||||
channels
|
||||
.send_output(&metadata, &node.id, &pipeline_item.record)
|
||||
.await;
|
||||
send_to_children(&mut channels.child_senders, pipeline_item, "FunctionNode").await;
|
||||
count += 1;
|
||||
}
|
||||
@@ -1907,6 +1944,7 @@ async fn process_function_node(
|
||||
}
|
||||
};
|
||||
flattened = false; // since apply_vrl_fn can produce unflattened data
|
||||
channels.send_output(&metadata, &node.id, &record).await;
|
||||
send_to_children(
|
||||
&mut channels.child_senders,
|
||||
PipelineItem {
|
||||
@@ -1961,6 +1999,7 @@ async fn process_function_node(
|
||||
}
|
||||
};
|
||||
flattened = false; // since JS functions can produce unflattened data
|
||||
channels.send_output(&metadata, &node.id, &record).await;
|
||||
send_to_children(
|
||||
&mut channels.child_senders,
|
||||
PipelineItem {
|
||||
@@ -2030,6 +2069,7 @@ async fn process_function_node(
|
||||
// since apply_vrl_fn can produce unflattened data
|
||||
for record in result.as_array().unwrap().iter() {
|
||||
// use usize::MAX as a flag to disregard original_value
|
||||
channels.send_output(&metadata, &node.id, record).await;
|
||||
send_to_children(
|
||||
&mut channels.child_senders,
|
||||
PipelineItem {
|
||||
@@ -2083,6 +2123,7 @@ async fn process_function_node(
|
||||
if let Some(result_arr) = result.as_array() {
|
||||
for record in result_arr.iter() {
|
||||
// use usize::MAX as a flag to disregard original_value
|
||||
channels.send_output(&metadata, &node.id, record).await;
|
||||
send_to_children(
|
||||
&mut channels.child_senders,
|
||||
PipelineItem {
|
||||
@@ -2202,6 +2243,7 @@ async fn process_condition_node(
|
||||
|
||||
// only send to children when passing all condition evaluations
|
||||
if passes {
|
||||
channels.send_output(&metadata, &node.id, &record).await;
|
||||
send_to_children(
|
||||
&mut channels.child_senders,
|
||||
PipelineItem {
|
||||
@@ -2571,6 +2613,10 @@ async fn process_destination_node(
|
||||
);
|
||||
}
|
||||
return Ok(0);
|
||||
} else {
|
||||
channels
|
||||
.send_output(&metadata, &node.id, &body.into())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2909,6 +2955,7 @@ mod tests {
|
||||
result_sender: None,
|
||||
error_sender: error_tx,
|
||||
inputs_sender: None,
|
||||
outputs_sender: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -3056,6 +3103,7 @@ mod tests {
|
||||
name: "test_function".to_string(),
|
||||
after_flatten: true,
|
||||
num_args: 1,
|
||||
raw_fn: None,
|
||||
}),
|
||||
children: vec![],
|
||||
is_disabled: false,
|
||||
@@ -3088,6 +3136,7 @@ mod tests {
|
||||
name: "func1".to_string(),
|
||||
after_flatten: false,
|
||||
num_args: 0,
|
||||
raw_fn: None,
|
||||
}),
|
||||
children: vec!["C".to_string()],
|
||||
is_disabled: false,
|
||||
@@ -3548,6 +3597,7 @@ mod tests {
|
||||
result_sender: None,
|
||||
error_sender: error_tx,
|
||||
inputs_sender,
|
||||
outputs_sender: None,
|
||||
};
|
||||
|
||||
(input_tx, child_receivers, inputs_rx, channels)
|
||||
@@ -3578,6 +3628,7 @@ mod tests {
|
||||
name: "should-not-run".to_string(),
|
||||
after_flatten: false,
|
||||
num_args: 0,
|
||||
raw_fn: None,
|
||||
}),
|
||||
children: vec!["child-1".to_string()],
|
||||
is_disabled: true,
|
||||
|
||||
@@ -45,6 +45,8 @@ pub struct InputMap {
|
||||
error_node_map: HashMap<String, Vec<Value>>,
|
||||
#[serde(default)]
|
||||
input_map: HashMap<String, Vec<Value>>,
|
||||
#[serde(default)]
|
||||
output_map: HashMap<String, Vec<Value>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -280,6 +282,15 @@ async fn validate_workflow(workflow: &Workflow, is_draft: bool) -> Result<(), an
|
||||
}
|
||||
|
||||
if let NodeData::Function(function_params) = &node.data {
|
||||
// ideally FE should not send raw fn here, and additionally
|
||||
// we should not allow raw fns for published workflows, so we check and deny
|
||||
if function_params.raw_fn.is_some() || function_params.name.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"function node {} still has some unsaved function changes associated with it. Either save or discard those.",
|
||||
node.id
|
||||
));
|
||||
}
|
||||
|
||||
// Load the function to check its trans_type
|
||||
let function = super::db::functions::get(&workflow.org_id, &function_params.name)
|
||||
.await
|
||||
@@ -544,6 +555,7 @@ async fn execute_workflow(
|
||||
let ip_map = InputMap {
|
||||
error_node_map: errored_input_map,
|
||||
input_map: res.inputs,
|
||||
output_map: res.outputs,
|
||||
};
|
||||
|
||||
// if this is not empty, then some node errored
|
||||
|
||||
@@ -22,6 +22,8 @@ test.describe('Workflows CRUD & builder', { tag: ['@workflows', '@enterprise', '
|
||||
let pm;
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
// Workflows feature is under rework — skipped until it stabilizes.
|
||||
test.skip(true, 'Workflows feature under rework');
|
||||
testLogger.testStart(testInfo.title, testInfo.file);
|
||||
await navigateToBase(page);
|
||||
pm = new PageManager(page);
|
||||
|
||||
@@ -31,6 +31,8 @@ test.describe(
|
||||
let pm;
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
// Workflows feature is under rework — skipped until it stabilizes.
|
||||
test.skip(true, 'Workflows feature under rework');
|
||||
testLogger.testStart(testInfo.title, testInfo.file);
|
||||
await navigateToBase(page);
|
||||
pm = new PageManager(page);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!--
|
||||
The single "add a step here" affordance for the flow canvases — a small round
|
||||
`+` chip. Used in three consistent places (Workflows): between the empty-canvas
|
||||
Trigger/Action scaffold slots, below a leaf node (append), and at an edge
|
||||
midpoint (insert between). Purely presentational; the caller positions it and
|
||||
handles the native @click. `nodrag`/`nopan` keep a click on it from panning or
|
||||
dragging the canvas underneath.
|
||||
-->
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:data-test="dataTest"
|
||||
class="nodrag nopan bg-surface-base border-border-strong text-text-secondary hover:border-accent hover:text-text-body flex size-5 items-center justify-center rounded-full border shadow-sm transition-colors"
|
||||
>
|
||||
<OIcon name="add" size="xs" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
|
||||
defineProps<{ dataTest?: string }>();
|
||||
</script>
|
||||
@@ -23,6 +23,13 @@ vi.mock("@vue-flow/core", () => ({
|
||||
template: '<path class="mock-base-edge" />',
|
||||
props: ["id", "style", "path", "markerEnd", "type"],
|
||||
},
|
||||
// The label layer teleports to the viewport in the real lib; here it just renders
|
||||
// its slot inline so the mid-edge `+` is queryable.
|
||||
EdgeLabelRenderer: {
|
||||
name: "EdgeLabelRenderer",
|
||||
template: '<div class="mock-edge-label"><slot /></div>',
|
||||
},
|
||||
Position: { Top: "top", Right: "right", Bottom: "bottom", Left: "left" },
|
||||
getBezierPath: vi.fn((props: any) => [
|
||||
`M ${props.sourceX} ${props.sourceY} C ... ${props.targetX} ${props.targetY}`,
|
||||
props.sourceX,
|
||||
@@ -124,6 +131,36 @@ describe("FlowEdge.vue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mid-edge insert + (insertable)", () => {
|
||||
const addBtn = (w: any) => w.find('[data-test="workflow-edge-add"]');
|
||||
|
||||
it("does not render the insert + by default (pipelines opt out)", () => {
|
||||
wrapper = createWrapper();
|
||||
expect(addBtn(wrapper).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders the insert + when insertable is set (Workflows opt in)", () => {
|
||||
wrapper = createWrapper({ insertable: true });
|
||||
expect(addBtn(wrapper).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("emits insert with the click event when the + is clicked", async () => {
|
||||
wrapper = createWrapper({ insertable: true });
|
||||
await addBtn(wrapper).trigger("click");
|
||||
expect(wrapper.emitted("insert")).toBeTruthy();
|
||||
expect(wrapper.emitted("insert")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emits insert-enter / insert-leave as the cursor crosses the + chip", async () => {
|
||||
wrapper = createWrapper({ insertable: true });
|
||||
const chip = wrapper.find(".pointer-events-auto"); // the chip wrapper around the +
|
||||
await chip.trigger("mouseenter");
|
||||
await chip.trigger("mouseleave");
|
||||
expect(wrapper.emitted("insert-enter")).toHaveLength(1);
|
||||
expect(wrapper.emitted("insert-leave")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("renders when source and target positions are the same", () => {
|
||||
wrapper = createWrapper({ sourcePosition: "bottom", targetPosition: "bottom" });
|
||||
|
||||
@@ -27,11 +27,29 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:marker-end="markerEnd"
|
||||
type="smoothstep"
|
||||
/>
|
||||
|
||||
<!-- Mid-edge "insert step here" `+` — opt-in (Workflows pass `insertable`), so
|
||||
the shared pipeline canvas is unchanged. Rendered into the EdgeLabelRenderer
|
||||
layer so it lives inside the viewport transform: `path[1]`/`path[2]` are the
|
||||
edge's midpoint in flow coords, and translate(-50%,-50%) centres the chip on
|
||||
it. Positioned via a CSS var (dynamic px, the sanctioned pattern here) so no
|
||||
literal px lands in a class. -->
|
||||
<EdgeLabelRenderer v-if="insertable">
|
||||
<div
|
||||
class="pointer-events-auto absolute top-0 left-0 [transform:var(--wf-edge-mid,none)]"
|
||||
:style="{ '--wf-edge-mid': `translate(-50%, -50%) translate(${path[1]}px, ${path[2]}px)` }"
|
||||
@mouseenter="emit('insert-enter')"
|
||||
@mouseleave="emit('insert-leave')"
|
||||
>
|
||||
<FlowAddButton data-test="workflow-edge-add" @click.stop="emit('insert', $event)" />
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BaseEdge, getBezierPath, Position } from "@vue-flow/core";
|
||||
import { BaseEdge, EdgeLabelRenderer, getBezierPath, Position } from "@vue-flow/core";
|
||||
import { computed, type PropType } from "vue";
|
||||
import FlowAddButton from "./FlowAddButton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, required: true },
|
||||
@@ -46,8 +64,19 @@ const props = defineProps({
|
||||
style: { type: Object, required: false },
|
||||
// Accepted for pipeline call-site compatibility; not used for rendering.
|
||||
isInView: { type: Boolean, required: false, default: false },
|
||||
// Workflows opt in to the mid-edge insert `+`; pipelines leave it off.
|
||||
insertable: { type: Boolean, required: false, default: false },
|
||||
});
|
||||
|
||||
// Clicking the mid-edge `+` asks the canvas to splice a step onto THIS edge. The
|
||||
// insert-enter/insert-leave pair lets the canvas keep the chip alive while the cursor
|
||||
// moves from the node onto it (Option C reveals the chip on node hover).
|
||||
const emit = defineEmits<{
|
||||
(e: "insert", event: MouseEvent): void;
|
||||
(e: "insert-enter"): void;
|
||||
(e: "insert-leave"): void;
|
||||
}>();
|
||||
|
||||
const path = computed(() => getBezierPath(props));
|
||||
</script>
|
||||
|
||||
|
||||
@@ -85,6 +85,13 @@
|
||||
box-shadow: 0 0 0 0.1875rem color-mix(in srgb, var(--color-success-500) 60%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Publish validation flagged an INCOMPLETE (dummy) node — thicken its border, keeping
|
||||
the node's OWN type colour (blue trigger / amber logic / green destination), so the
|
||||
user sees exactly which steps still need setup before publishing. */
|
||||
.o2vf_node .vue-flow__node:has(.wf-needs-setup) {
|
||||
border-width: 0.1875rem !important;
|
||||
}
|
||||
|
||||
/* Disabled/muted node — the step is toggled off (WorkflowNode marks its card
|
||||
`wf-node-disabled`). The FRAME loses its type colour and shadow (neutral grey);
|
||||
the icon + label are desaturated SEPARATELY (below) rather than filtering the
|
||||
|
||||
@@ -101,6 +101,45 @@ describe("ConditionBuilder", () => {
|
||||
expect(payload.conditions.conditions[0].column).toBe("level");
|
||||
});
|
||||
|
||||
// Optional mode (Workflows dummy node): submit never blocks — it returns the rule
|
||||
// with a `complete` flag so the host can save an incomplete rule as a placeholder.
|
||||
it("optional: returns complete:true and the payload for a valid rule", async () => {
|
||||
const saved = {
|
||||
filterType: "group",
|
||||
conditions: [{ filterType: "condition", column: "level", operator: "=", value: "error" }],
|
||||
};
|
||||
const wrapper = createWrapper({ initialConditions: saved, optional: true });
|
||||
const payload = await (wrapper.vm as any).submit();
|
||||
expect(payload.version).toBe(2);
|
||||
expect(payload.complete).toBe(true);
|
||||
expect(payload.conditions.conditions[0].column).toBe("level");
|
||||
});
|
||||
|
||||
it("optional: returns complete:false (not null) for an incomplete rule", async () => {
|
||||
const wrapper = createWrapper({
|
||||
initialConditions: {
|
||||
filterType: "group",
|
||||
conditions: [{ filterType: "condition", column: "", operator: "" }],
|
||||
},
|
||||
optional: true,
|
||||
});
|
||||
const payload = await (wrapper.vm as any).submit();
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload.version).toBe(2);
|
||||
expect(payload.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("does not add a complete flag in the default (non-optional) payload", async () => {
|
||||
const saved = {
|
||||
filterType: "group",
|
||||
conditions: [{ filterType: "condition", column: "level", operator: "=", value: "error" }],
|
||||
};
|
||||
const wrapper = createWrapper({ initialConditions: saved });
|
||||
const payload = await (wrapper.vm as any).submit();
|
||||
expect(payload).toEqual({ version: 2, conditions: expect.any(Object) });
|
||||
expect("complete" in payload).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes dotted columns on submit when normalizeColumnNames is on", async () => {
|
||||
const saved = {
|
||||
filterType: "group",
|
||||
|
||||
@@ -120,6 +120,7 @@ const props = withDefaults(
|
||||
allowCustomColumns?: boolean;
|
||||
normalizeOperators?: boolean;
|
||||
normalizeColumnNames?: boolean;
|
||||
optional?: boolean;
|
||||
}>(),
|
||||
{
|
||||
fields: () => [],
|
||||
@@ -128,6 +129,7 @@ const props = withDefaults(
|
||||
allowCustomColumns: true,
|
||||
normalizeOperators: false,
|
||||
normalizeColumnNames: false,
|
||||
optional: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -269,16 +271,24 @@ const normalizeConditionColumns = (node: any): void => {
|
||||
if (Array.isArray(node.conditions)) node.conditions.forEach(normalizeConditionColumns);
|
||||
};
|
||||
|
||||
// Host bridge: validate through the schema and return { version, conditions },
|
||||
// or null when invalid (the error renders inline under the FilterGroup).
|
||||
// Host bridge: validate through the schema and return { version, conditions }.
|
||||
// Detach from the readonly form read-view before handing the tree to the host.
|
||||
//
|
||||
// `optional` splits the two callers:
|
||||
// - Pipelines (default): an incomplete rule BLOCKS — return null, error shown
|
||||
// inline under the FilterGroup.
|
||||
// - Workflows (optional=true, dummy-node model): never block. Return the rule
|
||||
// anyway with a `complete` flag so the host can save an incomplete rule as a
|
||||
// placeholder (and flag the node incomplete). The `complete` field is only added
|
||||
// in optional mode, so the pipeline payload shape is unchanged.
|
||||
const submit = async () => {
|
||||
validated.value = null;
|
||||
await form.handleSubmit();
|
||||
if (!validated.value) return null;
|
||||
const complete = !!validated.value;
|
||||
if (!complete && !props.optional) return null;
|
||||
const conditions = cloneDeep((form.state.values as any).conditions);
|
||||
if (props.normalizeColumnNames) normalizeConditionColumns(conditions);
|
||||
return { version: 2, conditions };
|
||||
return props.optional ? { version: 2, conditions, complete } : { version: 2, conditions };
|
||||
};
|
||||
|
||||
defineExpose({ submit, conditionGroup, form });
|
||||
|
||||
@@ -240,4 +240,77 @@ describe("DestinationPicker", () => {
|
||||
await flushPromises();
|
||||
expect(mockList).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── forcedType="custom" narrows the list (Workflows) ──────────────────────
|
||||
// The pipeline endpoint returns every destination type, but a workflow node can
|
||||
// only execute a custom webhook.
|
||||
|
||||
const mixedResponse = {
|
||||
data: [
|
||||
{ name: "webhook", url: "http://w.example.com", destination_type_name: "custom" },
|
||||
{ name: "splunk-hec", url: "http://s.example.com", destination_type_name: "splunk" },
|
||||
{ name: "dd-events", url: "http://d.example.com", destination_type: "datadog" },
|
||||
{ name: "legacy", url: "http://l.example.com" }, // no type at all
|
||||
],
|
||||
};
|
||||
|
||||
it("forcedType custom: lists only custom destinations", async () => {
|
||||
mockList.mockResolvedValueOnce(mixedResponse);
|
||||
const wrapper = createWrapper({ forcedType: "custom" });
|
||||
await flushPromises();
|
||||
expect((wrapper.vm as any).destinationOptions.map((o: any) => o.value)).toEqual(["webhook"]);
|
||||
});
|
||||
|
||||
// An untyped record is a prebuilt "openobserve" destination as far as the edit
|
||||
// form is concerned, so it is NOT treated as custom.
|
||||
it("forcedType custom: an untyped destination is not treated as custom", async () => {
|
||||
mockList.mockResolvedValueOnce(mixedResponse);
|
||||
const wrapper = createWrapper({ forcedType: "custom" });
|
||||
await flushPromises();
|
||||
const values = (wrapper.vm as any).destinationOptions.map((o: any) => o.value);
|
||||
expect(values).not.toContain("legacy");
|
||||
});
|
||||
|
||||
it("without forcedType: every destination is listed (pipelines are unaffected)", async () => {
|
||||
mockList.mockResolvedValueOnce(mixedResponse);
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
expect((wrapper.vm as any).destinationOptions).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("matches the type case-insensitively", async () => {
|
||||
mockList.mockResolvedValueOnce({
|
||||
data: [{ name: "shouty", url: "http://x.example.com", destination_type_name: " CUSTOM " }],
|
||||
});
|
||||
const wrapper = createWrapper({ forcedType: "custom" });
|
||||
await flushPromises();
|
||||
expect((wrapper.vm as any).destinationOptions.map((o: any) => o.value)).toEqual(["shouty"]);
|
||||
});
|
||||
|
||||
// The important one: a node saved before the filter existed must not silently
|
||||
// lose its destination when the drawer reopens.
|
||||
it("keeps an already-saved non-custom destination in the list, flagged", async () => {
|
||||
mockList.mockResolvedValueOnce(mixedResponse);
|
||||
const wrapper = createWrapper({ forcedType: "custom", initialName: "splunk-hec" });
|
||||
await flushPromises();
|
||||
const opts = (wrapper.vm as any).destinationOptions;
|
||||
expect(opts.map((o: any) => o.value)).toEqual(["splunk-hec", "webhook"]);
|
||||
expect(opts[0].subLabel).toBe("Unsupported Type — Workflows Support Custom Destinations Only");
|
||||
});
|
||||
|
||||
it("still resolves the retained non-custom destination on submit", async () => {
|
||||
mockList.mockResolvedValueOnce(mixedResponse);
|
||||
const wrapper = createWrapper({ forcedType: "custom", initialName: "splunk-hec" });
|
||||
await flushPromises();
|
||||
await expect((wrapper.vm as any).submit()).resolves.toMatchObject({
|
||||
destination_name: "splunk-hec",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not duplicate an already-saved custom destination", async () => {
|
||||
mockList.mockResolvedValueOnce(mixedResponse);
|
||||
const wrapper = createWrapper({ forcedType: "custom", initialName: "webhook" });
|
||||
await flushPromises();
|
||||
expect((wrapper.vm as any).destinationOptions.map((o: any) => o.value)).toEqual(["webhook"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,8 +30,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<template>
|
||||
<div data-test="destination-picker" class="flex w-full flex-col gap-4">
|
||||
<!-- Mode toggle — a bare control OUTSIDE the form: it swaps the
|
||||
select-existing form for the CreateDestinationForm create child. -->
|
||||
<!-- Mode toggle — a bare control OUTSIDE the form: it swaps the select-existing
|
||||
form for the CreateDestinationForm create child. Always visible, so the user
|
||||
can toggle back to picking an existing destination without hunting for Cancel. -->
|
||||
<OSwitch
|
||||
v-model="createNewDestination"
|
||||
:label="t('flow.destination.createNew')"
|
||||
@@ -73,6 +74,7 @@ import { useOForm } from "@/lib/forms/Form/useOForm";
|
||||
import OFormSelect from "@/lib/forms/Select/OFormSelect.vue";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import destinationService from "@/services/alert_destination";
|
||||
import { isCustomDestination } from "@/utils/destinationType";
|
||||
import CreateDestinationForm from "@/components/pipeline/NodeForm/CreateDestinationForm.vue";
|
||||
import {
|
||||
makeExternalDestinationSchema,
|
||||
@@ -139,15 +141,37 @@ watch(createNewDestination, async (v) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Show the destination URL as a sub-label.
|
||||
const destinationOptions = computed(() =>
|
||||
destinations.value.map((d: any) => ({
|
||||
label: d.name,
|
||||
value: d.name,
|
||||
subLabel: d.url && d.url.length > 70 ? d.url.slice(0, 70) + "..." : d.url,
|
||||
subLabelInline: true,
|
||||
})),
|
||||
);
|
||||
const toOption = (d: any) => ({
|
||||
label: d.name,
|
||||
value: d.name,
|
||||
subLabel: d.url && d.url.length > 70 ? d.url.slice(0, 70) + "..." : d.url,
|
||||
subLabelInline: true,
|
||||
});
|
||||
|
||||
// Show the destination URL as a sub-label. When the host locks the type to custom
|
||||
// (Workflows), prebuilt provider destinations are filtered out — they are listed by
|
||||
// the shared pipeline endpoint but cannot be executed by a workflow node.
|
||||
const destinationOptions = computed(() => {
|
||||
const all = destinations.value;
|
||||
if (props.forcedType !== "custom") return all.map(toOption);
|
||||
|
||||
const custom = all.filter(isCustomDestination);
|
||||
const options = custom.map(toOption);
|
||||
|
||||
// A node saved before this filter existed may point at a prebuilt destination.
|
||||
// Keep it listed (flagged) rather than letting it vanish — an empty picker would
|
||||
// silently downgrade the node to a placeholder on the next Save.
|
||||
const saved = props.initialName
|
||||
? all.find((d: any) => d.name === props.initialName && !isCustomDestination(d))
|
||||
: undefined;
|
||||
if (saved) {
|
||||
options.unshift({
|
||||
...toOption(saved),
|
||||
subLabel: t("flow.destination.unsupportedType"),
|
||||
});
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
// Pipeline-module external destinations.
|
||||
const getDestinations = async () => {
|
||||
|
||||
@@ -26,6 +26,10 @@ vi.mock("@/services/jstransform", () => ({
|
||||
default: { list: (...args: any[]) => mockList(...args) },
|
||||
}));
|
||||
|
||||
// The live editor code AddFunction.getCode() returns — tests set it to simulate the
|
||||
// user typing / editing (the real editor is imperative; the picker reads it on demand).
|
||||
let mockEditorCode = "";
|
||||
|
||||
// Minimal, controllable stubs for the lib inputs so we can drive v-model.
|
||||
const OSelectStub = {
|
||||
name: "OSelect",
|
||||
@@ -49,6 +53,12 @@ function createWrapper(props: Record<string, any> = {}) {
|
||||
stubs: {
|
||||
OSelect: OSelectStub,
|
||||
OSwitch: OSwitchStub,
|
||||
OButton: {
|
||||
name: "OButton",
|
||||
props: ["iconLeft", "variant", "size"],
|
||||
emits: ["click"],
|
||||
template: '<button class="o-btn" @click="$emit(\'click\')"><slot /></button>',
|
||||
},
|
||||
OSpinner: true,
|
||||
OCard: { template: "<div><slot /></div>" },
|
||||
OCardSection: { template: "<div><slot /></div>" },
|
||||
@@ -56,9 +66,15 @@ function createWrapper(props: Record<string, any> = {}) {
|
||||
OIcon: true,
|
||||
AddFunction: {
|
||||
name: "AddFunction",
|
||||
template: '<div class="add-function-stub"></div>',
|
||||
props: ["isUpdated", "heightOffset"],
|
||||
template: '<div class="add-function-stub">{{ defaultCode }}</div>',
|
||||
props: ["isUpdated", "heightOffset", "defaultCode", "forcedLanguage", "sampleEvents"],
|
||||
emits: ["update:list", "cancel:hideform"],
|
||||
// Mirrors the real AddFunction's only exposed method; returns the mock code
|
||||
// unless a test left it empty, in which case it echoes the seed (matches the
|
||||
// real editor, which starts at default-code).
|
||||
setup(props: any, { expose }: any) {
|
||||
expose({ getCode: () => (mockEditorCode !== "" ? mockEditorCode : props.defaultCode) });
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -82,6 +98,7 @@ const listResponse = {
|
||||
describe("FunctionPicker", () => {
|
||||
beforeEach(() => {
|
||||
mockList.mockResolvedValue(listResponse);
|
||||
mockEditorCode = "";
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -132,6 +149,30 @@ describe("FunctionPicker", () => {
|
||||
await expect((wrapper.vm as any).submit()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
// Optional mode (Workflows dummy node): an empty selection is allowed — submit
|
||||
// resolves an empty name (placeholder) instead of null, and no required error.
|
||||
it("optional: submit resolves an empty name when nothing is selected", async () => {
|
||||
const wrapper = createWrapper({ optional: true });
|
||||
await flushPromises();
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({
|
||||
name: "",
|
||||
after_flatten: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("optional: still resolves the chosen function when one is selected", async () => {
|
||||
const wrapper = createWrapper({
|
||||
optional: true,
|
||||
initialName: "alpha",
|
||||
initialAfterFlatten: false,
|
||||
});
|
||||
await flushPromises();
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({
|
||||
name: "alpha",
|
||||
after_flatten: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("submit resolves { name, after_flatten } with the flatten value", async () => {
|
||||
const wrapper = createWrapper({ initialName: "alpha", initialAfterFlatten: false });
|
||||
await flushPromises();
|
||||
@@ -183,4 +224,122 @@ describe("FunctionPicker", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// Single-screen mode (workflows): the select AND the create editor are BOTH on one
|
||||
// page — no mode switch, no view swap. Pipelines keep the switch.
|
||||
it("createButton: shows the select and the create editor together (no mode switch)", async () => {
|
||||
const wrapper = createWrapper({ createButton: true });
|
||||
await flushPromises();
|
||||
expect(wrapper.find(".o-select").exists()).toBe(true); // pick existing
|
||||
expect(wrapper.find(".add-function-stub").exists()).toBe(true); // create new — same page
|
||||
expect(wrapper.find('[data-test="create-function-toggle"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("createButton: does not emit expand (inline on one page, no drawer widen swap)", async () => {
|
||||
const wrapper = createWrapper({ createButton: true });
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted("expand")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("createButton: selecting a function fills its definition into the editor", async () => {
|
||||
// 'alpha' is a VRL function in the fixture — preselect it, then the editor's
|
||||
// default-code should be that function's definition.
|
||||
const wrapper = createWrapper({ createButton: true, language: "vrl", initialName: "alpha" });
|
||||
await flushPromises();
|
||||
const addFn = wrapper.findComponent({ name: "AddFunction" });
|
||||
expect(addFn.props("defaultCode")).toBe("def alpha(r): r");
|
||||
});
|
||||
|
||||
it("keeps the mode switch by default (pipelines)", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="create-function-toggle"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="create-function-open"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
// ── Workflow inline JS (raw_fn) + dirty tracking ─────────────────────────────
|
||||
// Single-screen workflow: the editor can hold inline/edited code that isn't backed
|
||||
// by a saved function. submit() then sends `raw_fn` (empty name) instead of a name;
|
||||
// a clean saved-fn selection sends `{ name, raw_fn: undefined }` so any stale raw_fn
|
||||
// is cleared. isDirty() drives the NDV's save/discard exit prompt.
|
||||
const wf = (props: Record<string, any> = {}) =>
|
||||
createWrapper({ createButton: true, optional: true, language: "javascript", ...props });
|
||||
|
||||
it("clean selection (no edits) → submit sends the name and clears raw_fn", async () => {
|
||||
const wrapper = wf({ initialName: "js_fn", initialAfterFlatten: false });
|
||||
await flushPromises();
|
||||
// editor equals the saved code → not dirty
|
||||
mockEditorCode = "() => {}";
|
||||
expect((wrapper.vm as any).isDirty()).toBe(false);
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({
|
||||
name: "js_fn",
|
||||
raw_fn: undefined,
|
||||
after_flatten: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("edited saved fn (unsaved) → submit sends raw_fn with an empty name", async () => {
|
||||
const wrapper = wf({ initialName: "js_fn", initialAfterFlatten: true });
|
||||
await flushPromises();
|
||||
mockEditorCode = "() => { return 1 }"; // diverges from saved "() => {}"
|
||||
expect((wrapper.vm as any).isDirty()).toBe(true);
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({
|
||||
name: "",
|
||||
raw_fn: "() => { return 1 }",
|
||||
after_flatten: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("pure inline code (no fn selected) → submit sends raw_fn", async () => {
|
||||
const wrapper = wf({ initialAfterFlatten: false, defaultCode: "// seed" });
|
||||
await flushPromises();
|
||||
mockEditorCode = "() => 42"; // real code, none selected, differs from the seed
|
||||
expect((wrapper.vm as any).isDirty()).toBe(true);
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({
|
||||
name: "",
|
||||
raw_fn: "() => 42",
|
||||
after_flatten: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("untouched seed (no fn, only default code) → not dirty, empty-name dummy", async () => {
|
||||
const wrapper = wf({ defaultCode: "// seed" });
|
||||
await flushPromises();
|
||||
mockEditorCode = "// seed"; // still the seed → nothing to save
|
||||
expect((wrapper.vm as any).isDirty()).toBe(false);
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({
|
||||
name: "",
|
||||
raw_fn: undefined,
|
||||
after_flatten: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("initialRawFn seeds the editor when reopening an inline (nameless) node", async () => {
|
||||
const wrapper = wf({ initialRawFn: "() => 'persisted'" });
|
||||
await flushPromises();
|
||||
const addFn = wrapper.findComponent({ name: "AddFunction" });
|
||||
expect(addFn.props("defaultCode")).toBe("() => 'persisted'");
|
||||
});
|
||||
|
||||
it("reopened, untouched inline raw_fn node is NOT dirty (seed = initialRawFn)", async () => {
|
||||
// Reopen a saved inline node: editor seeds from initialRawFn, defaultCode is the
|
||||
// fresh-node placeholder. Nothing typed → must be clean (no spurious exit prompt).
|
||||
const wrapper = wf({ initialRawFn: "() => 'persisted'", defaultCode: "// seed" });
|
||||
await flushPromises();
|
||||
expect((wrapper.vm as any).isDirty()).toBe(false);
|
||||
});
|
||||
|
||||
it("editing a reopened inline raw_fn node past its persisted code → dirty", async () => {
|
||||
const wrapper = wf({ initialRawFn: "() => 'persisted'", defaultCode: "// seed" });
|
||||
await flushPromises();
|
||||
mockEditorCode = "() => 'edited'"; // diverges from the persisted raw_fn
|
||||
expect((wrapper.vm as any).isDirty()).toBe(true);
|
||||
});
|
||||
|
||||
it("isDirty() is false outside single-screen mode (raw_fn is workflow-only)", async () => {
|
||||
const wrapper = createWrapper({ language: "javascript", initialName: "js_fn" });
|
||||
await flushPromises();
|
||||
mockEditorCode = "() => { changed }";
|
||||
expect((wrapper.vm as any).isDirty()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
showFlatten — show the After-Flattening toggle + guidelines (default true)
|
||||
isUpdating — lock the select (edit-an-existing-function mode)
|
||||
duplicateNames — names that are already used → shows "already associated"
|
||||
optional — allow saving with NO function selected (Workflows dummy
|
||||
nodes): submit() returns an empty `name` instead of null,
|
||||
and the required schema check is skipped. Default false so
|
||||
Pipelines are unaffected.
|
||||
createButton — single-screen mode (Workflows): show the select AND the
|
||||
inline create editor together on ONE page (no mode switch, no
|
||||
view swap) — pick existing above, or create new below (its
|
||||
own Save auto-selects it). Default false → Pipelines keep the
|
||||
mode switch + full-height editor.
|
||||
|
||||
Emits:
|
||||
expand(boolean) — inline-create mode toggled (host can widen the drawer)
|
||||
@@ -53,14 +62,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<template>
|
||||
<div
|
||||
data-test="function-picker"
|
||||
class="flex w-full flex-col gap-4"
|
||||
:class="createNewFunction ? 'h-full min-h-0 gap-0' : ''"
|
||||
class="flex w-full flex-col"
|
||||
:class="
|
||||
createNewFunction ? 'h-full min-h-0 gap-0' : createButton ? 'h-full min-h-0 gap-2' : 'gap-4'
|
||||
"
|
||||
>
|
||||
<OSpinner v-if="loading" size="md" class="mx-auto my-8" />
|
||||
|
||||
<template v-else>
|
||||
<!-- create / pick toggle -->
|
||||
<div class="flex items-center gap-3" :class="createNewFunction ? 'shrink-0 px-4 pt-4' : ''">
|
||||
<!-- create / pick MODE SWITCH (pipelines). Single-screen hosts (workflows) use a
|
||||
subtle "+ Create New" button by the select instead — see createButton below. -->
|
||||
<div
|
||||
v-if="!createButton"
|
||||
class="flex items-center gap-3"
|
||||
:class="createNewFunction ? 'shrink-0 px-4 pt-4' : ''"
|
||||
>
|
||||
<OSwitch
|
||||
v-model="createNewFunction"
|
||||
:label="t('flow.function.createNew')"
|
||||
@@ -84,42 +100,46 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- inline function editor (full-width; its own toolbar owns save/cancel) -->
|
||||
<div v-if="createNewFunction" class="flow-add-function min-h-0 w-full flex-1">
|
||||
<!-- ALWAYS a fresh function. AddFunction's `is-updated` means "editing an
|
||||
EXISTING function" and disables its name input (`:disable-name`), so
|
||||
it must never be fed our `isUpdating` — that flag means "editing the
|
||||
NODE", which is a different thing. Conflating them left the user
|
||||
unable to name the function they were creating. -->
|
||||
<AddFunction
|
||||
ref="addFunctionRef"
|
||||
:is-updated="false"
|
||||
:height-offset="75"
|
||||
:sample-events="sampleEvents"
|
||||
:forced-language="language"
|
||||
:default-code="defaultCode"
|
||||
@update:list="onFunctionCreation"
|
||||
@cancel:hideform="cancelFunctionCreation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OForm v-if="!createNewFunction" :form="form" class="flex flex-col gap-4">
|
||||
<!-- SELECT an existing function (+ preview + After-Flattening). Single-screen
|
||||
hosts (workflows) show this ALWAYS, with the create editor below it; pipelines
|
||||
show it only when not in create mode. -->
|
||||
<OForm v-if="createButton || !createNewFunction" :form="form" class="flex flex-col gap-4">
|
||||
<!-- required + "already associated" are both enforced by the shared
|
||||
AssociateFunction schema, rendered inline on the field. -->
|
||||
<OFormSelect
|
||||
name="selectedFunction"
|
||||
:options="functionOptions"
|
||||
:label="t('flow.function.select')"
|
||||
required
|
||||
searchable
|
||||
:readonly="isUpdating"
|
||||
:disabled="isUpdating"
|
||||
data-test="associate-function-select-function-input"
|
||||
/>
|
||||
AssociateFunction schema, rendered inline on the field. Single-screen
|
||||
(workflow): a save ICON sits on the same line, right of the dropdown —
|
||||
mirroring the Logs editor — so saving reads as "save this FUNCTION", not
|
||||
"save the node" (the node commits on close). -->
|
||||
<div class="flex items-end gap-2">
|
||||
<OFormSelect
|
||||
name="selectedFunction"
|
||||
class="min-w-0 flex-1"
|
||||
:options="functionOptions"
|
||||
:label="t('flow.function.select')"
|
||||
:required="!optional"
|
||||
searchable
|
||||
:readonly="isUpdating"
|
||||
:disabled="isUpdating"
|
||||
data-test="associate-function-select-function-input"
|
||||
/>
|
||||
<OButton
|
||||
v-if="createButton"
|
||||
variant="outline"
|
||||
type="button"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
:loading="savingFn"
|
||||
:title="t('function.save')"
|
||||
data-test="wf-function-save-btn"
|
||||
@click="onBottomSave"
|
||||
>
|
||||
<OIcon name="save" size="sm" />
|
||||
</OButton>
|
||||
</div>
|
||||
|
||||
<!-- read-only definition preview -->
|
||||
<!-- read-only definition preview (pipelines only — the single-screen editor
|
||||
below is where workflows view/edit the function). -->
|
||||
<div
|
||||
v-if="selectedFunction && selectedDefinition"
|
||||
v-if="!createButton && selectedFunction && selectedDefinition"
|
||||
data-test="associate-function-definition-section"
|
||||
class="mt-4 mb-4"
|
||||
>
|
||||
@@ -151,22 +171,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</OCard>
|
||||
</div>
|
||||
|
||||
<!-- After-Flattening (RAF/RBF) toggle + guidelines -->
|
||||
<div v-if="showFlatten" class="flex w-full flex-col gap-3">
|
||||
<!-- After-Flattening (RAF/RBF) toggle + guidelines. Single-screen (workflow)
|
||||
moves this toggle to the bottom bar (next to Save), so it's hidden here. -->
|
||||
<div v-if="showFlatten && !createButton" class="flex w-full flex-col gap-3">
|
||||
<OFormSwitch
|
||||
name="afterFlattening"
|
||||
:label="t('flow.function.flatten')"
|
||||
data-test="associate-function-after-flattening-toggle"
|
||||
/>
|
||||
<!-- Same theme-aware banner tokens the pipeline + workflow Condition
|
||||
notes use, so all three match and work in dark mode (the old
|
||||
#f9f290/#2d3748 were light-only). These were --color-note-* until
|
||||
#13173 removed that set; banner-warning-* is its replacement.
|
||||
The `border-banner-warning-border` matters in LIGHT mode: the bg
|
||||
(warning-50, ~#fefce8) is near-white, so without the border the
|
||||
callout blends into the page and reads as unstyled — the canonical
|
||||
warning banners (logstream/schema, settings/*) all carry it. -->
|
||||
<!-- RBF/RAF guidelines banner. Hidden in the single-screen (workflow) editor
|
||||
to keep it uncluttered; the toggle above is self-explanatory there.
|
||||
Same theme-aware banner tokens the pipeline + workflow Condition notes use
|
||||
(banner-warning-* replaced the retired --color-note-* set); the
|
||||
border matters in LIGHT mode so the near-white fill doesn't read unstyled. -->
|
||||
<div
|
||||
v-if="!createButton"
|
||||
class="bg-banner-warning-bg border-banner-warning-border text-banner-warning-text rounded-default flex w-full flex-col gap-2 border p-3"
|
||||
>
|
||||
<div class="text-sm">
|
||||
@@ -191,28 +210,150 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</div>
|
||||
</OForm>
|
||||
|
||||
<!-- CREATE a new function — inline on the SAME page. Single-screen (workflows):
|
||||
always shown below the select, set apart by a divider; its own toolbar saves
|
||||
the function, which then auto-selects in the dropdown above. Pipelines: shown
|
||||
only when the mode switch is on (keeps their full-height editor). -->
|
||||
<!-- ALWAYS a fresh function. AddFunction's `is-updated` means "editing an
|
||||
EXISTING function" (disables its name input), which is a different thing
|
||||
from editing the NODE — never feed it our `isUpdating`. Single-screen
|
||||
(workflows) shows it always, full-height, as the main surface. -->
|
||||
<!-- Editor fills the available height (comment stays pinned below via the host
|
||||
panel), so no vertical space is wasted. -->
|
||||
<div v-if="createButton || createNewFunction" class="flow-add-function min-h-0 w-full flex-1">
|
||||
<!-- Single-screen: picking a saved function loads ITS definition into the
|
||||
editor (re-keyed so the code swaps on each selection); with none picked it
|
||||
seeds the fresh-function default. -->
|
||||
<AddFunction
|
||||
ref="addFunctionRef"
|
||||
:key="createButton ? `${selectedFunction}:${resetNonce}` : undefined"
|
||||
:is-updated="false"
|
||||
:height-offset="75"
|
||||
:sample-events="sampleEvents"
|
||||
:forced-language="language"
|
||||
:hide-test-panel="createButton"
|
||||
:hide-ai-assist="createButton"
|
||||
:default-code="editorSeed"
|
||||
@update:list="onFunctionCreation"
|
||||
@cancel:hideform="cancelFunctionCreation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Single-screen (workflow) bottom bar: After Flattening pinned to the RIGHT.
|
||||
Save moved up beside the dropdown so it clearly saves the FUNCTION. The
|
||||
toggle carries a hover tooltip explaining before- vs after-flattening. -->
|
||||
<div v-if="createButton && showFlatten" class="flex shrink-0 items-center justify-end pt-1">
|
||||
<div class="inline-flex items-center">
|
||||
<OSwitch
|
||||
:model-value="afterFlatteningValue"
|
||||
:label="t('flow.function.flatten')"
|
||||
data-test="wf-after-flatten"
|
||||
@update:model-value="onAfterFlatteningChange"
|
||||
/>
|
||||
<OTooltip side="top">
|
||||
<template #content>
|
||||
<div class="flex max-w-64 flex-col gap-1">
|
||||
<div>
|
||||
<span class="font-semibold">{{ t("flow.function.rbf") }}</span>
|
||||
{{ t("flow.function.rbfDesc") }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-semibold">{{ t("flow.function.raf") }}</span>
|
||||
{{ t("flow.function.rafDesc") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</OTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Saved Functions dialog (single-screen / workflow only) — mirrors the Logs
|
||||
editor: Save from the editor opens Update|Create. Update overwrites an existing
|
||||
reusable function (with a confirm, since it may be used elsewhere); Create makes
|
||||
a new one. Either way we then select it, so the node references it by name. -->
|
||||
<ODialog
|
||||
v-if="createButton"
|
||||
v-model:open="savedDialog"
|
||||
size="md"
|
||||
form-id="wf-saved-function-form"
|
||||
:title="t('search.functionPlaceholder')"
|
||||
:secondary-button-label="t('confirmDialog.cancel')"
|
||||
:primary-button-label="t('confirmDialog.ok')"
|
||||
:primary-button-loading="savingFn"
|
||||
@click:secondary="savedDialog = false"
|
||||
>
|
||||
<OForm id="wf-saved-function-form" :form="savedForm">
|
||||
<OFormToggleGroup
|
||||
name="isSavedFunctionAction"
|
||||
data-test="wf-saved-function-action-toggle"
|
||||
:disabled="functionOptions.length === 0"
|
||||
class="mb-3"
|
||||
>
|
||||
<OToggleGroupItem value="update" size="sm">{{ t("common.update") }}</OToggleGroupItem>
|
||||
<OToggleGroupItem value="create" size="sm">{{ t("common.create") }}</OToggleGroupItem>
|
||||
</OFormToggleGroup>
|
||||
<OFormInput
|
||||
v-if="savedMode === 'create'"
|
||||
name="savedFunctionName"
|
||||
data-test="wf-saved-function-name-input"
|
||||
:label="t('search.saveFunctionName')"
|
||||
required
|
||||
/>
|
||||
<OFormSelect
|
||||
v-else
|
||||
name="savedFunctionSelectedName"
|
||||
data-test="wf-saved-function-name-select"
|
||||
:options="functionOptions"
|
||||
:label="t('search.saveFunctionName')"
|
||||
:placeholder="t('search.selectFunctionNamePlaceholder')"
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
</OForm>
|
||||
</ODialog>
|
||||
<ConfirmDialog
|
||||
v-if="createButton"
|
||||
data-test="wf-function-update-confirm"
|
||||
:title="t('search.confirmFunctionUpdateTitle')"
|
||||
:message="t('search.confirmFunctionUpdateMsg', { name: fnToUpdateName })"
|
||||
v-model="fnUpdateConfirm"
|
||||
@update:ok="executeUpdate"
|
||||
@update:cancel="fnUpdateConfirm = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
|
||||
import OForm from "@/lib/forms/Form/OForm.vue";
|
||||
import OFormSelect from "@/lib/forms/Select/OFormSelect.vue";
|
||||
import OFormSwitch from "@/lib/forms/Switch/OFormSwitch.vue";
|
||||
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
|
||||
import OFormToggleGroup from "@/lib/core/ToggleGroup/OFormToggleGroup.vue";
|
||||
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import { useOForm } from "@/lib/forms/Form/useOForm";
|
||||
import {
|
||||
makeAssociateFunctionSchema,
|
||||
type AssociateFunctionForm,
|
||||
} from "@/components/pipeline/NodeForm/AssociateFunction.schema";
|
||||
import {
|
||||
makeSavedFunctionSchema,
|
||||
type SavedFunctionForm,
|
||||
} from "@/plugins/logs/SearchBar.SavedFunction.schema";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
|
||||
import OCard from "@/lib/core/Card/OCard.vue";
|
||||
import OCardSection from "@/lib/core/Card/OCardSection.vue";
|
||||
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import functionsService from "@/services/jstransform";
|
||||
import { isJsFunction } from "@/utils/functionLanguage";
|
||||
@@ -225,6 +366,15 @@ const props = withDefaults(
|
||||
initialAfterFlatten?: boolean;
|
||||
showFlatten?: boolean;
|
||||
isUpdating?: boolean;
|
||||
optional?: boolean;
|
||||
// Single-screen mode (Workflows): the select + the inline create editor on ONE
|
||||
// full-height page (no mode switch, no definition preview, no RBF/RAF guidelines) —
|
||||
// an editor-first surface. Pipelines keep the switch + preview + guidelines (default).
|
||||
createButton?: boolean;
|
||||
// Workflow inline JS: the node's saved raw_fn (unsaved/edited code not backed by a
|
||||
// library function). When set with no `initialName`, it seeds the editor so reopening
|
||||
// a draft keeps the inline code. submit() sends it back as `raw_fn` while dirty.
|
||||
initialRawFn?: string;
|
||||
duplicateNames?: string[];
|
||||
// Sample events to seed the inline function editor's "Events" panel (e.g. the
|
||||
// workflow alert payload). Omitted → the generic log sample.
|
||||
@@ -242,6 +392,9 @@ const props = withDefaults(
|
||||
initialAfterFlatten: true,
|
||||
showFlatten: true,
|
||||
isUpdating: false,
|
||||
optional: false,
|
||||
createButton: false,
|
||||
initialRawFn: "",
|
||||
duplicateNames: () => [],
|
||||
sampleEvents: undefined,
|
||||
language: "",
|
||||
@@ -293,6 +446,49 @@ const selectedFunction = form.useStore((s: any) => s.values?.selectedFunction ??
|
||||
|
||||
const selectedDefinition = computed(() => functionDefs.value[selectedFunction.value] || "");
|
||||
|
||||
// ── Inline JS (raw_fn) + dirty tracking (workflow single-screen) ──────────────
|
||||
// The editor seeds from the selected fn's saved code, else the node's persisted
|
||||
// raw_fn, else the fresh-function default. Bumping resetNonce re-keys AddFunction
|
||||
// to force a remount at that seed — used by discard to revert the editor.
|
||||
const resetNonce = ref(0);
|
||||
const editorSeed = computed(() =>
|
||||
props.createButton && selectedFunction.value
|
||||
? selectedDefinition.value
|
||||
: props.initialRawFn || props.defaultCode,
|
||||
);
|
||||
// Live editor code (imperative — AddFunction exposes getCode only). Read on demand;
|
||||
// NOT reactive to typing, so isDirty()/submit() call it at the moment they run.
|
||||
const editorCode = (): string => (addFunctionRef.value?.getCode?.() as string) ?? "";
|
||||
// Would this node serialize as inline `raw_fn`? True when the live code diverges from
|
||||
// the selected fn's saved code, or there's real inline code with no fn selected.
|
||||
const isDirty = (): boolean => {
|
||||
if (!props.createButton) return false; // raw_fn is a workflow-only concept
|
||||
const code = editorCode().trim();
|
||||
if (selectedFunction.value) return code !== (selectedDefinition.value || "").trim();
|
||||
// No saved fn selected: dirty only when the code diverges from what the editor was
|
||||
// seeded with — the node's persisted raw_fn if it has one, else the fresh default
|
||||
// (matches `editorSeed`). Comparing against defaultCode alone flags an untouched
|
||||
// reopened inline node as dirty.
|
||||
const seed = (props.initialRawFn || props.defaultCode || "").trim();
|
||||
return code.length > 0 && code !== seed;
|
||||
};
|
||||
// Discard: revert the editor to its seed by remounting — the selected fn's saved code
|
||||
// (name kept), or the fresh default when nothing was selected (node → unconfigured).
|
||||
const discardChanges = () => {
|
||||
resetNonce.value++;
|
||||
};
|
||||
|
||||
// After Flattening lives in the bottom bar (single-screen). Plain switch bound to the
|
||||
// same form field submit() reads, so no second OForm context is needed at the bottom.
|
||||
const afterFlatteningValue = form.useStore((s: any) => !!s.values?.afterFlattening);
|
||||
const onAfterFlatteningChange = (v: unknown) => form.setFieldValue("afterFlattening", !!v);
|
||||
|
||||
// Bottom Save → pull the live editor code from AddFunction and open Update|Create.
|
||||
const onBottomSave = () => {
|
||||
const code = (addFunctionRef.value?.getCode?.() as string) ?? "";
|
||||
onSaveRequest(code);
|
||||
};
|
||||
|
||||
// Only functions written in the host's language are selectable: a pipeline runs
|
||||
// VRL, a workflow node runs JS. Offering the other kind would let a user attach a
|
||||
// function the node can't execute. (isJsFunction handles the transType /
|
||||
@@ -345,10 +541,131 @@ const cancelFunctionCreation = () => {
|
||||
createNewFunction.value = false;
|
||||
};
|
||||
|
||||
// ── Saved Functions dialog (single-screen / workflow) ────────────────────────
|
||||
// Mirrors the Logs editor: the editor's Save hands us its code (save-request), we
|
||||
// ask Update-or-Create, then persist + select. Reuses the Logs schema so the
|
||||
// create/update validation is identical.
|
||||
const savedDialog = ref(false);
|
||||
const savingFn = ref(false);
|
||||
const pendingCode = ref("");
|
||||
const fnUpdateConfirm = ref(false);
|
||||
const fnToUpdateName = ref("");
|
||||
|
||||
const savedForm = useOForm<SavedFunctionForm>({
|
||||
defaultValues: {
|
||||
isSavedFunctionAction: "create",
|
||||
savedFunctionName: "",
|
||||
savedFunctionSelectedName: "",
|
||||
},
|
||||
schema: makeSavedFunctionSchema(t),
|
||||
onSubmit: (v) => onSavedSubmit(v),
|
||||
});
|
||||
const savedMode = savedForm.useStore(
|
||||
(s: any) => (s.values.isSavedFunctionAction as string) ?? "create",
|
||||
);
|
||||
|
||||
// Editor Save → open Update|Create. Default to Update (preselecting the loaded
|
||||
// function) when one is selected; otherwise Create. Empty code has nothing to save.
|
||||
const onSaveRequest = (code: string) => {
|
||||
if (!code || !code.trim()) {
|
||||
toast({ variant: "warning", message: t("logs.searchBar.functionFieldRequired") });
|
||||
return;
|
||||
}
|
||||
pendingCode.value = code;
|
||||
const current = selectedFunction.value as string;
|
||||
savedForm.reset({
|
||||
isSavedFunctionAction: current && functionOptions.value.length ? "update" : "create",
|
||||
savedFunctionName: "",
|
||||
savedFunctionSelectedName: current || "",
|
||||
});
|
||||
savedDialog.value = true;
|
||||
};
|
||||
|
||||
// jstransform payload for the current editor code. Workflow functions are JS
|
||||
// (transType 1); params is the fixed `row` binding.
|
||||
const fnPayload = (name: string) => ({
|
||||
name,
|
||||
function: pendingCode.value,
|
||||
params: "row",
|
||||
transType: wantsJs.value ? 1 : 0,
|
||||
});
|
||||
|
||||
const afterSaved = async (name: string) => {
|
||||
await getFunctions();
|
||||
await nextTick();
|
||||
form.setFieldValue("selectedFunction", name);
|
||||
emit("created", { name });
|
||||
};
|
||||
|
||||
const onSavedSubmit = async (v: SavedFunctionForm) => {
|
||||
if (v.isSavedFunctionAction === "create") {
|
||||
savingFn.value = true;
|
||||
try {
|
||||
const res: any = await functionsService.create(
|
||||
store.state.selectedOrganization.identifier,
|
||||
fnPayload(v.savedFunctionName),
|
||||
);
|
||||
toast({ variant: "success", message: raw(res?.data?.message || t("flow.function.saved")) });
|
||||
savedDialog.value = false;
|
||||
await afterSaved(v.savedFunctionName);
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
variant: "error",
|
||||
message: raw(e?.response?.data?.message || t("flow.function.saveError")),
|
||||
});
|
||||
} finally {
|
||||
savingFn.value = false;
|
||||
}
|
||||
} else {
|
||||
// Update overwrites a reusable function that may be used elsewhere → confirm first.
|
||||
fnToUpdateName.value = v.savedFunctionSelectedName;
|
||||
fnUpdateConfirm.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const executeUpdate = async () => {
|
||||
savingFn.value = true;
|
||||
try {
|
||||
const res: any = await functionsService.update(
|
||||
store.state.selectedOrganization.identifier,
|
||||
fnPayload(fnToUpdateName.value),
|
||||
);
|
||||
toast({ variant: "success", message: raw(res?.data?.message || t("flow.function.updated")) });
|
||||
fnUpdateConfirm.value = false;
|
||||
savedDialog.value = false;
|
||||
await afterSaved(fnToUpdateName.value);
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
variant: "error",
|
||||
message: raw(e?.response?.data?.message || t("flow.function.saveError")),
|
||||
});
|
||||
} finally {
|
||||
savingFn.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Host bridge: validate through the schema and return the node payload, or null
|
||||
// when invalid (OForm renders required / already-associated inline on the field).
|
||||
const submit = async () => {
|
||||
if (createNewFunction.value) return null; // still in the inline editor
|
||||
// Optional (Workflows dummy node): empty is allowed. Read the current value
|
||||
// WITHOUT running the required schema, so no inline error and empty resolves to
|
||||
// an empty name rather than null (mirrors DestinationPicker's optional branch).
|
||||
if (props.optional) {
|
||||
const flat = props.showFlatten ? { after_flatten: !!form.state.values.afterFlattening } : {};
|
||||
// Workflow single-screen: when the editor holds inline/edited code (diverges from
|
||||
// the selected saved fn, or there's code with none selected), send it as `raw_fn`
|
||||
// with an empty name — the backend runs it as inline JS. Otherwise reference the
|
||||
// saved fn by name. (Never both; a clean selection or an empty node clears raw_fn.)
|
||||
if (props.createButton && isDirty()) {
|
||||
return { name: "", raw_fn: editorCode(), ...flat };
|
||||
}
|
||||
const name = (form.state.values.selectedFunction as string) || "";
|
||||
// Clean/empty: clear any stale raw_fn. `undefined` (not "") so it drops from JSON
|
||||
// → backend `None`; an empty string would be `Some("")` and read as "has raw_fn",
|
||||
// which would wrongly flag a saved-fn node as unsaved and block Publish.
|
||||
return { name, raw_fn: undefined, ...flat };
|
||||
}
|
||||
validated.value = null;
|
||||
await form.handleSubmit();
|
||||
const values = validated.value as AssociateFunctionForm | null;
|
||||
@@ -358,23 +675,8 @@ const submit = async () => {
|
||||
: { name: values.selectedFunction };
|
||||
};
|
||||
|
||||
defineExpose({ submit, createNewFunction, form });
|
||||
// `isDirty` / `discardChanges` drive the workflow NDV's save-or-discard exit prompt;
|
||||
// `saveChanges` opens the Save (Update|Create) flow with the live editor code.
|
||||
const saveChanges = () => onBottomSave();
|
||||
defineExpose({ submit, createNewFunction, form, isDirty, discardChanges, saveChanges });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* keep(lib-override:AddFunction): only AddFunction's own chrome is suppressed here — :deep() reaches a child
|
||||
component's internals, which utilities cannot.
|
||||
The `.function-code` font-family became the `font-mono` utility, and the
|
||||
::-webkit-scrollbar rules were DELETED: component.css already styles
|
||||
scrollbars globally from tokens WITH `.dark` variants, so these light-only
|
||||
hex rules were duplicating it and overriding the themed version in dark. */
|
||||
.flow-add-function :deep(.add-function-back-btn),
|
||||
.flow-add-function :deep(.add-function-fullscreen-btn),
|
||||
.flow-add-function :deep(.add-function-title) {
|
||||
display: none;
|
||||
}
|
||||
.flow-add-function :deep(.add-function-name-input) {
|
||||
width: 100%;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,7 +20,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
button (type="submit"), so it lives INSIDE the <OForm>. The editor +
|
||||
TestFunction below stay OUTSIDE the form. Inline form → Enter submits
|
||||
natively via the type="submit" Save button (no form-id needed). -->
|
||||
<!-- Embedded (workflow NDV): the whole toolbar is hidden. Naming happens at SAVE
|
||||
time in the host's Update|Create dialog, and After Flattening + Save live at the
|
||||
bottom of the host panel — so the editor is the only thing here. -->
|
||||
<OForm
|
||||
v-if="!hideTestPanel"
|
||||
id="add-function-form"
|
||||
:form="addFunctionForm"
|
||||
class="border-border-default shrink-0 border-b px-2"
|
||||
@@ -31,6 +35,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:disable-name="beingUpdated"
|
||||
:transform-type-options="transformTypeOptions"
|
||||
:hide-trans-type="!!forcedLanguage"
|
||||
:hide-chat="hideAiAssist"
|
||||
@test="onTestFunction"
|
||||
@back="closeAddFunction"
|
||||
@cancel="cancelAddFunction"
|
||||
@@ -43,15 +48,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
class="flex min-h-0 overflow-hidden"
|
||||
:class="[store.state.isAiChatEnabled && !isAddFunctionComponent ? 'w-3/4' : 'w-full']"
|
||||
>
|
||||
<!-- Workflows (hideTestPanel): the NDV supplies Input · Output around this
|
||||
pane, so the embedded test panel is redundant — collapse the splitter to
|
||||
an editor-only, full-width surface (no drag handle). -->
|
||||
<OSplitter
|
||||
v-model="splitterModel"
|
||||
:limits="[30, 100]"
|
||||
:limits="hideTestPanel ? [100, 100] : [30, 100]"
|
||||
class="w-full overflow-hidden"
|
||||
:horizontal="false"
|
||||
separator-class="w-[0.0625rem] bg-card-glass-border"
|
||||
:separator-class="hideTestPanel ? 'hidden' : 'w-[0.0625rem] bg-card-glass-border'"
|
||||
>
|
||||
<template v-slot:before>
|
||||
<div class="bg-card-glass-bg flex h-full min-h-0 flex-col px-2 pt-2 pb-3">
|
||||
<!-- Workflows (hideTestPanel): drop the horizontal padding so the editor
|
||||
sits flush with the panel edges. -->
|
||||
<div
|
||||
class="bg-card-glass-bg flex h-full min-h-0 flex-col pt-2 pb-3"
|
||||
:class="hideTestPanel ? 'px-0' : 'px-2'"
|
||||
>
|
||||
<div class="o2-input flex min-h-0 flex-1 flex-col pb-2">
|
||||
<FullViewContainer
|
||||
name="function"
|
||||
@@ -63,6 +76,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
)
|
||||
"
|
||||
min-header-height="2.125rem"
|
||||
:show-expand-icon="!hideTestPanel"
|
||||
:label-class="hideTestPanel ? 'pl-2' : ''"
|
||||
/>
|
||||
<div v-show="expandState.functions" class="relative mb-1.5 min-h-0 flex-1">
|
||||
<!-- Unified Query Editor (with built-in AI bar) -->
|
||||
@@ -135,13 +150,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:after>
|
||||
<div class="bg-card-glass-bg h-full overflow-y-auto px-2 pt-2 pb-3">
|
||||
<div
|
||||
v-if="!hideTestPanel"
|
||||
class="bg-card-glass-bg h-full overflow-y-auto px-2 pt-2 pb-3"
|
||||
>
|
||||
<TestFunction
|
||||
ref="testFunctionRef"
|
||||
:vrlFunction="vrlFunctionData"
|
||||
@function-error="handleFunctionError"
|
||||
:heightOffset="heightOffset"
|
||||
:sample-events="sampleEvents"
|
||||
:hide-query="hideQuery"
|
||||
:hide-ai-assist="hideAiAssist"
|
||||
@sendToAiChat="sendToAiChat"
|
||||
/>
|
||||
</div>
|
||||
@@ -233,6 +253,26 @@ export default defineComponent({
|
||||
type: Array,
|
||||
default: undefined,
|
||||
},
|
||||
// Hide the SQL "Query" section of the test panel (workflows run on the trigger
|
||||
// event, not a stream query). Forwarded to TestFunction.
|
||||
hideQuery: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Hide the chat-panel AI buttons (the toolbar "Ask AI" toggle + the Events
|
||||
// send-to-chat button); the inline editor AI bar stays. Workflows set this.
|
||||
hideAiAssist: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Drop the embedded test panel (Events input + Output) entirely, leaving only
|
||||
// the code editor at full width. Workflows set this: the NDV already frames the
|
||||
// editor with its own Input · Output panes, so a nested test panel is redundant
|
||||
// (and confusing) inside the Config pane.
|
||||
hideTestPanel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// When set ('vrl' | 'javascript'), the language is locked to this value and
|
||||
// the VRL/JS toggle is hidden (workflow function nodes force 'javascript').
|
||||
forcedLanguage: {
|
||||
@@ -257,7 +297,7 @@ export default defineComponent({
|
||||
O2AIChat,
|
||||
},
|
||||
emits: ["update:list", "cancel:hideform", "sendToAiChat"],
|
||||
setup(props, { emit }) {
|
||||
setup(props, { emit, expose }) {
|
||||
const store: any = useStore();
|
||||
const router = useRouter();
|
||||
const { track } = useReo();
|
||||
@@ -279,7 +319,8 @@ export default defineComponent({
|
||||
let editorobj: any = null;
|
||||
const isFetchingStreams = ref(false);
|
||||
const testFunctionRef = ref<typeof TestFunction>();
|
||||
const splitterModel = ref(50);
|
||||
// Editor-only (workflows) starts full width; the test-panel split is locked out.
|
||||
const splitterModel = ref(props.hideTestPanel ? 100 : 50);
|
||||
const aiChatInputContext = ref("");
|
||||
const confirmDialogMeta = ref({
|
||||
// raw("") is only the empty placeholder — the real values are assigned from t().
|
||||
@@ -481,6 +522,10 @@ export default defineComponent({
|
||||
if (testFunctionRef.value) testFunctionRef.value.testFunction();
|
||||
};
|
||||
|
||||
// Embedded (workflow): the host's bottom Save reads the live editor code from here,
|
||||
// then drives its own Update|Create dialog. Exposed for the parent ref.
|
||||
expose({ getCode: () => formData.value.function });
|
||||
|
||||
const handleFunctionError = (err: string) => {
|
||||
vrlFunctionError.value = err;
|
||||
if (err) {
|
||||
|
||||
@@ -5,17 +5,27 @@
|
||||
action buttons (#actions). The name + transType controls are form-owned
|
||||
(OForm*); the parent AddFunction.vue provides the <OForm> context they
|
||||
inject — which is what lets the title live in the #title slot. -->
|
||||
<!-- Embedded (workflow NDV): the host panel owns the title, close (×) and
|
||||
navigation, so drop this component's own back chevron + "Add Function"
|
||||
subtitle — they read as a competing, mid-flow page header. -->
|
||||
<OPageHeader
|
||||
:subtitle="t('function.addFunction')"
|
||||
:subtitle="embedded ? undefined : t('function.addFunction')"
|
||||
title-overflow="visible"
|
||||
:back="{
|
||||
label: t('function.header'),
|
||||
onClick: redirectToFunctions,
|
||||
dataTest: 'add-function-back-btn',
|
||||
}"
|
||||
:back="
|
||||
embedded
|
||||
? undefined
|
||||
: {
|
||||
label: t('function.header'),
|
||||
onClick: redirectToFunctions,
|
||||
dataTest: 'add-function-back-btn',
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #title>
|
||||
<!-- Embedded (workflow NDV): no inline function name here — the name is asked at
|
||||
SAVE time in the Update|Create dialog, mirroring the Logs editor. -->
|
||||
<OFormInlineEdit
|
||||
v-if="!embedded"
|
||||
name="name"
|
||||
data-test="add-function-name-input"
|
||||
:placeholder="t('function.name')"
|
||||
@@ -151,7 +161,8 @@
|
||||
v-if="
|
||||
config.isEnterprise == 'true' &&
|
||||
!isAddFunctionComponent &&
|
||||
store.state.zoConfig.ai_enabled
|
||||
store.state.zoConfig.ai_enabled &&
|
||||
!hideChat
|
||||
"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -169,6 +180,7 @@
|
||||
/>
|
||||
</OButton>
|
||||
<OButton
|
||||
v-if="!embedded"
|
||||
data-test="add-function-fullscreen-btn"
|
||||
v-close-popup="true"
|
||||
variant="outline"
|
||||
@@ -180,6 +192,7 @@
|
||||
{{ t("common.fullscreen") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
v-if="!hideTest"
|
||||
data-test="add-function-test-btn"
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
@@ -190,6 +203,7 @@
|
||||
{{ t("function.testFunction") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
v-if="!embedded"
|
||||
data-test="add-function-cancel-btn"
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
@@ -252,6 +266,24 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Hides the top "Ask AI" chat toggle (workflows keep only the inline editor AI).
|
||||
hideChat: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Hides the "Test Function" action (workflows test via the NDV's Replay instead of
|
||||
// the embedded test panel, which is removed there).
|
||||
hideTest: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Embedded in a host panel (workflow NDV): drop this component's own page-header
|
||||
// navigation — the back chevron, the "Add Function" subtitle, Go Full Screen and
|
||||
// Cancel — since the host frame already provides title, close and navigation.
|
||||
embedded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/** Drives the Save spinner + disables sibling actions while the form submits. */
|
||||
isSubmitting: {
|
||||
type: Boolean,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div data-test="test-function-section" class="flex flex-wrap items-center pb-2">
|
||||
<div v-if="!hideQuery" data-test="test-function-section" class="flex flex-wrap items-center pb-2">
|
||||
<div data-test="test-function-query-section" class="test-function-query-container w-full">
|
||||
<FullViewContainer
|
||||
data-test="test-function-query-title-section"
|
||||
@@ -159,6 +159,7 @@
|
||||
<template #right>
|
||||
<!-- o2 ai context add button in the test function -->
|
||||
<O2AIContextAddBtn
|
||||
v-if="!hideAiAssist"
|
||||
@send-to-ai-chat="sendToAiChat(JSON.stringify(inputEvents))"
|
||||
imageHeight="24"
|
||||
imageWidth="24"
|
||||
@@ -305,6 +306,19 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: undefined,
|
||||
},
|
||||
// Hide the SQL "Query" section (stream + query + Run query). Workflows run the
|
||||
// function on the trigger event (seeded into "Events"), not a stream query, so the
|
||||
// Query section is irrelevant there.
|
||||
hideQuery: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Hide the "send to AI chat" button on the Events header (workflows keep only the
|
||||
// inline editor AI, not the chat-panel context buttons).
|
||||
hideAiAssist: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["function-error", "sendToAiChat"]);
|
||||
|
||||
@@ -75,11 +75,6 @@ const { stub } = vi.hoisted(() => ({
|
||||
vi.mock("@/plugins/workflows/WorkflowCanvas.vue", () => stub("WorkflowCanvas"));
|
||||
vi.mock("./WorkflowNodeDrawer.vue", () => stub("WorkflowNodeDrawer"));
|
||||
vi.mock("./WorkflowTestDialog.vue", () => stub("WorkflowTestDialog"));
|
||||
vi.mock("./WorkflowResultsDock.vue", () =>
|
||||
stub("WorkflowResultsDock", {
|
||||
template: `<div data-test="WorkflowResultsDock"><slot /></div>`,
|
||||
}),
|
||||
);
|
||||
vi.mock("./WorkflowLinkAlertsDialog.vue", () =>
|
||||
stub("WorkflowLinkAlertsDialog", {
|
||||
props: ["workflowId", "workflowName"],
|
||||
@@ -157,6 +152,15 @@ const globalStubs = {
|
||||
template:
|
||||
'<input class="o-input" v-bind="$attrs" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
},
|
||||
// The History dropdown — render its trigger + footer slots so the History button
|
||||
// and the "Open Full Runs View" link are present for the toolbar tests.
|
||||
WorkflowRunSwitcher: {
|
||||
name: "WorkflowRunSwitcher",
|
||||
props: ["currentRunId", "orgId", "workflowId"],
|
||||
emits: ["select"],
|
||||
template:
|
||||
'<div class="workflow-run-switcher"><slot name="trigger" /><slot name="footer" /></div>',
|
||||
},
|
||||
};
|
||||
|
||||
const mountEditor = () =>
|
||||
@@ -502,12 +506,20 @@ describe("WorkflowEditor", () => {
|
||||
palette(wrapper).props("onItemClick")({ subtype: "condition" });
|
||||
await nextTick();
|
||||
|
||||
expect(workflowObj.dialog.name).toBe("condition");
|
||||
expect(workflowObj.dialog.show).toBe(true);
|
||||
expect(workflowObj.pendingEdge).toEqual({
|
||||
source: triggerId,
|
||||
sourceHandle: undefined,
|
||||
});
|
||||
// Insert-immediately, and the panel stays SHUT: adding several steps in a row
|
||||
// shouldn't mean dismissing a dialog each time. The node is on the canvas now,
|
||||
// wired after the trigger, and the pending edge has been consumed.
|
||||
expect(workflowObj.dialog.show).toBe(false);
|
||||
const added = workflowObj.currentSelectedWorkflow.nodes.find(
|
||||
(n: any) => n.data.node_type === "condition",
|
||||
);
|
||||
expect(added).toBeTruthy();
|
||||
expect(
|
||||
workflowObj.currentSelectedWorkflow.edges.some(
|
||||
(e: any) => e.source === triggerId && e.target === added.id,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(workflowObj.pendingEdge).toBeNull();
|
||||
});
|
||||
|
||||
it("records the dragged node type on palette drag start", async () => {
|
||||
@@ -555,7 +567,7 @@ describe("WorkflowEditor", () => {
|
||||
expect(items[0].iconTint).toContain("badge-warning-soft");
|
||||
});
|
||||
|
||||
it("stages the picked step after the source node and closes the picker", async () => {
|
||||
it("inserts the picked step after the source node and closes the picker", async () => {
|
||||
wrapper = mountEditor();
|
||||
await flushPromises();
|
||||
const triggerId = placeTrigger();
|
||||
@@ -573,11 +585,18 @@ describe("WorkflowEditor", () => {
|
||||
await nextTick();
|
||||
|
||||
expect(workflowObj.stepPicker.show).toBe(false);
|
||||
expect(workflowObj.dialog.name).toBe("function");
|
||||
expect(workflowObj.pendingEdge).toEqual({
|
||||
source: triggerId,
|
||||
sourceHandle: undefined,
|
||||
});
|
||||
// Insert-immediately with the panel left shut — configure by clicking the node.
|
||||
expect(workflowObj.dialog.show).toBe(false);
|
||||
const fn = workflowObj.currentSelectedWorkflow.nodes.find(
|
||||
(n: any) => n.data.node_type === "function",
|
||||
);
|
||||
expect(fn).toBeTruthy();
|
||||
expect(
|
||||
workflowObj.currentSelectedWorkflow.edges.some(
|
||||
(e: any) => e.source === triggerId && e.target === fn.id,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(workflowObj.pendingEdge).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the picker on close", async () => {
|
||||
@@ -776,8 +795,10 @@ describe("WorkflowEditor", () => {
|
||||
|
||||
await clickSave(wrapper);
|
||||
|
||||
// Pluralised + interpolated: the count must actually reach the message, so
|
||||
// assert the resolved string rather than the raw key.
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
message: t("workflow.finishStepsBeforePublish"),
|
||||
message: i18n.global.t("workflow.finishStepsBeforePublish", { count: 1 }, 1),
|
||||
variant: "warning",
|
||||
});
|
||||
expect(createWorkflow).not.toHaveBeenCalled();
|
||||
@@ -1520,12 +1541,18 @@ describe("WorkflowEditor", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("mounts the results dock (which hosts the step Input/Output) instead of an overlay drawer", async () => {
|
||||
// The docked results panel is gone: a step's Input/Output is inspected in the
|
||||
// node's own NDV, so inspecting and editing a step are one surface.
|
||||
it("inspects step results in the NDV, not a docked results panel", async () => {
|
||||
wrapper = mountEditor();
|
||||
await flushPromises();
|
||||
// The editor docks results below the canvas via WorkflowResultsDock; a node
|
||||
// badge selects a step inside the dock rather than opening an overlay drawer.
|
||||
expect(wrapper.find('[data-test="WorkflowResultsDock"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="WorkflowResultsDock"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-test="WorkflowNodeDrawer"]').exists()).toBe(false);
|
||||
|
||||
// Opening a step (what a node/badge click does) mounts the NDV.
|
||||
workflowObj.dialog.show = true;
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-test="WorkflowNodeDrawer"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1541,10 +1568,10 @@ describe("WorkflowEditor", () => {
|
||||
await flushPromises();
|
||||
};
|
||||
|
||||
it("navigates to the Runs view from the toolbar History button", async () => {
|
||||
it("navigates to the Runs view from the History dropdown's 'Open Full Runs View'", async () => {
|
||||
await openSaved();
|
||||
|
||||
await wrapper.find('[data-test="workflow-editor-history"]').trigger("click");
|
||||
await wrapper.find('[data-test="workflow-editor-open-runs-view"]').trigger("click");
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith({
|
||||
name: "workflowRuns",
|
||||
@@ -1556,10 +1583,82 @@ describe("WorkflowEditor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not load a run on mount even when deep-linked with ?run_id", async () => {
|
||||
// ── Run overlay: arriving from Runs via "Fix This Step" ──────────────────
|
||||
// Reverses the old contract ("the editor never loads a run"). A run is now
|
||||
// copied onto the SAME testRun.result the canvas paints from, so the user
|
||||
// lands on the identical canvas — but editable. The run is only ever READ.
|
||||
|
||||
it("loads the deep-linked run onto the canvas", async () => {
|
||||
await openSaved({ id: "wf-1", run_id: "run-7" });
|
||||
// Run inspection lives entirely in the Runs view now.
|
||||
expect(getWorkflowRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ run_id: "run-7", id: "wf-1" }),
|
||||
);
|
||||
expect(workflowObj.testRun.result?.mode).toBe("history");
|
||||
expect(workflowObj.testRun.result?.runId).toBe("run-7");
|
||||
});
|
||||
|
||||
it("does not load anything without ?run_id", async () => {
|
||||
await openSaved({ id: "wf-1" });
|
||||
expect(getWorkflowRun).not.toHaveBeenCalled();
|
||||
expect(workflowObj.testRun.result).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the provenance chip while a run overlay is active", async () => {
|
||||
await openSaved({ id: "wf-1", run_id: "run-7" });
|
||||
expect(wrapper.find('[data-test="workflow-editor-run-chip"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("has no run chip on a normal edit session", async () => {
|
||||
await openSaved({ id: "wf-1" });
|
||||
expect(wrapper.find('[data-test="workflow-editor-run-chip"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
// The chip is the ONLY thing separating historical from live data, so it
|
||||
// must disappear the moment a real Test writes the same key. A Test result
|
||||
// carries no `mode`, which is what makes this automatic.
|
||||
it("drops the chip when a test result replaces the overlay", async () => {
|
||||
await openSaved({ id: "wf-1", run_id: "run-7" });
|
||||
workflowObj.testRun.result = { errors: {}, inputs: {}, ranNodeIds: [], blockedNodeIds: [] };
|
||||
await nextTick();
|
||||
expect(wrapper.find('[data-test="workflow-editor-run-chip"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the overlay from the chip", async () => {
|
||||
await openSaved({ id: "wf-1", run_id: "run-7" });
|
||||
await wrapper.find('[data-test="workflow-editor-run-chip-clear"]').trigger("click");
|
||||
await nextTick();
|
||||
expect(workflowObj.testRun.result).toBeNull();
|
||||
expect(wrapper.find('[data-test="workflow-editor-run-chip"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("opens the node named by ?node_id", async () => {
|
||||
await openSaved({ id: "wf-1", run_id: "run-7", node_id: "d1" });
|
||||
expect(workflowObj.dialog.show).toBe(true);
|
||||
expect(workflowObj.currentSelectedNodeID).toBe("d1");
|
||||
});
|
||||
|
||||
it("warns instead of opening when that node was removed after the run", async () => {
|
||||
await openSaved({ id: "wf-1", run_id: "run-7", node_id: "gone" });
|
||||
expect(workflowObj.dialog.show).toBe(false);
|
||||
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: "warning" }));
|
||||
});
|
||||
|
||||
// Steps the run executed that the workflow no longer has: their badges have
|
||||
// nowhere to render, so the run would look cleaner here than it really was.
|
||||
it("warns about ghost steps the workflow no longer has", async () => {
|
||||
getWorkflowRun.mockResolvedValue({
|
||||
data: { errors: { data: [{ node_id: "deleted-node", error: ["boom"] }] }, data: {} },
|
||||
});
|
||||
await openSaved({ id: "wf-1", run_id: "run-7" });
|
||||
expect(workflowObj.testRun.result?.ghostNodeIds).toContain("deleted-node");
|
||||
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: "warning" }));
|
||||
});
|
||||
|
||||
it("surfaces a run that fails to load without leaving a stale overlay", async () => {
|
||||
getWorkflowRun.mockRejectedValue({ response: { data: { message: "nope" } } });
|
||||
await openSaved({ id: "wf-1", run_id: "run-7" });
|
||||
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
|
||||
expect(wrapper.find('[data-test="workflow-editor-run-chip"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -82,17 +82,56 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
it's managed from the list, same as pipelines. -->
|
||||
|
||||
<template #actions>
|
||||
<!-- History (Runs) — only meaningful for a published, saved workflow.
|
||||
Drafts have no run history by design, so it's hidden for them. -->
|
||||
<OButton
|
||||
v-if="workflowObj.isEditWorkflow && !workflowObj.currentSelectedWorkflow.isDraft"
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
data-test="workflow-editor-history"
|
||||
@click="openRuns"
|
||||
<!-- Past-run chip — shown when a run is loaded onto the canvas (arrived via
|
||||
"Fix This Step" or picked from History). Compact provenance in place of
|
||||
the old full-width banner: the tooltip carries "edit freely — never
|
||||
changed", and ✕ clears the run data. -->
|
||||
<span
|
||||
v-if="runOverlay"
|
||||
data-test="workflow-editor-run-chip"
|
||||
class="border-border-default text-text-secondary rounded-default inline-flex max-w-[16rem] shrink-0 items-center gap-1 border px-1.5 py-0.5 text-xs"
|
||||
:title="t('workflow.runOverlay.banner', { run: runOverlay.runId })"
|
||||
>
|
||||
{{ t("workflow.history.open") }}
|
||||
</OButton>
|
||||
<OIcon name="history" size="xs" class="shrink-0" />
|
||||
<span class="truncate">{{ t("workflow.ndv.runLabel") }} · {{ runOverlay.runId }}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-test="workflow-editor-run-chip-clear"
|
||||
class="text-text-secondary hover:text-text-body -mr-0.5 flex shrink-0 items-center"
|
||||
:aria-label="t('workflow.runOverlay.clear')"
|
||||
@click="clearRunOverlay"
|
||||
>
|
||||
<OIcon name="close" size="xs" />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<!-- History — published, saved workflows only (drafts have no runs). Opens
|
||||
the runs list as a minified dropdown (same as the NDV: a titled header
|
||||
with the refresh + "last refreshed" time built in), with "Open Full Runs
|
||||
View" for the full inspection surface. Picking a run loads it. -->
|
||||
<WorkflowRunSwitcher
|
||||
v-if="showHistory"
|
||||
:current-run-id="runOverlay?.runId || ''"
|
||||
:org-id="orgId()"
|
||||
:workflow-id="workflowObj.currentSelectedWorkflow.id || ''"
|
||||
@select="switchEditorRun"
|
||||
>
|
||||
<template #trigger>
|
||||
<OButton variant="outline" size="sm-action" data-test="workflow-editor-history">
|
||||
{{ t("workflow.history.open") }}
|
||||
</OButton>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button
|
||||
type="button"
|
||||
data-test="workflow-editor-open-runs-view"
|
||||
class="text-accent flex w-full items-center justify-center text-xs font-medium hover:underline"
|
||||
@click="openRuns"
|
||||
>
|
||||
{{ t("workflow.history.openFullView") }}
|
||||
</button>
|
||||
</template>
|
||||
</WorkflowRunSwitcher>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
@@ -168,15 +207,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
on the lighter `surface-subtle` slab, which is what made the two
|
||||
editors read as different shades. -->
|
||||
<div class="bg-surface-subtle relative min-w-0 flex-1 overflow-hidden dark:bg-transparent">
|
||||
<!-- The results dock (T4) is a LAYOUT WRAPPER around the canvas: with no
|
||||
Test result it renders the canvas full-size; once a run produces a
|
||||
result it splits the space (canvas on top, results panel docked below,
|
||||
draggable divider) so the graph stays visible — instead of the old
|
||||
overlay drawer. The canvas is the dock's slot so the dock can place it
|
||||
in the splitter's top pane. -->
|
||||
<WorkflowResultsDock>
|
||||
<WorkflowCanvas />
|
||||
</WorkflowResultsDock>
|
||||
<!-- Test results are inspected in the node's NDV (click a node's ✓/✗ badge),
|
||||
so the canvas fills the space — no docked results panel. -->
|
||||
<WorkflowCanvas />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -242,11 +275,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { useRouter, onBeforeRouteLeave, type RouteLocationRaw } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OInlineEdit from "@/lib/forms/InlineEdit/OInlineEdit.vue";
|
||||
import OPageHeader from "@/lib/core/PageHeader/OPageHeader.vue";
|
||||
import BetaBadge from "@/components/common/BetaBadge.vue";
|
||||
@@ -254,8 +288,8 @@ import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
|
||||
import WorkflowCanvas from "@/plugins/workflows/WorkflowCanvas.vue";
|
||||
import WorkflowNodeDrawer from "./WorkflowNodeDrawer.vue";
|
||||
import WorkflowRunSwitcher from "./WorkflowRunSwitcher.vue";
|
||||
import WorkflowTestDialog from "./WorkflowTestDialog.vue";
|
||||
import WorkflowResultsDock from "./WorkflowResultsDock.vue";
|
||||
import WorkflowLinkAlertsDialog from "./WorkflowLinkAlertsDialog.vue";
|
||||
import StepPickerDialog from "@/components/flow/StepPickerDialog.vue";
|
||||
import NodePalette from "@/components/flow/NodePalette.vue";
|
||||
@@ -273,6 +307,7 @@ import useWorkflowCanvas, {
|
||||
markWorkflowDirty,
|
||||
reachableFrom,
|
||||
isNodeIncomplete,
|
||||
loadWorkflowRun,
|
||||
} from "@/plugins/workflows/useWorkflowCanvas";
|
||||
import workflowService from "@/services/workflows";
|
||||
|
||||
@@ -295,11 +330,30 @@ const {
|
||||
addNodeAfter,
|
||||
addNodeOnEdge,
|
||||
addTriggerNode,
|
||||
addActionFromStart,
|
||||
closeStepPicker,
|
||||
onDragStart,
|
||||
addNodeToEnd,
|
||||
editNode,
|
||||
} = useWorkflowCanvas(t);
|
||||
|
||||
// ── Run overlay (arriving from Runs history) ────────────────────────────────
|
||||
// A past run is loaded into the SAME `testRun.result` the canvas paints from, so
|
||||
// the editor shows the identical badges and per-node Input/Output — except the
|
||||
// workflow stays editable. `mode: "history"` is stamped only by loadWorkflowRun;
|
||||
// a real Test writes the same key WITHOUT it, so running a test drops the banner
|
||||
// on its own and the two data sources can never be confused.
|
||||
const runOverlay = computed(() =>
|
||||
workflowObj.testRun.result?.mode === "history" ? workflowObj.testRun.result : null,
|
||||
);
|
||||
const clearRunOverlay = () => {
|
||||
workflowObj.testRun.result = null;
|
||||
};
|
||||
// History is only meaningful for a published, saved workflow — drafts have no runs.
|
||||
const showHistory = computed(
|
||||
() => workflowObj.isEditWorkflow && !workflowObj.currentSelectedWorkflow.isDraft,
|
||||
);
|
||||
|
||||
// Docked palette items, grouped into sections (Transform / Destination) to
|
||||
// mirror the pipeline sidebar. The trigger is pre-placed and not addable, so
|
||||
// there's no Source section. Icons reuse the pipeline node images (shared map)
|
||||
@@ -382,6 +436,9 @@ const onStepPick = (item: any) => {
|
||||
// The trigger has nothing before it, so it is placed rather than appended.
|
||||
if (mode === "trigger")
|
||||
addTriggerNode(item.nodeType || "workflow_trigger", item.trigger_kind, position);
|
||||
// Empty-canvas scaffold's Action slot — the workflow's first step (appends after
|
||||
// the trigger when one exists, else placed unconnected at the slot).
|
||||
else if (mode === "action") addActionFromStart(item.key, position);
|
||||
// Insert-between (T7): splice the picked type onto the selected edge.
|
||||
else if (mode === "insert") addNodeOnEdge(edgeId, item.key);
|
||||
else addNodeAfter(source, handle, item.key);
|
||||
@@ -539,9 +596,19 @@ const validate = ({
|
||||
// Publish-only (strict path — Test/allowOrphans and Draft/!requireGraph both
|
||||
// return before here): a still-unfinished placeholder node (e.g. a Destination
|
||||
// saved with no destination) can't be published. Draft + Test allow it.
|
||||
const incomplete = nodes.find((n: any) => isNodeIncomplete(n));
|
||||
if (incomplete) {
|
||||
toast({ message: t("workflow.finishStepsBeforePublish"), variant: "warning" });
|
||||
const incompleteNodes = nodes.filter((n: any) => isNodeIncomplete(n));
|
||||
if (incompleteNodes.length) {
|
||||
// Show the user EXACTLY which steps block publishing: flash a warning ring on each
|
||||
// (the canvas frames them — see WorkflowCanvas) instead of a vague "fill all steps".
|
||||
workflowObj.incompleteHighlight = incompleteNodes.map((n: any) => n.id);
|
||||
toast({
|
||||
message: t(
|
||||
"workflow.finishStepsBeforePublish",
|
||||
{ count: incompleteNodes.length },
|
||||
incompleteNodes.length,
|
||||
),
|
||||
variant: "warning",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -778,11 +845,60 @@ onMounted(async () => {
|
||||
// Edit-from-list already hydrated the shared state synchronously; only
|
||||
// re-fetch on a cold load (deep link / refresh) where it's missing.
|
||||
if (workflowObj.currentSelectedWorkflow?.id !== id) await loadWorkflow(id);
|
||||
// Arrived from a run ("Fix This Step"). Must run AFTER the workflow is
|
||||
// hydrated: loadWorkflowRun diffs the run against the current node list to
|
||||
// find ghost steps, and resetWorkflowData nulls testRun.result.
|
||||
await applyRunOverlay(
|
||||
id,
|
||||
query.run_id as string | undefined,
|
||||
query.node_id as string | undefined,
|
||||
);
|
||||
} else {
|
||||
startNewWorkflow();
|
||||
}
|
||||
});
|
||||
|
||||
// Copy a past run onto the canvas, then open the step the user came to fix.
|
||||
// Read-only projection: nothing here writes back to the stored run.
|
||||
const applyRunOverlay = async (workflowId: string, runId?: string, nodeId?: string) => {
|
||||
if (!runId) return;
|
||||
const r = await loadWorkflowRun({ orgId: orgId(), workflowId, runId });
|
||||
if (!r.ok) {
|
||||
toast({
|
||||
message: raw(r.error) || t("workflow.history.loadRunError"),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Steps this run executed that the workflow no longer has: their badges have
|
||||
// nowhere to render, so the run would look cleaner here than it actually was.
|
||||
const ghosts = runOverlay.value?.ghostNodeIds?.length || 0;
|
||||
if (ghosts) {
|
||||
toast({
|
||||
message: t("workflow.runOverlay.ghostNodes", { count: ghosts }, ghosts),
|
||||
variant: "warning",
|
||||
});
|
||||
}
|
||||
if (!nodeId) return;
|
||||
const exists = (workflowObj.currentSelectedWorkflow.nodes || []).some(
|
||||
(n: any) => n.id === nodeId,
|
||||
);
|
||||
if (exists) editNode(nodeId);
|
||||
else toast({ message: t("workflow.runOverlay.missingNode"), variant: "warning" });
|
||||
};
|
||||
|
||||
// Pick a run from the History dropdown → load it onto the canvas and reflect it in
|
||||
// the URL, so a refresh reloads the same run. No node is opened (unlike arriving via
|
||||
// "Fix This Step"); the user is switching which run's data the editor overlays.
|
||||
const switchEditorRun = async (runId: string) => {
|
||||
const id = workflowObj.currentSelectedWorkflow?.id;
|
||||
if (!id || !runId || runId === (runOverlay.value?.runId || "")) return;
|
||||
await applyRunOverlay(id, runId);
|
||||
router.replace({
|
||||
query: { ...router.currentRoute.value.query, run_id: runId },
|
||||
});
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("beforeunload", beforeUnloadHandler);
|
||||
resetWorkflowData();
|
||||
|
||||
@@ -36,21 +36,39 @@ vi.mock("@/utils/zincutils", () => ({
|
||||
|
||||
vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: vi.fn() }));
|
||||
|
||||
const { triggerSubmit, conditionSubmit, functionSubmit, destinationSubmit, makeBodyStub } =
|
||||
vi.hoisted(() => ({
|
||||
triggerSubmit: vi.fn(),
|
||||
conditionSubmit: vi.fn(),
|
||||
functionSubmit: vi.fn(),
|
||||
destinationSubmit: vi.fn(),
|
||||
makeBodyStub: (name: string, submit: any, extraData: any = {}) => ({
|
||||
default: {
|
||||
name,
|
||||
template: `<div class="body-stub" data-test="${name}" />`,
|
||||
data: () => ({ ...extraData }),
|
||||
methods: { submit },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
const { mockRouter } = vi.hoisted(() => ({ mockRouter: { push: vi.fn() } }));
|
||||
vi.mock("vue-router", () => ({
|
||||
useRouter: () => mockRouter,
|
||||
useRoute: () => ({ query: {} }),
|
||||
onBeforeRouteLeave: () => {},
|
||||
}));
|
||||
|
||||
const {
|
||||
triggerSubmit,
|
||||
conditionSubmit,
|
||||
functionSubmit,
|
||||
destinationSubmit,
|
||||
functionIsDirty,
|
||||
functionDiscard,
|
||||
functionSave,
|
||||
makeBodyStub,
|
||||
} = vi.hoisted(() => ({
|
||||
triggerSubmit: vi.fn(),
|
||||
conditionSubmit: vi.fn(),
|
||||
functionSubmit: vi.fn(),
|
||||
destinationSubmit: vi.fn(),
|
||||
functionIsDirty: vi.fn(() => false),
|
||||
functionDiscard: vi.fn(),
|
||||
functionSave: vi.fn(),
|
||||
makeBodyStub: (name: string, submit: any, extraData: any = {}, extraMethods: any = {}) => ({
|
||||
default: {
|
||||
name,
|
||||
template: `<div class="body-stub" data-test="${name}" />`,
|
||||
data: () => ({ ...extraData }),
|
||||
methods: { submit, ...extraMethods },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/workflows/nodes/WorkflowTrigger.vue", () =>
|
||||
makeBodyStub("WorkflowTrigger", () => triggerSubmit()),
|
||||
@@ -59,7 +77,16 @@ vi.mock("@/plugins/workflows/nodes/WorkflowCondition.vue", () =>
|
||||
makeBodyStub("WorkflowCondition", () => conditionSubmit()),
|
||||
);
|
||||
vi.mock("@/plugins/workflows/nodes/WorkflowFunction.vue", () =>
|
||||
makeBodyStub("WorkflowFunction", () => functionSubmit()),
|
||||
makeBodyStub(
|
||||
"WorkflowFunction",
|
||||
() => functionSubmit(),
|
||||
{},
|
||||
{
|
||||
isDirty: () => functionIsDirty(),
|
||||
discardChanges: () => functionDiscard(),
|
||||
saveChanges: () => functionSave(),
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.mock("@/plugins/workflows/nodes/WorkflowDestination.vue", () =>
|
||||
makeBodyStub("WorkflowDestination", () => destinationSubmit(), {
|
||||
@@ -67,6 +94,16 @@ vi.mock("@/plugins/workflows/nodes/WorkflowDestination.vue", () =>
|
||||
}),
|
||||
);
|
||||
|
||||
// CodeQueryEditor pulls a heavy Monaco/useLogs chain at import — mock it so the NDV's
|
||||
// Input/Output panes render a lightweight stand-in.
|
||||
vi.mock("@/components/CodeQueryEditor.vue", () => ({
|
||||
default: {
|
||||
name: "CodeQueryEditor",
|
||||
props: ["query", "language", "readOnly", "editorId", "showAutoComplete"],
|
||||
template: '<div class="code-editor">{{ query }}</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
@@ -77,13 +114,16 @@ import useWorkflowCanvas, { workflowObj } from "@/plugins/workflows/useWorkflowC
|
||||
|
||||
const t = (k: string, v?: any) => i18n.global.t(k, v ?? {});
|
||||
|
||||
const ODrawerStub = {
|
||||
name: "ODrawer",
|
||||
// The panel container is either ODrawer (create/edit) or ODialog (tested node). Both
|
||||
// stubs share the same button/emit surface so the shared tests don't care which.
|
||||
const makeContainerStub = (name: string, cls: string) => ({
|
||||
name,
|
||||
props: [
|
||||
"open",
|
||||
"title",
|
||||
"width",
|
||||
"size",
|
||||
"maxHeight",
|
||||
"showClose",
|
||||
"primaryButtonLabel",
|
||||
"secondaryButtonLabel",
|
||||
@@ -91,17 +131,23 @@ const ODrawerStub = {
|
||||
],
|
||||
emits: ["update:open", "click:primary", "click:secondary", "click:neutral"],
|
||||
template: `
|
||||
<div class="o-drawer" v-bind="$attrs">
|
||||
<div class="${cls}" v-bind="$attrs">
|
||||
<div class="drawer-title">{{ title }}</div>
|
||||
<div class="header-slot"><slot name="header" /></div>
|
||||
<button class="btn-primary" @click="$emit('click:primary')">{{ primaryButtonLabel }}</button>
|
||||
<button class="btn-secondary" @click="$emit('click:secondary')">{{ secondaryButtonLabel }}</button>
|
||||
<button class="btn-neutral" @click="$emit('click:neutral')">{{ neutralButtonLabel }}</button>
|
||||
<button class="btn-close" @click="$emit('update:open', false)" />
|
||||
<button class="btn-open" @click="$emit('update:open', true)" />
|
||||
<slot />
|
||||
<!-- Rendered LAST so the real footer's markup is assertable without
|
||||
shifting any existing first-match selector. -->
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
});
|
||||
const ODrawerStub = makeContainerStub("ODrawer", "o-drawer");
|
||||
const ODialogStub = makeContainerStub("ODialog", "o-dialog");
|
||||
|
||||
const mountDrawer = () =>
|
||||
mount(WorkflowNodeDrawer, {
|
||||
@@ -109,12 +155,53 @@ const mountDrawer = () =>
|
||||
plugins: [i18n, store],
|
||||
stubs: {
|
||||
ODrawer: ODrawerStub,
|
||||
ODialog: ODialogStub,
|
||||
OIcon: { props: ["name", "size"], template: '<i class="o-icon" :data-name="name" />' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const drawerProps = (wrapper: any) => wrapper.findComponent(ODrawerStub).props() as any;
|
||||
// The delete confirmation is nested INSIDE the NDV (so it stacks above it), and is
|
||||
// the panel's own state rather than the canvas-level deleteConfirm.
|
||||
const confirmDialog = (wrapper: any) => wrapper.findComponent({ name: "ConfirmDialog" });
|
||||
|
||||
// Whichever container is currently rendered (drawer for edit, dialog when tested).
|
||||
const drawerProps = (wrapper: any) => {
|
||||
const dlg = wrapper.findComponent(ODialogStub);
|
||||
return (dlg.exists() ? dlg : wrapper.findComponent(ODrawerStub)).props() as any;
|
||||
};
|
||||
|
||||
// Seed a Test run so the node has input/output data (unlocks the I/O panes).
|
||||
const seedRun = (id: string, input: any[] = [{ a: 1 }]) => {
|
||||
workflowObj.testRun.result = { inputs: { [id]: input }, errors: {} } as any;
|
||||
};
|
||||
|
||||
// A trigger → condition → destination graph for prev/next navigation tests.
|
||||
const seedGraph = () => {
|
||||
workflowObj.currentSelectedWorkflow.nodes = [
|
||||
{
|
||||
id: "trig",
|
||||
type: "input",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { node_type: "workflow_trigger", trigger_kind: "alert_fired" },
|
||||
},
|
||||
{ id: "cond", type: "default", position: { x: 0, y: 0 }, data: { node_type: "condition" } },
|
||||
{ id: "dest", type: "output", position: { x: 0, y: 0 }, data: { node_type: "destination" } },
|
||||
] as any;
|
||||
workflowObj.currentSelectedWorkflow.edges = [
|
||||
{ id: "e1", source: "trig", target: "cond" },
|
||||
{ id: "e2", source: "cond", target: "dest" },
|
||||
] as any;
|
||||
};
|
||||
const openNode = (id: string, withRun = true) => {
|
||||
const node = workflowObj.currentSelectedWorkflow.nodes.find((n: any) => n.id === id);
|
||||
workflowObj.currentSelectedNodeData = node;
|
||||
workflowObj.currentSelectedNodeID = id;
|
||||
workflowObj.isEditNode = true;
|
||||
workflowObj.dialog.name = node.data.node_type;
|
||||
workflowObj.dialog.show = true;
|
||||
if (withRun) seedRun(id);
|
||||
};
|
||||
|
||||
// Stage a node for the drawer to edit / add, exactly as the canvas would.
|
||||
const stageNode = (nodeType: string, opts: { isEdit?: boolean; id?: string; data?: any } = {}) => {
|
||||
@@ -200,138 +287,410 @@ describe("WorkflowNodeDrawer", () => {
|
||||
it("uses the fallback help icon in the placeholder when there is no meta", () => {
|
||||
stageNode("mystery_node");
|
||||
wrapper = mountDrawer();
|
||||
expect(wrapper.find(".o-icon").attributes("data-name")).toBe("help");
|
||||
// Scope to the placeholder: the panel's chrome (Steps collapse, prev/next)
|
||||
// carries icons of its own, so the FIRST icon on screen is not this one.
|
||||
const icon = wrapper.find('[data-test="workflow-ndv-config-placeholder"] .o-icon');
|
||||
expect(icon.attributes("data-name")).toBe("help");
|
||||
});
|
||||
|
||||
it("pads the body when not expanded and drops padding when expanded", async () => {
|
||||
// The known node types own the full three-pane layout and manage their own
|
||||
// insets, so the outer padding applies only to a type with no body form.
|
||||
it("pads only a non-IO body, and drops padding when expanded", async () => {
|
||||
const body = (w: any) => w.find('[data-test="workflow-ndv-body"]');
|
||||
|
||||
stageNode("mystery_node");
|
||||
wrapper = mountDrawer();
|
||||
expect(body(wrapper).classes()).toContain("p-4");
|
||||
wrapper.unmount();
|
||||
|
||||
// The known types own the three-pane layout and manage their own insets.
|
||||
stageNode("function");
|
||||
wrapper = mountDrawer();
|
||||
expect(wrapper.find(".p-4").exists()).toBe(true);
|
||||
expect(body(wrapper).classes()).not.toContain("p-4");
|
||||
|
||||
workflowObj.dialog.expand = true;
|
||||
await nextTick();
|
||||
expect(wrapper.find(".p-4").exists()).toBe(false);
|
||||
expect(wrapper.find(".h-full.min-h-0").exists()).toBe(true);
|
||||
expect(body(wrapper).classes()).not.toContain("p-4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("drawer sizing", () => {
|
||||
it("uses width 45 for condition", () => {
|
||||
// ONE container for every node, run or not: the NDV dialog. The old per-type side
|
||||
// drawer is gone, so a step looks the same whether you are building it or
|
||||
// inspecting a run of it.
|
||||
describe("container: always the NDV dialog", () => {
|
||||
it.each(["condition", "function", "destination", "workflow_trigger"])(
|
||||
"uses the NDV dialog for an untested %s (never a side drawer)",
|
||||
(nodeType) => {
|
||||
stageNode(nodeType, { isEdit: true });
|
||||
wrapper = mountDrawer();
|
||||
expect(wrapper.findComponent(ODialogStub).exists()).toBe(true);
|
||||
expect(wrapper.findComponent(ODrawerStub).exists()).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the same container once the node has run data", () => {
|
||||
const node = stageNode("condition", { isEdit: true });
|
||||
seedRun(node.id);
|
||||
wrapper = mountDrawer();
|
||||
expect(wrapper.findComponent(ODialogStub).exists()).toBe(true);
|
||||
expect(wrapper.findComponent(ODrawerStub).exists()).toBe(false);
|
||||
});
|
||||
|
||||
// Sized so the dialog centers with margin rather than going edge-to-edge.
|
||||
it("is a centered xl dialog at a fixed width, regardless of type or run", () => {
|
||||
const node = stageNode("function", { isEdit: true });
|
||||
seedRun(node.id);
|
||||
wrapper = mountDrawer();
|
||||
expect(drawerProps(wrapper).size).toBe("xl");
|
||||
expect(drawerProps(wrapper).width).toBe(95);
|
||||
});
|
||||
});
|
||||
|
||||
// Input · Config · Output, always — the panel keeps one shape whether or not the
|
||||
// node has run, so walking prev/next never changes the layout under the user.
|
||||
describe("three-region layout", () => {
|
||||
it("shows all three panes before a run (empty, not absent)", () => {
|
||||
stageNode("condition");
|
||||
wrapper = mountDrawer();
|
||||
expect(drawerProps(wrapper).width).toBe(45);
|
||||
expect(drawerProps(wrapper).size).toBe("md");
|
||||
expect(wrapper.find('[data-test="workflow-ndv-config"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="workflow-ndv-input"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="workflow-ndv-output"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("uses width 30 for function", () => {
|
||||
stageNode("function");
|
||||
it("shows Input · Config · Output once the node has run data", () => {
|
||||
// Needs a real upstream step: the Input pane renders records it RECEIVED,
|
||||
// so a lone node shows the empty state rather than an editor.
|
||||
seedGraph();
|
||||
openNode("cond");
|
||||
wrapper = mountDrawer();
|
||||
expect(drawerProps(wrapper).width).toBe(30);
|
||||
expect(wrapper.find('[data-test="workflow-ndv-input"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="workflow-ndv-config"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="workflow-ndv-output"]').exists()).toBe(true);
|
||||
// real input data is rendered (not a placeholder)
|
||||
expect(wrapper.find('[data-test="workflow-ndv-input"] .code-editor').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("uses no width and size lg for destination", () => {
|
||||
stageNode("destination");
|
||||
// Run Step feeds a per-node test input, so in editor mode the Input pane is ALWAYS
|
||||
// an editable editor (seeded with the trigger sample) — even for a deep node that
|
||||
// has never run — with a caption marking it as intentional test input.
|
||||
it("editor mode shows an editable, seeded test input for a deep node with no run", () => {
|
||||
seedGraph();
|
||||
openNode("dest", false); // deep node (parent is cond, not the trigger), no run
|
||||
wrapper = mountDrawer();
|
||||
expect(drawerProps(wrapper).width).toBeUndefined();
|
||||
expect(drawerProps(wrapper).size).toBe("lg");
|
||||
const input = wrapper.find('[data-test="workflow-ndv-input"]');
|
||||
expect(input.find(".code-editor").exists()).toBe(true); // editor, not empty state
|
||||
expect(wrapper.find('[data-test="workflow-ndv-input-empty"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-test="workflow-ndv-input-hint"]').exists()).toBe(true);
|
||||
// seeded with the trigger's sample event (the base test input), not a bare "[]"
|
||||
expect(input.find(".code-editor").text()).not.toBe("[]");
|
||||
});
|
||||
|
||||
it("uses no width and size md for the trigger", () => {
|
||||
stageNode("workflow_trigger");
|
||||
// The trigger is the one exception: it has no input (its output IS the event),
|
||||
// so Config expands into that space and only Config · Output show.
|
||||
it("omits the Input pane for the trigger, keeping Config and Output", () => {
|
||||
const node = stageNode("workflow_trigger", { isEdit: true });
|
||||
seedRun(node.id);
|
||||
wrapper = mountDrawer();
|
||||
expect(drawerProps(wrapper).width).toBeUndefined();
|
||||
expect(drawerProps(wrapper).size).toBe("md");
|
||||
expect(wrapper.find('[data-test="workflow-ndv-config"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="workflow-ndv-output"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="workflow-ndv-input"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("expands to full width (97) regardless of node type", async () => {
|
||||
stageNode("condition");
|
||||
it("collapses to Config only while the inline create editor is expanded", async () => {
|
||||
const node = stageNode("function", { isEdit: true });
|
||||
seedRun(node.id);
|
||||
wrapper = mountDrawer();
|
||||
expect(wrapper.find('[data-test="workflow-ndv-input"]').exists()).toBe(true);
|
||||
|
||||
workflowObj.dialog.expand = true;
|
||||
await nextTick();
|
||||
expect(drawerProps(wrapper).width).toBe(97);
|
||||
expect(wrapper.find('[data-test="workflow-ndv-input"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-test="workflow-ndv-output"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-test="workflow-ndv-config"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// The footer is a real slot now (not container label props): Delete on the left,
|
||||
// Prev/Next + Run Step on the right. Config still commits on CLOSE, so there is
|
||||
// no Save/Cancel anywhere.
|
||||
describe("footer buttons", () => {
|
||||
it("shows Save/Cancel for an editable body", () => {
|
||||
stageNode("condition");
|
||||
const deleteBtn = (w: any) => w.find('[data-test="workflow-node-delete"]');
|
||||
|
||||
it("never renders a Save or Cancel button", () => {
|
||||
stageNode("condition", { isEdit: true });
|
||||
wrapper = mountDrawer();
|
||||
expect(drawerProps(wrapper).primaryButtonLabel).toBe("Save");
|
||||
expect(drawerProps(wrapper).secondaryButtonLabel).toBe("Cancel");
|
||||
expect(drawerProps(wrapper).primaryButtonLabel).toBeUndefined();
|
||||
expect(drawerProps(wrapper).secondaryButtonLabel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides Save/Cancel/Delete while the drawer is expanded", async () => {
|
||||
it("offers Delete for a non-trigger node (add or edit)", () => {
|
||||
stageNode("condition", { isEdit: true });
|
||||
wrapper = mountDrawer();
|
||||
expect(deleteBtn(wrapper).exists()).toBe(true);
|
||||
expect(deleteBtn(wrapper).text()).toBe(t("workflow.deleteNode"));
|
||||
});
|
||||
|
||||
// The function's full-width inline editor owns the panel and carries its own
|
||||
// controls, so the whole footer stands down.
|
||||
it("hides the footer while the inline editor is expanded", async () => {
|
||||
stageNode("function", { isEdit: true });
|
||||
wrapper = mountDrawer();
|
||||
workflowObj.dialog.expand = true;
|
||||
await nextTick();
|
||||
const p = drawerProps(wrapper);
|
||||
expect(p.primaryButtonLabel).toBeUndefined();
|
||||
expect(p.secondaryButtonLabel).toBeUndefined();
|
||||
expect(p.neutralButtonLabel).toBeUndefined();
|
||||
expect(deleteBtn(wrapper).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("hides Save/Cancel for the read-only trigger body", () => {
|
||||
it("disables Delete for the read-only trigger", () => {
|
||||
stageNode("workflow_trigger", { isEdit: true });
|
||||
wrapper = mountDrawer();
|
||||
const p = drawerProps(wrapper);
|
||||
expect(p.primaryButtonLabel).toBeUndefined();
|
||||
expect(p.secondaryButtonLabel).toBeUndefined();
|
||||
expect(deleteBtn(wrapper).attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("never offers Delete for the trigger", () => {
|
||||
stageNode("workflow_trigger", { isEdit: true });
|
||||
wrapper = mountDrawer();
|
||||
expect(drawerProps(wrapper).neutralButtonLabel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("offers Delete only when editing an existing non-trigger node", () => {
|
||||
stageNode("condition", { isEdit: true });
|
||||
wrapper = mountDrawer();
|
||||
expect(drawerProps(wrapper).neutralButtonLabel).toBe(t("workflow.deleteNode"));
|
||||
});
|
||||
|
||||
it("does not offer Delete while adding a new node", () => {
|
||||
stageNode("condition");
|
||||
wrapper = mountDrawer();
|
||||
expect(drawerProps(wrapper).neutralButtonLabel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides the footer while the destination body is creating a new destination", async () => {
|
||||
// The inline "Create New Destination" form lives inside the Config pane, and an
|
||||
// unfinished one simply saves a dummy node — so the footer stays intact rather
|
||||
// than trapping the user in a form with no way out.
|
||||
it("keeps Delete available while the destination body creates a new destination", async () => {
|
||||
stageNode("destination", { isEdit: true });
|
||||
wrapper = mountDrawer();
|
||||
expect(drawerProps(wrapper).primaryButtonLabel).toBe("Save");
|
||||
expect(deleteBtn(wrapper).attributes("disabled")).toBeUndefined();
|
||||
|
||||
wrapper.findComponent({ name: "WorkflowDestination" }).vm.createNewDestination = true;
|
||||
await nextTick();
|
||||
|
||||
const p = drawerProps(wrapper);
|
||||
expect(p.primaryButtonLabel).toBeUndefined();
|
||||
expect(p.secondaryButtonLabel).toBeUndefined();
|
||||
expect(p.neutralButtonLabel).toBeUndefined();
|
||||
expect(deleteBtn(wrapper).exists()).toBe(true);
|
||||
expect(deleteBtn(wrapper).attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("save", () => {
|
||||
it("commits the payload returned by the body form (add flow)", async () => {
|
||||
const node = stageNode("condition");
|
||||
workflowObj.pendingEdge = { source: "src-1", sourceHandle: undefined };
|
||||
// Prev/Next are footer buttons walking the same tree order as the Steps rail —
|
||||
// they replaced the floating per-edge cards, so one control handles fan-out too.
|
||||
describe("prev/next navigation", () => {
|
||||
const prev = (w: any) => w.find('[data-test="workflow-ndv-prev-step"]');
|
||||
const next = (w: any) => w.find('[data-test="workflow-ndv-next-step"]');
|
||||
|
||||
it("offers prev and next for a node with neighbours", () => {
|
||||
seedGraph();
|
||||
openNode("cond");
|
||||
wrapper = mountDrawer();
|
||||
expect(prev(wrapper).attributes("disabled")).toBeUndefined();
|
||||
expect(next(wrapper).attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("disables prev on the first step", () => {
|
||||
seedGraph();
|
||||
openNode("trig");
|
||||
wrapper = mountDrawer();
|
||||
expect(prev(wrapper).attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("disables next on the last step", () => {
|
||||
seedGraph();
|
||||
openNode("dest");
|
||||
wrapper = mountDrawer();
|
||||
expect(next(wrapper).attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("commits the current node's config before navigating (no Save button)", async () => {
|
||||
seedGraph();
|
||||
openNode("cond");
|
||||
conditionSubmit.mockReturnValue({ version: 2, conditions: [{ column: "a" }] });
|
||||
wrapper = mountDrawer();
|
||||
await next(wrapper).trigger("click");
|
||||
await flushPromises();
|
||||
expect(workflowObj.currentSelectedNodeID).toBe("dest"); // navigated
|
||||
// Navigating is a commit point: the body's submit() ran and its payload was
|
||||
// merged into the (now left-behind) node, so the selection isn't dropped.
|
||||
expect(conditionSubmit).toHaveBeenCalled();
|
||||
const cond = workflowObj.currentSelectedWorkflow.nodes.find((n: any) => n.id === "cond");
|
||||
expect(cond.data.conditions).toEqual([{ column: "a" }]);
|
||||
});
|
||||
|
||||
// Layout must not change under the user when they land on a step with no data.
|
||||
it("keeps the panes when navigating to a node that did not run", async () => {
|
||||
seedGraph();
|
||||
openNode("cond"); // a run exists; `dest` received no input
|
||||
wrapper = mountDrawer();
|
||||
await next(wrapper).trigger("click");
|
||||
await flushPromises();
|
||||
expect(workflowObj.currentSelectedNodeID).toBe("dest");
|
||||
expect(wrapper.find('[data-test="workflow-ndv-input"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="workflow-ndv-output"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// A Function node's editor can hold inline/edited JS not yet saved to the library
|
||||
// (raw_fn). Leaving — Prev/Next or close — must prompt Save / Discard / Keep editing
|
||||
// (never auto-save, never silently drop edits).
|
||||
describe("unsaved-function exit guard (raw_fn)", () => {
|
||||
const seedFnGraph = () => {
|
||||
workflowObj.currentSelectedWorkflow.nodes = [
|
||||
{ id: "src-1", data: { node_type: "workflow_trigger" } },
|
||||
];
|
||||
conditionSubmit.mockReturnValue({ conditions: [{ column: "a" }] });
|
||||
{
|
||||
id: "trig",
|
||||
type: "input",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { node_type: "workflow_trigger", trigger_kind: "alert_fired" },
|
||||
},
|
||||
{ id: "fn", type: "default", position: { x: 0, y: 0 }, data: { node_type: "function" } },
|
||||
{
|
||||
id: "dest",
|
||||
type: "output",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { node_type: "destination" },
|
||||
},
|
||||
] as any;
|
||||
workflowObj.currentSelectedWorkflow.edges = [
|
||||
{ id: "e1", source: "trig", target: "fn" },
|
||||
{ id: "e2", source: "fn", target: "dest" },
|
||||
] as any;
|
||||
};
|
||||
const unsaved = (w: any) =>
|
||||
w
|
||||
.findAllComponents(ODialogStub)
|
||||
.find((d: any) => d.props("title") === t("workflow.node.unsavedFnTitle"));
|
||||
const promptOpen = (w: any) => !!unsaved(w)?.props("open");
|
||||
const next = (w: any) => w.find('[data-test="workflow-ndv-next-step"]');
|
||||
|
||||
beforeEach(() => functionIsDirty.mockReturnValue(false));
|
||||
|
||||
it("prompts instead of navigating when the editor is dirty", async () => {
|
||||
seedFnGraph();
|
||||
openNode("fn");
|
||||
functionIsDirty.mockReturnValue(true);
|
||||
wrapper = mountDrawer();
|
||||
await next(wrapper).trigger("click");
|
||||
await flushPromises();
|
||||
expect(promptOpen(wrapper)).toBe(true);
|
||||
expect(workflowObj.currentSelectedNodeID).toBe("fn"); // did NOT navigate
|
||||
});
|
||||
|
||||
it("navigates directly when the editor is not dirty (no prompt)", async () => {
|
||||
seedFnGraph();
|
||||
openNode("fn");
|
||||
wrapper = mountDrawer();
|
||||
await next(wrapper).trigger("click");
|
||||
await flushPromises();
|
||||
expect(promptOpen(wrapper)).toBe(false);
|
||||
expect(workflowObj.currentSelectedNodeID).toBe("dest");
|
||||
});
|
||||
|
||||
it("Discard proceeds with the navigation (and reverts the editor)", async () => {
|
||||
seedFnGraph();
|
||||
openNode("fn");
|
||||
functionIsDirty.mockReturnValue(true);
|
||||
wrapper = mountDrawer();
|
||||
await next(wrapper).trigger("click");
|
||||
await unsaved(wrapper).find(".btn-secondary").trigger("click"); // Discard
|
||||
await flushPromises();
|
||||
expect(functionDiscard).toHaveBeenCalled();
|
||||
expect(workflowObj.currentSelectedNodeID).toBe("dest");
|
||||
expect(promptOpen(wrapper)).toBe(false);
|
||||
});
|
||||
|
||||
it("Save opens the save flow and stays on the node", async () => {
|
||||
seedFnGraph();
|
||||
openNode("fn");
|
||||
functionIsDirty.mockReturnValue(true);
|
||||
wrapper = mountDrawer();
|
||||
await next(wrapper).trigger("click");
|
||||
await unsaved(wrapper).find(".btn-primary").trigger("click"); // Save
|
||||
await flushPromises();
|
||||
expect(functionSave).toHaveBeenCalled();
|
||||
expect(workflowObj.currentSelectedNodeID).toBe("fn"); // stayed
|
||||
expect(promptOpen(wrapper)).toBe(false);
|
||||
});
|
||||
|
||||
it("Keep editing closes the prompt and stays", async () => {
|
||||
seedFnGraph();
|
||||
openNode("fn");
|
||||
functionIsDirty.mockReturnValue(true);
|
||||
wrapper = mountDrawer();
|
||||
await next(wrapper).trigger("click");
|
||||
await unsaved(wrapper).find(".btn-neutral").trigger("click"); // Keep editing
|
||||
await flushPromises();
|
||||
expect(workflowObj.currentSelectedNodeID).toBe("fn");
|
||||
expect(promptOpen(wrapper)).toBe(false);
|
||||
expect(functionDiscard).not.toHaveBeenCalled();
|
||||
expect(functionSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Function nodes hide reka's X + block outside/escape (persistent); the custom
|
||||
// header X routes through the guard so there's no optimistic close (no flicker).
|
||||
it("uses a custom close X + persistent dialog for function nodes", () => {
|
||||
seedFnGraph();
|
||||
openNode("fn");
|
||||
wrapper = mountDrawer();
|
||||
expect(wrapper.find('[data-test="workflow-ndv-close"]').exists()).toBe(true);
|
||||
expect(drawerProps(wrapper).showClose).toBe(false); // reka X hidden
|
||||
});
|
||||
|
||||
it("guards the custom close X when dirty (panel stays open)", async () => {
|
||||
seedFnGraph();
|
||||
openNode("fn");
|
||||
functionIsDirty.mockReturnValue(true);
|
||||
wrapper = mountDrawer();
|
||||
await wrapper.find('[data-test="workflow-ndv-close"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(promptOpen(wrapper)).toBe(true);
|
||||
expect(workflowObj.dialog.show).toBe(true); // not closed
|
||||
});
|
||||
|
||||
it("Discard from a guarded close actually closes the panel", async () => {
|
||||
seedFnGraph();
|
||||
openNode("fn");
|
||||
functionIsDirty.mockReturnValue(true);
|
||||
wrapper = mountDrawer();
|
||||
await wrapper.find('[data-test="workflow-ndv-close"]').trigger("click");
|
||||
await unsaved(wrapper).find(".btn-secondary").trigger("click"); // Discard
|
||||
await flushPromises();
|
||||
expect(workflowObj.dialog.show).toBe(false); // now closed
|
||||
});
|
||||
|
||||
it("closes directly via the custom X when NOT dirty (no prompt)", async () => {
|
||||
seedFnGraph();
|
||||
openNode("fn");
|
||||
wrapper = mountDrawer();
|
||||
await wrapper.find('[data-test="workflow-ndv-close"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(promptOpen(wrapper)).toBe(false);
|
||||
expect(workflowObj.dialog.show).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Re-running a step is now "Run Step" in the footer (it executes against the
|
||||
// Input pane's records), replacing the old Replay button.
|
||||
describe("run step (NDV footer)", () => {
|
||||
const runBtn = (w: any) => w.find('[data-test="workflow-node-execute"]');
|
||||
|
||||
it("offers Run Step for a node in the editor", () => {
|
||||
seedGraph();
|
||||
openNode("cond");
|
||||
wrapper = mountDrawer();
|
||||
expect(runBtn(wrapper).exists()).toBe(true);
|
||||
expect(runBtn(wrapper).text()).toBe(t("workflow.ndv.executeStep"));
|
||||
});
|
||||
|
||||
it("offers Run Step even before the node has run", () => {
|
||||
stageNode("condition", { isEdit: true });
|
||||
wrapper = mountDrawer();
|
||||
expect(runBtn(wrapper).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// The node is already on the canvas (insert-immediately happens in the composable
|
||||
// add flow, not here). Closing the drawer merges the body's payload into it.
|
||||
describe("commit on close", () => {
|
||||
it("merges the body payload into the node on close", async () => {
|
||||
stageNode("condition", { isEdit: true });
|
||||
conditionSubmit.mockReturnValue({ version: 2, conditions: [{ column: "a" }] });
|
||||
|
||||
wrapper = mountDrawer();
|
||||
await wrapper.find(".btn-primary").trigger("click");
|
||||
await wrapper.find(".btn-close").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(conditionSubmit).toHaveBeenCalled();
|
||||
const nodes = workflowObj.currentSelectedWorkflow.nodes;
|
||||
expect(nodes).toHaveLength(2);
|
||||
expect(nodes[1].id).toBe(node.id);
|
||||
expect(nodes[1].data.conditions).toEqual([{ column: "a" }]);
|
||||
// auto-wired from the pending edge
|
||||
expect(workflowObj.currentSelectedWorkflow.edges).toHaveLength(1);
|
||||
expect(workflowObj.currentSelectedWorkflow.nodes[0].data.conditions).toEqual([
|
||||
{ column: "a" },
|
||||
]);
|
||||
expect(workflowObj.dialog.show).toBe(false);
|
||||
});
|
||||
|
||||
@@ -340,72 +699,64 @@ describe("WorkflowNodeDrawer", () => {
|
||||
destinationSubmit.mockResolvedValue({ destination_id: "sink-a" });
|
||||
|
||||
wrapper = mountDrawer();
|
||||
await wrapper.find(".btn-primary").trigger("click");
|
||||
await wrapper.find(".btn-close").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(workflowObj.currentSelectedWorkflow.nodes[0].data.destination_id).toBe("sink-a");
|
||||
expect(workflowObj.dialog.show).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks the save when the body form returns null (invalid)", async () => {
|
||||
it("closes without merging when the body returns null (inline create still open)", async () => {
|
||||
stageNode("condition", { isEdit: true });
|
||||
conditionSubmit.mockReturnValue(null);
|
||||
|
||||
wrapper = mountDrawer();
|
||||
await wrapper.find(".btn-primary").trigger("click");
|
||||
await wrapper.find(".btn-close").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(workflowObj.dialog.show).toBe(true);
|
||||
expect(workflowObj.dialog.show).toBe(false);
|
||||
expect(workflowObj.currentSelectedWorkflow.nodes[0].data.conditions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks the save when the body form returns undefined", async () => {
|
||||
stageNode("function", { isEdit: true });
|
||||
functionSubmit.mockReturnValue(undefined);
|
||||
|
||||
wrapper = mountDrawer();
|
||||
await wrapper.find(".btn-primary").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(workflowObj.dialog.show).toBe(true);
|
||||
});
|
||||
|
||||
it("commits an empty payload for a placeholder (form-less) node type", async () => {
|
||||
it("closes without a body submit for a form-less placeholder node type", async () => {
|
||||
stageNode("mystery_node", { isEdit: true });
|
||||
wrapper = mountDrawer();
|
||||
|
||||
await wrapper.find(".btn-primary").trigger("click");
|
||||
await wrapper.find(".btn-close").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(workflowObj.dialog.show).toBe(false);
|
||||
expect(workflowObj.currentSelectedWorkflow.nodes[0].data.node_type).toBe("mystery_node");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel / close", () => {
|
||||
it("discards the staged node on Cancel", async () => {
|
||||
stageNode("condition");
|
||||
it("does NOT dirty the graph when opened and closed with no change", async () => {
|
||||
// Saved data already matches what the body re-asserts on close (same shape it
|
||||
// was persisted with), so the change-gated commit is a no-op.
|
||||
stageNode("condition", { isEdit: true, data: { version: 2, conditions: [{ column: "a" }] } });
|
||||
conditionSubmit.mockReturnValue({ version: 2, conditions: [{ column: "a" }] });
|
||||
workflowObj.dirtyFlag = false;
|
||||
|
||||
wrapper = mountDrawer();
|
||||
|
||||
await wrapper.find(".btn-secondary").trigger("click");
|
||||
await wrapper.find(".btn-close").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(workflowObj.dialog.show).toBe(false);
|
||||
expect(workflowObj.currentSelectedNodeData).toBeNull();
|
||||
expect(workflowObj.currentSelectedWorkflow.nodes).toHaveLength(0);
|
||||
expect(workflowObj.dirtyFlag).toBe(false);
|
||||
});
|
||||
|
||||
it("closes the drawer when ODrawer emits update:open=false", async () => {
|
||||
stageNode("condition");
|
||||
it("keeps the node on the canvas after close (no discard)", async () => {
|
||||
stageNode("condition", { isEdit: true });
|
||||
wrapper = mountDrawer();
|
||||
|
||||
await wrapper.find(".btn-close").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(workflowObj.dialog.show).toBe(false);
|
||||
expect(workflowObj.currentSelectedWorkflow.nodes).toHaveLength(1);
|
||||
expect(workflowObj.currentSelectedNodeID).toBe("");
|
||||
});
|
||||
|
||||
it("ignores update:open=true", async () => {
|
||||
stageNode("condition");
|
||||
stageNode("condition", { isEdit: true });
|
||||
wrapper = mountDrawer();
|
||||
|
||||
await wrapper.find(".btn-open").trigger("click");
|
||||
@@ -420,12 +771,9 @@ describe("WorkflowNodeDrawer", () => {
|
||||
stageNode("condition", { isEdit: true, id: "n-del" });
|
||||
wrapper = mountDrawer();
|
||||
|
||||
await wrapper.find(".btn-neutral").trigger("click");
|
||||
await wrapper.find('[data-test="workflow-node-delete"]').trigger("click");
|
||||
|
||||
expect(workflowObj.deleteConfirm).toEqual({
|
||||
show: true,
|
||||
nodeId: "n-del",
|
||||
});
|
||||
expect(confirmDialog(wrapper).props("modelValue")).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing when no node is selected", async () => {
|
||||
@@ -434,9 +782,102 @@ describe("WorkflowNodeDrawer", () => {
|
||||
workflowObj.currentSelectedNodeData = null;
|
||||
await nextTick();
|
||||
|
||||
await wrapper.find(".btn-neutral").trigger("click");
|
||||
await wrapper.find('[data-test="workflow-node-delete"]').trigger("click");
|
||||
|
||||
expect(workflowObj.deleteConfirm.show).toBe(false);
|
||||
expect(confirmDialog(wrapper).props("modelValue")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── "Edit This Step" (run history → editor) ───────────────────────────────
|
||||
// Inspecting a run is read-only, so every verb is disabled and the user is left
|
||||
// with nothing to do. This is the one safe action: hand the run id and the node
|
||||
// id to the editor. The run itself is only read.
|
||||
describe("edit this step", () => {
|
||||
const stageHistoryRun = (runId = "run-7") => {
|
||||
workflowObj.readOnly = true;
|
||||
workflowObj.currentSelectedWorkflow.id = "wf-1";
|
||||
workflowObj.testRun.result = {
|
||||
errors: {},
|
||||
inputs: {},
|
||||
ranNodeIds: [],
|
||||
blockedNodeIds: [],
|
||||
mode: "history",
|
||||
runId,
|
||||
} as any;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
workflowObj.readOnly = false;
|
||||
workflowObj.testRun.result = null;
|
||||
});
|
||||
|
||||
it("offers the action while inspecting a run", async () => {
|
||||
stageHistoryRun();
|
||||
stageNode("function", { isEdit: true, id: "fn-1" });
|
||||
wrapper = mountDrawer();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-test="workflow-node-edit-step"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("is absent in a normal editing session", async () => {
|
||||
stageNode("function", { isEdit: true, id: "fn-1" });
|
||||
wrapper = mountDrawer();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-test="workflow-node-edit-step"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
// A test run writes the same result key WITHOUT `mode`, so it must not be
|
||||
// mistaken for history.
|
||||
it("is absent when the result came from a test rather than history", async () => {
|
||||
workflowObj.readOnly = true;
|
||||
workflowObj.testRun.result = {
|
||||
errors: {},
|
||||
inputs: {},
|
||||
ranNodeIds: [],
|
||||
blockedNodeIds: [],
|
||||
} as any;
|
||||
stageNode("function", { isEdit: true, id: "fn-1" });
|
||||
wrapper = mountDrawer();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-test="workflow-node-edit-step"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
// Run Step is gated on !canvasReadOnly, so on a historical run it could never
|
||||
// be enabled — it is removed rather than shown permanently dead.
|
||||
it("removes Run Step while inspecting a run", async () => {
|
||||
stageHistoryRun();
|
||||
stageNode("function", { isEdit: true, id: "fn-1" });
|
||||
wrapper = mountDrawer();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-test="workflow-node-execute"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("offers the action on a PASSED step too, not just a failed one", async () => {
|
||||
stageHistoryRun();
|
||||
workflowObj.testRun.result.errors = {}; // nothing errored
|
||||
stageNode("condition", { isEdit: true, id: "cond-1" });
|
||||
wrapper = mountDrawer();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-test="workflow-node-edit-step"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("carries the run AND the node to the editor", async () => {
|
||||
stageHistoryRun();
|
||||
stageNode("function", { isEdit: true, id: "fn-1" });
|
||||
wrapper = mountDrawer();
|
||||
await nextTick();
|
||||
|
||||
await wrapper.find('[data-test="workflow-node-edit-step"]').trigger("click");
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith({
|
||||
name: "workflowEditor",
|
||||
query: expect.objectContaining({ id: "wf-1", run_id: "run-7", node_id: "fn-1" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,119 +0,0 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!--
|
||||
Execution-results DOCK (T4). Hosts the canvas (default slot) with the shared
|
||||
WorkflowResultsPanel docked BELOW it (canvas on top, panel on the bottom, split
|
||||
by a draggable vertical OSplitter) so the graph stays visible while inspecting a
|
||||
run — unlike the old overlay drawer. The dock height is remembered and the panel
|
||||
can be collapsed to just its header strip. It only appears once a Test run has
|
||||
produced a result; otherwise the canvas fills the whole area.
|
||||
-->
|
||||
<template>
|
||||
<!-- No run yet → canvas fills the area (dock hidden). -->
|
||||
<div v-if="!hasResult" class="h-full min-h-0 w-full">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Docked (canvas top / panel bottom). -->
|
||||
<OSplitter
|
||||
v-else-if="!collapsed"
|
||||
horizontal
|
||||
unit="%"
|
||||
:model-value="canvasPct"
|
||||
:limits="[30, 85]"
|
||||
separator-class="h-1! bg-border-default hover:bg-accent transition-colors"
|
||||
class="h-full min-h-0 w-full"
|
||||
@update:model-value="onResize"
|
||||
>
|
||||
<template #before>
|
||||
<div class="h-full w-full"><slot /></div>
|
||||
</template>
|
||||
<template #after>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<WorkflowResultsDockHeader
|
||||
:collapsed="collapsed"
|
||||
@toggle-collapse="toggleCollapse"
|
||||
@close="closeResults"
|
||||
/>
|
||||
<div class="bg-surface-base min-h-0 flex-1"><WorkflowResultsPanel /></div>
|
||||
</div>
|
||||
</template>
|
||||
</OSplitter>
|
||||
|
||||
<!-- Collapsed → canvas fills, panel reduced to its header strip at the bottom. -->
|
||||
<div v-else class="flex h-full min-h-0 w-full flex-col">
|
||||
<div class="min-h-0 min-w-0 flex-1"><slot /></div>
|
||||
<WorkflowResultsDockHeader :collapsed="collapsed" @toggle-collapse="toggleCollapse" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import OSplitter from "@/lib/core/Splitter/OSplitter.vue";
|
||||
import WorkflowResultsPanel from "./WorkflowResultsPanel.vue";
|
||||
import WorkflowResultsDockHeader from "./WorkflowResultsDockHeader.vue";
|
||||
import { workflowObj } from "@/plugins/workflows/useWorkflowCanvas";
|
||||
|
||||
// Persist the canvas share (so a resize survives reloads).
|
||||
const LS_HEIGHT = "workflows:resultsDockHeight"; // canvas height %
|
||||
|
||||
const readNum = (key: string, fallback: number): number => {
|
||||
const raw = Number(localStorage.getItem(key));
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
||||
};
|
||||
|
||||
const collapsed = ref(false);
|
||||
const canvasPct = ref(readNum(LS_HEIGHT, 55));
|
||||
|
||||
const hasResult = computed(() => !!workflowObj.testRun.result);
|
||||
|
||||
// A fresh Test run (or Replay) produces a new result — always OPEN the dock
|
||||
// expanded so the user sees the step results, even if it was collapsed before.
|
||||
watch(
|
||||
() => workflowObj.testRun.result,
|
||||
(result) => {
|
||||
if (result) collapsed.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
// Clicking a node's ✓/✗ badge re-assigns resultDrawer to a fresh object. Watch the
|
||||
// object REFERENCE (not `.show`, which is often already true) so every badge click
|
||||
// fires — and expand the dock if it's collapsed, so the click reveals the result.
|
||||
watch(
|
||||
() => workflowObj.testRun.resultDrawer,
|
||||
(rd) => {
|
||||
if (rd?.show) collapsed.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
const toggleCollapse = () => {
|
||||
collapsed.value = !collapsed.value;
|
||||
};
|
||||
// Clear the test result (hides the dock via hasResult) and dismiss the per-node
|
||||
// selection — also clears the ✓/✗ badges + active highlight on the canvas. Reset
|
||||
// collapse so the next run opens expanded.
|
||||
const closeResults = () => {
|
||||
workflowObj.testRun.result = null;
|
||||
workflowObj.testRun.resultDrawer = { show: false, nodeId: "" };
|
||||
collapsed.value = false;
|
||||
};
|
||||
const onResize = (v: number) => {
|
||||
canvasPct.value = v;
|
||||
localStorage.setItem(LS_HEIGHT, String(v));
|
||||
};
|
||||
</script>
|
||||
@@ -1,65 +0,0 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!--
|
||||
Header bar for the docked results panel (T4). Title + collapse chevron. Purely
|
||||
presentational — emits intents; the dock owns the state.
|
||||
-->
|
||||
<template>
|
||||
<div
|
||||
class="border-border-default bg-surface-base flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5"
|
||||
>
|
||||
<span class="text-text-body truncate text-sm font-semibold">
|
||||
{{ t("workflow.results.title") }}
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<!-- Collapse to just this header strip, or expand back to the full panel. -->
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-test="workflows-results-collapse"
|
||||
@click="emit('toggle-collapse')"
|
||||
>
|
||||
<OIcon :name="collapsed ? 'expand-less' : 'expand-more'" size="sm" />
|
||||
<OTooltip
|
||||
:content="collapsed ? t('workflow.results.expand') : t('workflow.results.collapse')"
|
||||
side="top"
|
||||
/>
|
||||
</OButton>
|
||||
<!-- Clear the test result and dismiss the dock (also clears canvas badges). -->
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-test="workflows-results-close"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<OIcon name="close" size="sm" />
|
||||
<OTooltip :content="t('workflow.results.close')" side="top" />
|
||||
</OButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
|
||||
defineProps<{ collapsed: boolean }>();
|
||||
const emit = defineEmits<{ (e: "toggle-collapse"): void; (e: "close"): void }>();
|
||||
const { t } = useI18nTyped();
|
||||
</script>
|
||||
@@ -1,276 +0,0 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!--
|
||||
Docked results panel CONTENT (T4) — the SAME body rendered by both dock layouts
|
||||
(bottom + right). An executed-node list on the left (click a step to inspect it,
|
||||
keeping the canvas in view) and the shared WorkflowStepResultContent on the
|
||||
right (Input / Output / Replay). Selection is the shared
|
||||
testRun.resultDrawer.nodeId, so a canvas badge click and a list row stay in sync.
|
||||
-->
|
||||
<template>
|
||||
<div class="flex h-full min-h-0" data-test="workflows-results-panel">
|
||||
<!-- Executed-step list. Hidden in the narrow right (vertical) dock, where the
|
||||
width is better spent on Input/Output — steps are switched by clicking a
|
||||
node on the canvas instead (the active node is highlighted there). -->
|
||||
<div v-if="showStepList" class="border-border-default flex w-56 shrink-0 flex-col border-r">
|
||||
<div
|
||||
class="text-text-secondary border-border-default shrink-0 border-b px-3 py-2 text-xs font-semibold"
|
||||
>
|
||||
{{ t("workflow.results.nodesTitle") }}
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto py-1">
|
||||
<!-- Step tree — replicates the traces waterfall "Operation Name" column:
|
||||
one fixed-width guide column per depth level, drawn with absolutely-
|
||||
positioned border-left (vertical │) / border-top (elbow └/├) segments,
|
||||
plus a circled child-count badge (leaf steps show a status dot). Rows
|
||||
are a fixed height so the vertical rails join seamlessly across rows. -->
|
||||
<button
|
||||
v-for="row in nodeRows"
|
||||
:key="row.id"
|
||||
type="button"
|
||||
:data-test="`workflows-results-node-${row.id}`"
|
||||
class="flex h-9 w-full items-center pr-3 pl-2 text-left text-sm"
|
||||
:class="
|
||||
row.id === selectedId
|
||||
? 'bg-select-item-hover-bg text-text-body'
|
||||
: 'text-text-secondary hover:bg-surface-subtle'
|
||||
"
|
||||
@click="selectNode(row.id)"
|
||||
>
|
||||
<!-- Guide columns (one per depth level). Each column is w-5, the SAME
|
||||
width as the badge slot below, so a child's elbow rail (in column
|
||||
`depth`) sits exactly under its parent's badge (slot `depth`). -->
|
||||
<span
|
||||
v-for="i in row.depth"
|
||||
:key="i"
|
||||
class="relative h-full w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- Ancestor rail: full-height vertical only where the subtree continues.
|
||||
Elbow column (the node's own level): vertical from the top to the
|
||||
mid-point, extended full-height when this step has a following
|
||||
sibling, plus a horizontal stub reaching toward the badge. -->
|
||||
<span
|
||||
v-if="i < row.depth ? row.guides[i - 1] : true"
|
||||
class="border-border-default absolute top-0 left-1/2 border-l"
|
||||
:class="i < row.depth ? 'bottom-0' : row.guides[i - 1] ? 'h-full' : 'h-1/2'"
|
||||
/>
|
||||
<span
|
||||
v-if="i === row.depth"
|
||||
class="border-border-default absolute top-1/2 right-0 left-1/2 border-t"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<!-- Badge (child count) / leaf dot — centred in a w-5 slot on the same
|
||||
grid as the guide columns, so rails line up under it. -->
|
||||
<span class="flex h-full w-5 shrink-0 items-center justify-center">
|
||||
<span
|
||||
v-if="row.hasChildren"
|
||||
class="bg-surface-base flex h-5 w-5 items-center justify-center rounded-full border text-xs leading-none font-semibold"
|
||||
:class="statusRingClass(row.status)"
|
||||
:data-test="`workflows-results-node-${row.id}-count`"
|
||||
>
|
||||
{{ row.childCount }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="statusDotClass(row.status)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<!-- Type icon + label. A stale step (deleted / disabled after the run) is
|
||||
struck-through and muted. -->
|
||||
<span
|
||||
class="flex min-w-0 items-center gap-1.5 pl-1.5"
|
||||
:class="{ 'opacity-60': row.stale }"
|
||||
>
|
||||
<OIcon :name="row.icon" size="xs" class="shrink-0" />
|
||||
<span class="min-w-0 truncate" :class="{ 'line-through': row.stale }">
|
||||
{{ row.label }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected step Input / Output / Replay -->
|
||||
<div class="min-h-0 min-w-0 flex-1 p-3">
|
||||
<WorkflowStepResultContent v-if="selectedId" :node-id="selectedId" />
|
||||
<div
|
||||
v-else
|
||||
class="text-text-secondary flex h-full items-center justify-center p-8 text-center text-sm italic"
|
||||
>
|
||||
{{ t("workflow.results.empty") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import WorkflowStepResultContent from "./WorkflowStepResultContent.vue";
|
||||
|
||||
import {
|
||||
workflowObj,
|
||||
nodeMeta,
|
||||
nodeConfigDetail,
|
||||
nodeCustomName,
|
||||
triggerDef,
|
||||
flowOrderedNodeIds,
|
||||
buildStepTree,
|
||||
isNodeDisabled,
|
||||
} from "@/plugins/workflows/useWorkflowCanvas";
|
||||
|
||||
// The right (vertical) dock hides the step list to reclaim width; the bottom dock
|
||||
// keeps it. Defaults to shown.
|
||||
withDefaults(defineProps<{ showStepList?: boolean }>(), { showStepList: true });
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
|
||||
const result = computed<any>(() => workflowObj.testRun.result);
|
||||
const nodes = computed<any[]>(() => workflowObj.currentSelectedWorkflow?.nodes || []);
|
||||
|
||||
// The executed nodes for this run, in flow order (trigger → down). Nodes that
|
||||
// didn't run (unwired / skipped from the run set) aren't listed.
|
||||
const ranIds = computed<string[]>(() => {
|
||||
const ran: string[] = result.value?.ranNodeIds || [];
|
||||
const set = new Set(ran);
|
||||
return flowOrderedNodeIds(nodes.value, workflowObj.currentSelectedWorkflow?.edges || []).filter(
|
||||
(id) => set.has(id),
|
||||
);
|
||||
});
|
||||
|
||||
const nodeStatus = (id: string): "ok" | "error" | "skipped" => {
|
||||
const r = result.value;
|
||||
if (r?.errors?.[id]) return "error";
|
||||
if (r?.inputs) return r.inputs[id]?.length ? "ok" : "skipped";
|
||||
if (r?.blockedNodeIds?.includes(id)) return "skipped";
|
||||
return "ok";
|
||||
};
|
||||
|
||||
const nodeLabel = (node: any): string => {
|
||||
const custom = nodeCustomName(node);
|
||||
if (custom) return custom;
|
||||
const type = node?.data?.node_type;
|
||||
if (type === "workflow_trigger") return t(triggerDef(node?.data?.trigger_kind).nodeTitleKey);
|
||||
const meta = nodeMeta(type);
|
||||
return nodeConfigDetail(node?.data, 28) || (meta ? t(meta.titleKey) : type || "");
|
||||
};
|
||||
const nodeIcon = (node: any): string => {
|
||||
const meta = nodeMeta(node?.data?.node_type);
|
||||
return meta?.image ? `img:${meta.image}` : meta?.icon || "help";
|
||||
};
|
||||
|
||||
interface StepRow {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
status: "ok" | "error" | "skipped";
|
||||
depth: number;
|
||||
childCount: number;
|
||||
hasChildren: boolean;
|
||||
guides: boolean[];
|
||||
// The step no longer matches the live graph — it was DELETED or DISABLED after
|
||||
// the run. Rendered struck-through but still inspectable.
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
const nodeById = (id: string) => nodes.value.find((n: any) => n.id === id);
|
||||
|
||||
// Turn a tree node (frozen snapshot or freshly built) into a render row. `live` is
|
||||
// the node's current record in the graph (null if deleted); label/icon prefer it
|
||||
// so a rename after the run is reflected, falling back to the frozen data.
|
||||
const toStepRow = (s: any, live: any, frozen: any): StepRow => {
|
||||
const node = live || frozen;
|
||||
return {
|
||||
id: s.id,
|
||||
label: nodeLabel(node),
|
||||
icon: nodeIcon(node),
|
||||
status: nodeStatus(s.id),
|
||||
depth: s.depth,
|
||||
childCount: s.childCount,
|
||||
hasChildren: s.childCount > 0,
|
||||
guides: s.guides,
|
||||
stale: !live || isNodeDisabled(live),
|
||||
};
|
||||
};
|
||||
|
||||
// The executed steps as a tree (traces-waterfall layout). A live Test run renders
|
||||
// from the run-time SNAPSHOT (result.ranSteps) so a step later deleted/disabled
|
||||
// stays listed struck-through; a history run (no snapshot) rebuilds the tree from
|
||||
// the live graph.
|
||||
const nodeRows = computed<StepRow[]>(() => {
|
||||
const steps = result.value?.ranSteps;
|
||||
if (Array.isArray(steps)) {
|
||||
return steps.map((s: any) =>
|
||||
toStepRow(s, nodeById(s.id), { id: s.id, data: s.data, meta: s.meta }),
|
||||
);
|
||||
}
|
||||
const tree = buildStepTree(
|
||||
nodes.value,
|
||||
workflowObj.currentSelectedWorkflow?.edges || [],
|
||||
ranIds.value,
|
||||
);
|
||||
return tree.map((s) => {
|
||||
const live = nodeById(s.id);
|
||||
return toStepRow(s, live, live);
|
||||
});
|
||||
});
|
||||
|
||||
const statusDotClass = (status: string) =>
|
||||
status === "error"
|
||||
? "bg-status-negative"
|
||||
: status === "ok"
|
||||
? "bg-status-positive"
|
||||
: "bg-badge-default-solid-bg";
|
||||
|
||||
// Circle badge (child count) ring + text colour, keyed to the step's run status.
|
||||
const statusRingClass = (status: string) =>
|
||||
status === "error"
|
||||
? "border-status-negative text-status-negative"
|
||||
: status === "ok"
|
||||
? "border-status-positive text-status-positive"
|
||||
: "border-border-strong text-text-secondary";
|
||||
|
||||
// Ids of the currently rendered rows — includes removed/disabled steps (which
|
||||
// aren't in `ranIds`), so a struck-through step stays selectable in the log.
|
||||
const rowIds = computed<string[]>(() => nodeRows.value.map((r) => r.id));
|
||||
|
||||
// Selection = shared resultDrawer.nodeId so a canvas badge and a list row stay in
|
||||
// sync. Falls back to the first executed step so the panel is never blank.
|
||||
const selectedId = computed(() => {
|
||||
const current = workflowObj.testRun.resultDrawer.nodeId;
|
||||
if (current && rowIds.value.includes(current)) return current;
|
||||
return rowIds.value[0] || "";
|
||||
});
|
||||
const selectNode = (id: string) => {
|
||||
workflowObj.testRun.resultDrawer = { show: true, nodeId: id };
|
||||
};
|
||||
|
||||
// Keep the shared selection pointed at a valid step when a new run arrives.
|
||||
watch(rowIds, (ids) => {
|
||||
const current = workflowObj.testRun.resultDrawer.nodeId;
|
||||
if ((!current || !ids.includes(current)) && ids.length) {
|
||||
workflowObj.testRun.resultDrawer = { show: true, nodeId: ids[0] };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,171 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!--
|
||||
Shared run-history picker — a dropdown of a workflow's past runs, used both in
|
||||
the NDV header (read-only Runs view) and the editor toolbar. The trigger is
|
||||
supplied by the parent (an icon button in the NDV, a "History" button in the
|
||||
editor) via the #trigger slot; picking a run emits `select` so the parent loads
|
||||
it onto the canvas.
|
||||
|
||||
The runs list is SHARED state (workflowObj.runsHistory), fetched once by the
|
||||
Runs page and reused here — no re-fetch on open unless the list is empty (an
|
||||
editor that never visited the Runs page). A titled header carries the
|
||||
ORefreshButton (its own "last refreshed" + staleness dot) to pull the latest.
|
||||
On open it auto-scrolls to the currently-loaded run so a long list lands on it.
|
||||
-->
|
||||
<template>
|
||||
<ODropdown v-model:open="open" align="start" side="bottom">
|
||||
<template #trigger>
|
||||
<slot name="trigger" />
|
||||
</template>
|
||||
<div class="w-72">
|
||||
<!-- Titled, with a refresh (its own dot + "last refreshed" relative time). -->
|
||||
<div
|
||||
class="border-border-default flex items-center justify-between gap-2 border-b px-2 py-1.5"
|
||||
>
|
||||
<span class="text-text-body truncate text-xs font-semibold">
|
||||
{{ t("workflow.history.title") }}
|
||||
</span>
|
||||
<ORefreshButton
|
||||
:last-run-at="workflowObj.runsHistory.fetchedAt || null"
|
||||
:loading="runsLoading"
|
||||
data-test="workflow-run-switcher-refresh"
|
||||
@click="refresh"
|
||||
/>
|
||||
</div>
|
||||
<div ref="scrollEl" class="max-h-80 overflow-auto py-1">
|
||||
<ODropdownItem
|
||||
v-for="run in sortedRuns"
|
||||
:key="run.run_id"
|
||||
:data-test="`workflow-run-switcher-run-${run.run_id}`"
|
||||
:data-run-id="run.run_id"
|
||||
@click="onSelect(run.run_id)"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<OIcon
|
||||
v-if="run.run_id === currentRunId"
|
||||
name="check"
|
||||
size="xs"
|
||||
class="text-accent shrink-0"
|
||||
/>
|
||||
<span v-else class="w-3.5 shrink-0" aria-hidden="true"></span>
|
||||
<OTimeCell
|
||||
:value="run.start_time"
|
||||
unit="us"
|
||||
mode="absolute"
|
||||
:timezone="store.state.timezone"
|
||||
:empty-label="raw('—')"
|
||||
/>
|
||||
</span>
|
||||
<OBadge :variant="run.error ? 'error-soft' : 'success-soft'" size="xs">
|
||||
{{ run.error ? t("workflow.history.failed") : t("workflow.history.success") }}
|
||||
</OBadge>
|
||||
</div>
|
||||
<!-- Hover a failed run to read WHY it failed without leaving the menu. -->
|
||||
<OTooltip
|
||||
v-if="run.error"
|
||||
:content="raw(String(run.error))"
|
||||
side="left"
|
||||
max-width="22rem"
|
||||
/>
|
||||
</ODropdownItem>
|
||||
<div
|
||||
v-if="!sortedRuns.length"
|
||||
data-test="workflow-run-switcher-empty"
|
||||
class="text-text-secondary px-3 py-4 text-center text-xs italic"
|
||||
>
|
||||
{{ t("workflow.ndv.noRunsList") }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Optional footer (e.g. the editor's "Open Full Runs View" link). -->
|
||||
<div v-if="$slots.footer" class="border-border-default border-t px-2 py-1.5">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</ODropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
|
||||
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OBadge from "@/lib/core/Badge/OBadge.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
|
||||
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
|
||||
import { workflowObj, loadRunsHistory } from "@/plugins/workflows/useWorkflowCanvas";
|
||||
|
||||
const props = defineProps<{
|
||||
// The run currently loaded on the canvas — check-marked and auto-scrolled to.
|
||||
currentRunId: string;
|
||||
orgId: string;
|
||||
workflowId: string;
|
||||
}>();
|
||||
const emit = defineEmits<{ (e: "select", runId: string): void }>();
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
|
||||
const open = ref(false);
|
||||
const runsLoading = computed(() => workflowObj.runsHistory.loading);
|
||||
// ALL runs (newest first) — the menu scrolls; bounded only by history retention.
|
||||
const sortedRuns = computed(() =>
|
||||
[...workflowObj.runsHistory.list].sort(
|
||||
(a: any, b: any) => (b.start_time || 0) - (a.start_time || 0),
|
||||
),
|
||||
);
|
||||
|
||||
const fetchRuns = async () => {
|
||||
if (!props.workflowId || runsLoading.value) return;
|
||||
// Re-pull the window the Runs page fetched for; fall back to a wide 10y window
|
||||
// when this view was deep-linked / opened without the list ever being fetched.
|
||||
const { start, end } = workflowObj.runsHistory.params;
|
||||
const nowUs = Date.now() * 1000;
|
||||
await loadRunsHistory({
|
||||
orgId: props.orgId,
|
||||
workflowId: props.workflowId,
|
||||
start: start || nowUs - 10 * 365 * 24 * 60 * 60 * 1_000_000,
|
||||
end: end || nowUs,
|
||||
});
|
||||
};
|
||||
const refresh = () => fetchRuns();
|
||||
|
||||
const onSelect = (runId: string) => {
|
||||
open.value = false;
|
||||
if (!runId || runId === props.currentRunId) return;
|
||||
emit("select", runId);
|
||||
};
|
||||
|
||||
// On open: fetch the list if we don't have one yet (an editor that never visited
|
||||
// the Runs page), then scroll to the CURRENT run so a long list lands on it.
|
||||
const scrollEl = ref<HTMLElement | null>(null);
|
||||
watch(open, async (isOpen) => {
|
||||
if (!isOpen) return;
|
||||
if (!workflowObj.runsHistory.list.length) await fetchRuns();
|
||||
if (!props.currentRunId) return;
|
||||
await nextTick();
|
||||
const box = scrollEl.value;
|
||||
const row = box?.querySelector<HTMLElement>(`[data-run-id="${CSS.escape(props.currentRunId)}"]`);
|
||||
if (!box || !row) return;
|
||||
box.scrollTop += row.getBoundingClientRect().top - box.getBoundingClientRect().top;
|
||||
box.scrollTop -= (box.clientHeight - row.clientHeight) / 2; // center it
|
||||
});
|
||||
</script>
|
||||
@@ -27,6 +27,8 @@ const { mockRouter, mockToast, mockHydrate, mockLoadRun, mockReset, workflowObj,
|
||||
readOnly: false,
|
||||
currentSelectedWorkflow: { id: "", name: "", nodes: [], edges: [] },
|
||||
testRun: { resultDrawer: { show: false, nodeId: "" }, result: null },
|
||||
// The NDV now renders inside this view, so the template reads dialog.show.
|
||||
dialog: { show: false, name: "", expand: false },
|
||||
};
|
||||
return {
|
||||
mockRouter: {
|
||||
@@ -205,6 +207,51 @@ describe("WorkflowRuns", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Debug In Editor ────────────────────────────────────────────────────────
|
||||
// The pair to Edit Workflow: same destination, but the selected run travels
|
||||
// with it so the editor repaints the whole run and stays editable. Whole-graph
|
||||
// counterpart to the NDV's per-step "Fix This Step".
|
||||
|
||||
it("hides Debug In Editor until a run is selected", async () => {
|
||||
const wrapper = await mountRuns();
|
||||
expect(wrapper.find('[data-test="workflow-runs-debug"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("offers Debug In Editor once a run is selected", async () => {
|
||||
const wrapper = await mountRuns();
|
||||
panel(wrapper).vm.$emit("select-run", "run-5");
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="workflow-runs-debug"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("Debug In Editor carries the selected run to the editor", async () => {
|
||||
const wrapper = await mountRuns();
|
||||
panel(wrapper).vm.$emit("select-run", "run-5");
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-test="workflow-runs-debug"]').trigger("click");
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith({
|
||||
name: "workflowEditor",
|
||||
query: { id: "wf-1", name: "my flow", org_identifier: "default", run_id: "run-5" },
|
||||
});
|
||||
});
|
||||
|
||||
// The two verbs must stay distinct: "go build" must not silently arrive with a
|
||||
// past run's badges painted on the canvas.
|
||||
it("Edit Workflow still leaves the run behind even when one is selected", async () => {
|
||||
const wrapper = await mountRuns();
|
||||
panel(wrapper).vm.$emit("select-run", "run-5");
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-test="workflow-runs-edit"]').trigger("click");
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith({
|
||||
name: "workflowEditor",
|
||||
query: { id: "wf-1", name: "my flow", org_identifier: "default" },
|
||||
});
|
||||
});
|
||||
|
||||
it("Test opens the dry-run dialog here and deselects the historical run", async () => {
|
||||
const wrapper = await mountRuns();
|
||||
// Select a run first, so we can prove Test deselects it.
|
||||
|
||||
@@ -60,17 +60,28 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OButton variant="outline" data-test="workflow-runs-edit" @click="onEditWorkflow">
|
||||
{{ t("workflow.runs.edit") }}
|
||||
</OButton>
|
||||
<!-- Only meaningful once a run is selected — it is the run that gets
|
||||
carried over. Without one there is nothing to debug, so it stays
|
||||
hidden rather than rendering as a dead control. -->
|
||||
<OButton
|
||||
v-if="selectedRunId"
|
||||
variant="primary"
|
||||
data-test="workflow-runs-debug"
|
||||
@click="onDebugInEditor"
|
||||
>
|
||||
{{ t("workflow.runs.debugInEditor") }}
|
||||
</OButton>
|
||||
</template>
|
||||
</OPageHeader>
|
||||
|
||||
<div class="flex min-h-0 flex-1 gap-2 px-2 pt-3">
|
||||
<!-- Read-only canvas (per-node run status overlay). The SAME results dock as
|
||||
the editor docks the step Input/Output below the canvas once a run is
|
||||
loaded (read-only here) — instead of the old overlay drawer. -->
|
||||
<div class="rounded-surface bg-surface-subtle relative mb-3 min-w-0 flex-1 overflow-hidden">
|
||||
<WorkflowResultsDock>
|
||||
<WorkflowCanvas />
|
||||
</WorkflowResultsDock>
|
||||
<!-- Read-only canvas (per-node run status overlay). Clicking a node's ✓/✗ badge
|
||||
opens its NDV (read-only here) with the step's Input · Config · Output — the
|
||||
SAME panel the editor uses, so results read identically in both places. -->
|
||||
<div
|
||||
class="rounded-surface bg-surface-subtle relative mb-3 min-w-0 flex-1 overflow-hidden dark:bg-transparent"
|
||||
>
|
||||
<WorkflowCanvas />
|
||||
</div>
|
||||
|
||||
<!-- Persistent runs list (master-detail). -->
|
||||
@@ -87,9 +98,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node NDV (read-only here) — opened by a node's ✓/✗ badge to inspect that
|
||||
step's Input · Config · Output for the loaded run. -->
|
||||
<WorkflowNodeDrawer v-if="workflowObj.dialog.show" />
|
||||
|
||||
<!-- Test input popup — a fresh dry-run of the current graph, launched from the
|
||||
header. Results paint on the read-only canvas (switching it out of the
|
||||
selected historical run) and open in the results dock above. -->
|
||||
selected historical run) and the node NDV shows each step's I/O. -->
|
||||
<WorkflowTestDialog v-if="workflowObj.testRun.show" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -106,7 +121,7 @@ import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
|
||||
import WorkflowCanvas from "@/plugins/workflows/WorkflowCanvas.vue";
|
||||
import WorkflowResultsDock from "./WorkflowResultsDock.vue";
|
||||
import WorkflowNodeDrawer from "./WorkflowNodeDrawer.vue";
|
||||
import WorkflowTestDialog from "./WorkflowTestDialog.vue";
|
||||
import WorkflowRunsPanel from "./WorkflowRunsPanel.vue";
|
||||
import useWorkflowCanvas, {
|
||||
@@ -160,6 +175,8 @@ watch(
|
||||
);
|
||||
|
||||
// Deliberate switch to the editor — the only bridge between inspect and build.
|
||||
// Deliberately WITHOUT the run: this is "go build", and arriving with a past
|
||||
// run's badges still painted would be surprising.
|
||||
const onEditWorkflow = () => {
|
||||
router.push({
|
||||
name: "workflowEditor",
|
||||
@@ -171,6 +188,22 @@ const onEditWorkflow = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// The other half of that pair: "go fix THIS run". Same destination, but the run
|
||||
// travels along, so the editor reloads it onto the canvas — identical badges and
|
||||
// per-node data, now editable. The run itself is only ever read; fixing edits the
|
||||
// definition, and re-testing produces a new test run.
|
||||
const onDebugInEditor = () => {
|
||||
router.push({
|
||||
name: "workflowEditor",
|
||||
query: {
|
||||
id: workflowId.value,
|
||||
name: workflowName.value,
|
||||
org_identifier: orgId.value,
|
||||
run_id: selectedRunId.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Cold-load hydrate (deep link / refresh): the list hydrates synchronously, so
|
||||
// only re-fetch when the shared state doesn't already hold this workflow.
|
||||
const loadWorkflow = async (id: string) => {
|
||||
|
||||
@@ -84,11 +84,12 @@ const DateTimeStub = {
|
||||
},
|
||||
};
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mount, flushPromises, enableAutoUnmount } from "@vue/test-utils";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import WorkflowRunsPanel from "./WorkflowRunsPanel.vue";
|
||||
import { workflowObj } from "@/plugins/workflows/useWorkflowCanvas";
|
||||
|
||||
const globalCfg = {
|
||||
plugins: [i18n, store],
|
||||
@@ -116,9 +117,20 @@ const mountPanel = (props: Record<string, any> = {}) =>
|
||||
});
|
||||
|
||||
describe("WorkflowRunsPanel", () => {
|
||||
// The runs list is SHARED state now, so a leaked wrapper from a prior test would
|
||||
// keep reacting to it and skew fetch counts — auto-unmount after each test.
|
||||
enableAutoUnmount(afterEach);
|
||||
beforeEach(() => {
|
||||
mockList.mockClear();
|
||||
mockToast.mockClear();
|
||||
// Reset the shared list so a prior test's fetch doesn't leak into the next
|
||||
// (each mount re-fetches and repopulates it).
|
||||
workflowObj.runsHistory = {
|
||||
list: [],
|
||||
fetchedAt: 0,
|
||||
params: { start: 0, end: 0 },
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
|
||||
it("renders the runs table", async () => {
|
||||
|
||||
@@ -164,7 +164,7 @@ import WorkflowExecutionTimeline from "@/components/alerts/AlertHistoryTimeline.
|
||||
import NoData from "@/components/shared/grid/NoData.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import workflowService from "@/services/workflows";
|
||||
import { workflowObj, loadRunsHistory } from "@/plugins/workflows/useWorkflowCanvas";
|
||||
|
||||
const props = defineProps<{
|
||||
orgId: string;
|
||||
@@ -180,11 +180,12 @@ const emit = defineEmits<{
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
|
||||
const loading = ref(false);
|
||||
// Loading + the runs list live in SHARED state (workflowObj.runsHistory) so the
|
||||
// NDV run switcher reuses the same fetch instead of re-hitting /history.
|
||||
const loading = computed(() => workflowObj.runsHistory.loading);
|
||||
// Distinguishes "the fetch failed" from "there are no runs" — the table's empty
|
||||
// slot renders a retryable error state for the former.
|
||||
const loadError = ref(false);
|
||||
const runs = ref<any[]>([]);
|
||||
|
||||
// Default range: last 24 hours (user can widen it via the picker). Same shape as
|
||||
// AlertHistory — startTime/endTime are microseconds.
|
||||
@@ -235,7 +236,7 @@ const getStatusVariant = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const rows = computed(() => runs.value);
|
||||
const rows = computed(() => workflowObj.runsHistory.list);
|
||||
|
||||
// Highlight the run currently loaded on the canvas. `!` (important) so the tint
|
||||
// wins over OTable's default/hover row background — keeping the list and canvas
|
||||
@@ -245,7 +246,7 @@ const rowClass = (row: any) =>
|
||||
|
||||
// Feed the shared timeline: one bar per run, coloured by success/error.
|
||||
const timelineHistory = computed(() =>
|
||||
runs.value.map((r: any) => ({
|
||||
workflowObj.runsHistory.list.map((r: any) => ({
|
||||
status: r.error ? "error" : "success",
|
||||
timestamp: r.start_time,
|
||||
})),
|
||||
@@ -303,26 +304,21 @@ const columns = computed<OTableColumnDef[]>(() => [
|
||||
|
||||
const fetchHistory = async () => {
|
||||
if (!props.workflowId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await workflowService.getWorkflowHistory({
|
||||
org_identifier: props.orgId,
|
||||
id: props.workflowId,
|
||||
start_time: dateTimeValues.value.startTime,
|
||||
end_time: dateTimeValues.value.endTime,
|
||||
});
|
||||
runs.value = Array.isArray(res.data) ? res.data : [];
|
||||
const res = await loadRunsHistory({
|
||||
orgId: props.orgId,
|
||||
workflowId: props.workflowId,
|
||||
start: dateTimeValues.value.startTime,
|
||||
end: dateTimeValues.value.endTime,
|
||||
});
|
||||
if (res.ok) {
|
||||
loadError.value = false;
|
||||
} catch (e: any) {
|
||||
// 403 is "no permission", not a failure to load — keep the plain empty
|
||||
// state for it rather than offering a retry that cannot succeed.
|
||||
loadError.value = e?.response?.status !== 403;
|
||||
if (loadError.value) {
|
||||
toast({ variant: "error", message: t("workflow.history.loadError") });
|
||||
}
|
||||
runs.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
// 403 is "no permission", not a failure to load — keep the plain empty state
|
||||
// for it rather than offering a retry that cannot succeed.
|
||||
loadError.value = res.status !== 403;
|
||||
if (loadError.value) {
|
||||
toast({ variant: "error", message: t("workflow.history.loadError") });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,486 +0,0 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!--
|
||||
Per-step Test result CONTENT — the Input / Output panes, status, copy,
|
||||
fullscreen, "Use as Test Input" and Replay for a single node. Shared by the
|
||||
results dock (WorkflowResultsPanel) in BOTH the editor and the read-only Runs
|
||||
view, so they render identical content from one source. The node is passed in
|
||||
via `nodeId`; everything else reads the shared testRun state (read-only when the
|
||||
run is historical — see `isHistory`).
|
||||
|
||||
Data: the test response carries a per-node `inputs` map. Input = the records
|
||||
THIS node received (nodeTestInput). Output = what it emitted, derived per
|
||||
outgoing edge as the downstream node's input (nodeTestOutputBranches) — the graph
|
||||
is a single-incoming tree, so a child's input IS this node's output on that edge.
|
||||
-->
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col gap-3">
|
||||
<!-- Input | Output, side by side (fullscreenable as one unit).
|
||||
flex-1 + min-h-0 so it fills the body down to the action row.
|
||||
The run status badge sits inline after the "Output" heading (below)
|
||||
rather than on its own line, to save vertical space in the dock. -->
|
||||
<div
|
||||
ref="ioContainerRef"
|
||||
data-test="workflow-step-io-container"
|
||||
class="flex min-h-0 flex-1 gap-2"
|
||||
:class="{
|
||||
'bg-surface-subtle h-screen max-h-screen items-stretch p-3': isFullscreen,
|
||||
}"
|
||||
>
|
||||
<!-- Input -->
|
||||
<div class="flex h-full w-1/2 min-w-0 flex-col">
|
||||
<div class="text-text-body mb-2 flex items-center justify-between text-sm font-bold">
|
||||
<div>{{ t("workflow.test.stepResult.input") }}</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<OTooltip :content="useInputTooltip" :delay="300" side="top">
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:disabled="!inputRecordsForTest"
|
||||
data-test="workflow-step-use-input-as-test"
|
||||
@click="useAsTestInput(inputRecordsForTest)"
|
||||
>
|
||||
<OIcon name="play-circle" size="xs" />
|
||||
</OButton>
|
||||
</OTooltip>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:title="fullscreenTitle"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<OIcon :name="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" size="xs" />
|
||||
</OButton>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:title="t('workflow.test.stepResult.copyInput')"
|
||||
:disabled="!inputModel"
|
||||
@click="copy(inputModel, 'input')"
|
||||
>
|
||||
<OIcon name="content-copy" size="xs" />
|
||||
</OButton>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Test mode: input is EDITABLE and Replay re-runs from this step with
|
||||
it. History mode: read-only per-node input captured for the run. -->
|
||||
<div
|
||||
class="border-border-default rounded-default bg-code-bg min-h-0 flex-1 overflow-hidden border"
|
||||
>
|
||||
<CodeQueryEditor
|
||||
v-if="receivedRecords"
|
||||
editor-id="workflow-step-input"
|
||||
language="json"
|
||||
:query="inputModel"
|
||||
:read-only="isHistory"
|
||||
:show-auto-complete="false"
|
||||
@update:query="inputModel = $event"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
data-test="workflow-step-result-no-input"
|
||||
class="text-text-secondary flex h-full items-center justify-center p-8 text-center text-sm italic"
|
||||
>
|
||||
{{ t("workflow.test.stepResult.noInput") }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isHistory && receivedRecords && inputInvalid"
|
||||
data-test="workflow-step-result-input-error"
|
||||
class="text-input-error-text mt-1 text-xs leading-snug"
|
||||
>
|
||||
{{ t("workflow.test.invalidJson") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div class="flex h-full w-1/2 min-w-0 flex-col">
|
||||
<div class="text-text-body mb-2 flex items-center justify-between text-sm font-bold">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ t("workflow.test.stepResult.output") }}</span>
|
||||
<!-- Run status (Passed / Errored / No Records) — inline after the
|
||||
heading, same source as the node's canvas badge. -->
|
||||
<OBadge :variant="statusVariant" size="sm" data-test="workflow-step-result-status">
|
||||
{{ t(`workflow.test.stepResult.status.${stepStatus}`) }}
|
||||
</OBadge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<OTooltip :content="useOutputTooltip" :delay="300" side="top">
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:disabled="!outputRecordsForTest"
|
||||
data-test="workflow-step-use-output-as-test"
|
||||
@click="useAsTestInput(outputRecordsForTest)"
|
||||
>
|
||||
<OIcon name="play-circle" size="xs" />
|
||||
</OButton>
|
||||
</OTooltip>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:title="fullscreenTitle"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<OIcon :name="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" size="xs" />
|
||||
</OButton>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:title="t('workflow.test.stepResult.copyOutput')"
|
||||
:disabled="!copyableOutput"
|
||||
@click="copy(copyableOutput, 'output')"
|
||||
>
|
||||
<OIcon name="content-copy" size="xs" />
|
||||
</OButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-border-default rounded-default bg-code-bg flex min-h-0 flex-1 flex-col overflow-hidden border"
|
||||
>
|
||||
<div
|
||||
v-if="outputIsEmpty"
|
||||
data-test="workflow-step-result-output-empty"
|
||||
class="text-text-secondary flex h-full items-center justify-center p-8 text-center text-sm italic"
|
||||
>
|
||||
{{ outputEmptyMessage }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="showError"
|
||||
data-test="workflow-step-result-error-section"
|
||||
class="flex shrink-0 flex-col gap-1.5 overflow-auto p-2.5"
|
||||
:class="{ 'flex-1': !hasForwardedRecords }"
|
||||
>
|
||||
<div v-if="showSectionHeadings" class="text-status-error-text text-xs font-semibold">
|
||||
{{ t("workflow.test.stepResult.errorHeading") }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(m, i) in errorMessages"
|
||||
:key="i"
|
||||
data-test="workflow-step-result-error-line"
|
||||
class="text-status-error-text text-xs leading-snug whitespace-pre-wrap"
|
||||
>
|
||||
{{ m }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="hasForwardedRecords">
|
||||
<div
|
||||
v-if="showSectionHeadings"
|
||||
class="text-text-secondary shrink-0 px-2.5 pt-2 text-xs font-semibold"
|
||||
>
|
||||
{{ t("workflow.test.stepResult.forwardedHeading") }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(label, i) in showBranchLabels ? outputBranchLabels : []"
|
||||
:key="i"
|
||||
data-test="workflow-step-result-output-branch-label"
|
||||
class="text-text-secondary flex shrink-0 items-center gap-1 px-2.5 pt-2 text-xs font-medium"
|
||||
>
|
||||
<OIcon name="arrow-right" size="xs" />
|
||||
<span class="min-w-0 truncate">{{ label }}</span>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1" data-test="workflow-step-result-output-records">
|
||||
<CodeQueryEditor
|
||||
editor-id="workflow-step-output"
|
||||
language="json"
|
||||
:query="outputRecordsText || ''"
|
||||
:read-only="true"
|
||||
:show-auto-complete="false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replay only makes sense for a live Test run — a past run is read-only. -->
|
||||
<div v-if="!isHistory" class="flex shrink-0 items-center justify-end">
|
||||
<OTooltip :content="t('workflow.test.stepResult.replayHint')" :delay="300" side="top">
|
||||
<OButton
|
||||
variant="primary"
|
||||
size="sm-action"
|
||||
data-test="workflow-step-replay-btn"
|
||||
:disabled="!canReplay || replaying"
|
||||
:loading="replaying"
|
||||
@click="replay"
|
||||
>
|
||||
{{ t("workflow.test.stepResult.replay") }}
|
||||
</OButton>
|
||||
</OTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useI18nTyped, raw } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OBadge from "@/lib/core/Badge/OBadge.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import CodeQueryEditor from "@/components/CodeQueryEditor.vue";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import { toggleFullscreen as domToggleFullScreen } from "@/utils/dom";
|
||||
|
||||
import {
|
||||
workflowObj,
|
||||
nodeMeta,
|
||||
executeTestRun,
|
||||
nodeTestInput,
|
||||
nodeTestOutputBranches,
|
||||
} from "@/plugins/workflows/useWorkflowCanvas";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
|
||||
// The node whose result to show — supplied by the host (drawer or dock panel).
|
||||
const props = defineProps<{ nodeId: string }>();
|
||||
const emit = defineEmits<{ (e: "replayed"): void }>();
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
|
||||
const nodeId = computed(() => props.nodeId);
|
||||
const result = computed<any>(() => workflowObj.testRun.result);
|
||||
|
||||
const node = computed<any>(() =>
|
||||
(workflowObj.currentSelectedWorkflow?.nodes || []).find((n: any) => n.id === nodeId.value),
|
||||
);
|
||||
|
||||
const errorEntries = computed<any[]>(() => {
|
||||
const raw = result.value?.errors?.[nodeId.value];
|
||||
return Array.isArray(raw?.errors) ? raw.errors : [];
|
||||
});
|
||||
const errorMessages = computed<string[]>(() =>
|
||||
errorEntries.value.map((e) => (Array.isArray(e) ? String(e[0]) : String(e))),
|
||||
);
|
||||
|
||||
const stepStatus = computed<"ok" | "error" | "skipped">(() => {
|
||||
const r = result.value;
|
||||
if (r?.errors?.[nodeId.value]) return "error";
|
||||
if (r?.inputs) return r.inputs[nodeId.value]?.length ? "ok" : "skipped";
|
||||
if (r?.blockedNodeIds?.includes(nodeId.value)) return "skipped";
|
||||
return "ok";
|
||||
});
|
||||
const statusVariant = computed<"error-soft" | "success-soft" | "default-soft">(() =>
|
||||
stepStatus.value === "error"
|
||||
? "error-soft"
|
||||
: stepStatus.value === "ok"
|
||||
? "success-soft"
|
||||
: "default-soft",
|
||||
);
|
||||
|
||||
// A history run is read-only (viewing a past execution). A live Test run is
|
||||
// editable + replayable.
|
||||
const isHistory = computed(() => result.value?.mode === "history");
|
||||
|
||||
const deepUnwrapJson = (value: any): any => {
|
||||
if (typeof value === "string") {
|
||||
const s = value.trim();
|
||||
if (s.startsWith("{") || s.startsWith("[")) {
|
||||
try {
|
||||
return deepUnwrapJson(JSON.parse(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(deepUnwrapJson);
|
||||
if (value && typeof value === "object") {
|
||||
const out: Record<string, any> = {};
|
||||
for (const k of Object.keys(value)) out[k] = deepUnwrapJson(value[k]);
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const prettyRecords = (records: any): string => JSON.stringify(deepUnwrapJson(records), null, 2);
|
||||
|
||||
const editableInput = ref("");
|
||||
const seedInputFromRun = () => {
|
||||
const recs = nodeTestInput(nodeId.value);
|
||||
editableInput.value = recs == null ? "" : prettyRecords(recs);
|
||||
};
|
||||
seedInputFromRun();
|
||||
|
||||
const inputModel = computed<string>({
|
||||
get: () => editableInput.value,
|
||||
set: (val: string) => {
|
||||
if (!isHistory.value) editableInput.value = val;
|
||||
},
|
||||
});
|
||||
|
||||
const parsedReplayInput = computed<any[] | null>(() => {
|
||||
const text = editableInput.value.trim();
|
||||
if (!text) return null;
|
||||
try {
|
||||
const v = JSON.parse(text);
|
||||
return Array.isArray(v) ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const inputInvalid = computed(() => parsedReplayInput.value === null);
|
||||
const receivedRecords = computed(() => nodeTestInput(nodeId.value) != null);
|
||||
|
||||
const isTerminal = computed(() => nodeTestOutputBranches(nodeId.value).length === 0);
|
||||
|
||||
const outputRecordsText = computed<string | null>(() => {
|
||||
if (isTerminal.value) {
|
||||
const recs = nodeTestInput(nodeId.value);
|
||||
return recs == null ? null : prettyRecords(recs);
|
||||
}
|
||||
const branches = nodeTestOutputBranches(nodeId.value).filter((b) => b.records != null);
|
||||
if (!branches.length) return null;
|
||||
if (branches.length === 1) return prettyRecords(branches[0].records);
|
||||
const byBranch: Record<string, any> = {};
|
||||
for (const b of branches) {
|
||||
const typeName = nodeMeta(b.nodeType) ? t(nodeMeta(b.nodeType)!.titleKey) : b.nodeType;
|
||||
const label = b.detail ? `${typeName} · ${b.detail}` : typeName;
|
||||
byBranch[`→ ${label}`] = deepUnwrapJson(b.records);
|
||||
}
|
||||
return JSON.stringify(byBranch, null, 2);
|
||||
});
|
||||
|
||||
const outputBranchLabels = computed<string[]>(() => {
|
||||
if (isTerminal.value) {
|
||||
const recs = nodeTestInput(nodeId.value);
|
||||
return recs && recs.length ? [t("workflow.test.stepResult.sentExternally")] : [];
|
||||
}
|
||||
return nodeTestOutputBranches(nodeId.value)
|
||||
.filter((b) => Array.isArray(b.records) && b.records.length)
|
||||
.map((b) => {
|
||||
const typeName = nodeMeta(b.nodeType) ? t(nodeMeta(b.nodeType)!.titleKey) : b.nodeType;
|
||||
return b.detail ? `${typeName} · ${b.detail}` : typeName;
|
||||
});
|
||||
});
|
||||
const showBranchLabels = computed(() => isTerminal.value || outputBranchLabels.value.length > 1);
|
||||
|
||||
const emptyOutputMessage = computed(() =>
|
||||
node.value?.data?.node_type === "condition"
|
||||
? t("workflow.test.stepResult.conditionNoMatch")
|
||||
: t("workflow.test.stepResult.branchFiltered"),
|
||||
);
|
||||
|
||||
const showError = computed(() => errorMessages.value.length > 0);
|
||||
const showForwarded = computed(() => !isTerminal.value || !showError.value);
|
||||
const hasForwardedRecords = computed(() => showForwarded.value && !!outputRecordsText.value);
|
||||
const showSectionHeadings = computed(() => showError.value && hasForwardedRecords.value);
|
||||
|
||||
const outputIsEmpty = computed(() => !showError.value && !hasForwardedRecords.value);
|
||||
const outputEmptyMessage = computed(() =>
|
||||
isTerminal.value ? t("workflow.test.stepResult.destinationNoRecords") : emptyOutputMessage.value,
|
||||
);
|
||||
|
||||
const copyableOutput = computed(() => {
|
||||
const parts: string[] = [];
|
||||
if (showError.value) parts.push(errorMessages.value.join("\n"));
|
||||
if (hasForwardedRecords.value && outputRecordsText.value) parts.push(outputRecordsText.value);
|
||||
return parts.join("\n\n");
|
||||
});
|
||||
|
||||
const copy = (text: string, type: "input" | "output") => {
|
||||
if (!text) return;
|
||||
copyToClipboard(text, t, {
|
||||
successMessage: t(
|
||||
type === "input"
|
||||
? "workflow.test.stepResult.copiedInput"
|
||||
: "workflow.test.stepResult.copiedOutput",
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const ioContainerRef = ref<HTMLElement | null>(null);
|
||||
const isFullscreen = ref(false);
|
||||
const fullscreenTitle = computed(() =>
|
||||
t(
|
||||
isFullscreen.value
|
||||
? "workflow.test.stepResult.exitFullscreen"
|
||||
: "workflow.test.stepResult.enterFullscreen",
|
||||
),
|
||||
);
|
||||
const toggleFullscreen = () => {
|
||||
if (!ioContainerRef.value) return;
|
||||
domToggleFullScreen(ioContainerRef.value).catch((err: any) =>
|
||||
console.error("Failed to toggle fullscreen:", err),
|
||||
);
|
||||
};
|
||||
const onFullscreenChange = () => {
|
||||
isFullscreen.value = document.fullscreenElement === ioContainerRef.value;
|
||||
};
|
||||
watch(nodeId, seedInputFromRun);
|
||||
// A fresh run replaces testRun.result; re-seed the editor from the new input.
|
||||
watch(result, seedInputFromRun);
|
||||
onMounted(() => {
|
||||
seedInputFromRun();
|
||||
document.addEventListener("fullscreenchange", onFullscreenChange);
|
||||
document.addEventListener("webkitfullscreenchange", onFullscreenChange);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("fullscreenchange", onFullscreenChange);
|
||||
document.removeEventListener("webkitfullscreenchange", onFullscreenChange);
|
||||
});
|
||||
|
||||
const canReplay = computed(() => parsedReplayInput.value !== null);
|
||||
const replaying = ref(false);
|
||||
const replay = async () => {
|
||||
if (!canReplay.value || replaying.value || !parsedReplayInput.value) return;
|
||||
replaying.value = true;
|
||||
const r = await executeTestRun({
|
||||
orgId: store.state.selectedOrganization.identifier,
|
||||
inputs: parsedReplayInput.value,
|
||||
fromNode: nodeId.value,
|
||||
});
|
||||
replaying.value = false;
|
||||
if (r.ok) emit("replayed");
|
||||
else toast({ message: raw(r.error || t("workflow.test.runError")), variant: "error" });
|
||||
};
|
||||
|
||||
const inputRecordsForTest = computed<any[] | null>(() => {
|
||||
const p = parsedReplayInput.value;
|
||||
return p && p.length ? p : null;
|
||||
});
|
||||
const outputRecordsForTest = computed<any[] | null>(() => {
|
||||
if (isTerminal.value) return nodeTestInput(nodeId.value);
|
||||
const recs = nodeTestOutputBranches(nodeId.value).flatMap((b) => b.records || []);
|
||||
return recs.length ? recs : null;
|
||||
});
|
||||
|
||||
const useInputTooltip = computed(() =>
|
||||
inputRecordsForTest.value
|
||||
? t("workflow.test.stepResult.useAsTestInput")
|
||||
: t("workflow.test.stepResult.useAsTestInputNoInput"),
|
||||
);
|
||||
const useOutputTooltip = computed(() =>
|
||||
outputRecordsForTest.value
|
||||
? t("workflow.test.stepResult.useAsTestInput")
|
||||
: t("workflow.test.stepResult.useAsTestInputNoOutput"),
|
||||
);
|
||||
|
||||
const useAsTestInput = (records: any[] | null) => {
|
||||
if (!records || !records.length) return;
|
||||
workflowObj.testRun.input = JSON.stringify(records, null, 2);
|
||||
workflowObj.testRun.resultDrawer = { show: false, nodeId: "" };
|
||||
workflowObj.testRun.show = true;
|
||||
};
|
||||
|
||||
// Title helper exposed for hosts (drawer header).
|
||||
defineExpose({ node });
|
||||
</script>
|
||||
@@ -165,6 +165,33 @@ describe("groupNavLinks", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// Workflows moved off the rail and under Data, next to Pipelines. Its visibility
|
||||
// still rides on the top-level entry MainLayout adds/removes from the
|
||||
// `workflows_enabled` flag, so the two tests below are the whole contract.
|
||||
it("absorbs workflows into Data, next to Pipelines", () => {
|
||||
const entries = groupNavLinks([
|
||||
link("home"),
|
||||
link("streams"),
|
||||
link("pipeline"),
|
||||
link("workflows"),
|
||||
link("ingestion"),
|
||||
]);
|
||||
expect(keysOf(entries)).not.toContain("link:workflows");
|
||||
expect(dataGroup(entries)?.children.map((c) => c.name)).toEqual([
|
||||
"logstreams",
|
||||
"pipelines",
|
||||
"workflows",
|
||||
"functionList",
|
||||
"enrichmentTables",
|
||||
"ingestion",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits Workflows from Data when the feature flag left it off the rail", () => {
|
||||
const entries = groupNavLinks([link("home"), link("streams"), link("pipeline")]);
|
||||
expect(dataGroup(entries)?.children.map((c) => c.name)).not.toContain("workflows");
|
||||
});
|
||||
|
||||
it("collapses Alerts into Reliability; Reports stays a link when Dashboards is absent", () => {
|
||||
const entries = groupNavLinks([link("home"), link("alertList"), link("reports")]);
|
||||
// Destinations/Templates ride on alertList, so Alerts alone is already a
|
||||
|
||||
@@ -277,7 +277,7 @@ export const NAV_GROUPS: NavGroupDef[] = [
|
||||
titleKey: "menu.data",
|
||||
icon: "database",
|
||||
parentLink: "/streams",
|
||||
absorbs: ["streams", "pipeline", "ingestion"],
|
||||
absorbs: ["streams", "pipeline", "ingestion", "workflows"],
|
||||
// Data follows the Reliability tile — after Infra, which names the same
|
||||
// anchor and is declared ahead of it. This is load-bearing: without it Data
|
||||
// lands at its own first absorbed item (pipeline/streams), near the TOP of
|
||||
@@ -293,6 +293,11 @@ export const NAV_GROUPS: NavGroupDef[] = [
|
||||
requires: "pipeline",
|
||||
gate: "streamPipelines",
|
||||
},
|
||||
// Sits with Pipelines — the two are the same kind of thing (a flow canvas)
|
||||
// and share the canvas code. `requires` keys off the top-level entry
|
||||
// MainLayout adds/removes from `workflows_enabled`, so the flag still owns
|
||||
// visibility and no gate is duplicated here.
|
||||
{ titleKey: "menu.workflows", icon: "schema", name: "workflows", requires: "workflows" },
|
||||
{ titleKey: "function.header", icon: "function", name: "functionList", requires: "pipeline" },
|
||||
{
|
||||
titleKey: "function.enrichmentTables",
|
||||
|
||||
@@ -139,6 +139,9 @@
|
||||
--color-surface-overlay: var(--d-overlay); /* dropdowns, dialogs, selects, popovers */
|
||||
--color-surface-subtle: var(--d-well); /* recessed fills, segmented track */
|
||||
--color-surface-subtle-hover: var(--d-panel); /* hover on a recessed fill (one step up) */
|
||||
/* Floating chip on a dark panel — a lighter grey so it lifts off the surface. */
|
||||
--color-surface-chip: var(--color-grey-700);
|
||||
--color-surface-chip-hover: var(--color-grey-600);
|
||||
/* Accent-tinted interaction surfaces — dark accent hover/active values. */
|
||||
--color-surface-accent-hover: var(--color-primary-900);
|
||||
--color-surface-accent-active: var(--color-primary-800);
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
--color-surface-overlay: var(--color-white);
|
||||
--color-surface-subtle: var(--color-grey-100);
|
||||
--color-surface-subtle-hover: var(--color-grey-200);
|
||||
/* Floating chip/pill (e.g. the NDV's prev/next node buttons over the white
|
||||
Input/Output panes) — a WHITE fill that reads via its border + shadow in light
|
||||
mode; flips to a lifted grey on the dark canvas (dark.css). */
|
||||
--color-surface-chip: var(--color-white);
|
||||
--color-surface-chip-hover: var(--color-grey-100);
|
||||
/* Accent-tinted interaction surfaces. The ONE place the "hover/active on an
|
||||
accent-tinted control" treatment lives, so buttons, menu items, tabs, selected
|
||||
rows etc. share it — change it here and every accent hover/active surface moves. */
|
||||
@@ -129,6 +134,8 @@
|
||||
--color-surface-overlay: var(--color-surface-overlay);
|
||||
--color-surface-subtle: var(--color-surface-subtle);
|
||||
--color-surface-subtle-hover: var(--color-surface-subtle-hover);
|
||||
--color-surface-chip: var(--color-surface-chip);
|
||||
--color-surface-chip-hover: var(--color-surface-chip-hover);
|
||||
--color-surface-accent-hover: var(--color-surface-accent-hover);
|
||||
--color-surface-accent-active: var(--color-surface-accent-active);
|
||||
--color-icon-chip-primary-bg: var(--color-icon-chip-primary-bg);
|
||||
|
||||
@@ -847,13 +847,17 @@
|
||||
"destination": {
|
||||
"createNew": "Neues Ziel erstellen",
|
||||
"select": "Ziel",
|
||||
"loadError": "Fehler beim Abrufen der Ziele."
|
||||
"loadError": "Fehler beim Abrufen der Ziele.",
|
||||
"unsupportedType": "Nicht unterstützter Typ — Workflows unterstützen nur benutzerdefinierte Ziele"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "Neue Funktion erstellen",
|
||||
"createNewNoteWorkflow": "Hinweis: Die Funktion wird erstellt, dann werden Sie zum vorherigen Formular weitergeleitet, um die neu erstellte Funktion mit dem Workflow zu verknüpfen.",
|
||||
"select": "Funktion auswählen",
|
||||
"loadError": "Fehler beim Laden der Funktionen",
|
||||
"saved": "Funktion erfolgreich gespeichert",
|
||||
"updated": "Funktion erfolgreich aktualisiert",
|
||||
"saveError": "Speichern der Funktion fehlgeschlagen",
|
||||
"flatten": "Nach der Abflachung",
|
||||
"guidelinesTitle": "Richtlinien zur Funktionsausführung:",
|
||||
"rbf": "RBF (Ausführung vor der Abflachung):",
|
||||
@@ -2559,6 +2563,7 @@
|
||||
"runs": {
|
||||
"title": "Workflow-Ausführungen",
|
||||
"edit": "Workflow bearbeiten",
|
||||
"debugInEditor": "Im Editor debuggen",
|
||||
"staleGraphWarning": "Dieser Workflow wurde seit diesem Lauf bearbeitet — der {count} Schritt, den er ausgeführt hat, existiert nicht mehr, daher wird sein Status hier nicht angezeigt. | Dieser Workflow wurde seit diesem Lauf bearbeitet — die {count} Schritte, die er ausgeführt hat, existieren nicht mehr, daher wird ihr Status hier nicht angezeigt."
|
||||
},
|
||||
"history": {
|
||||
@@ -2576,6 +2581,7 @@
|
||||
"success": "Erfolg",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"open": "Verlauf",
|
||||
"openFullView": "Vollständige Ausführungsansicht öffnen",
|
||||
"loadError": "Fehler beim Laden der Ausführungen",
|
||||
"loadRunError": "Fehler beim Laden dieser Ausführung"
|
||||
},
|
||||
@@ -2594,7 +2600,7 @@
|
||||
"triggerRequired": "Ein Trigger ist erforderlich",
|
||||
"addStepRequired": "Fügen Sie mindestens einen Schritt nach dem Trigger hinzu",
|
||||
"connectAllNodes": "Verbinden Sie alle Knoten vor dem Speichern",
|
||||
"finishStepsBeforePublish": "Schließe die Konfiguration aller Schritte ab, bevor du veröffentlichst",
|
||||
"finishStepsBeforePublish": "{count} Schritt benötigt Einrichtung vor der Veröffentlichung — auf der Arbeitsfläche hervorgehoben. | {count} Schritte benötigen Einrichtung vor der Veröffentlichung — auf der Arbeitsfläche hervorgehoben.",
|
||||
"draft": "Entwurf",
|
||||
"saveDraft": "Als Entwurf speichern",
|
||||
"publish": "Veröffentlichen",
|
||||
@@ -2708,6 +2714,11 @@
|
||||
"linkError": "Alarme konnten nicht verknüpft werden."
|
||||
},
|
||||
"chooseTrigger": "Trigger auswählen",
|
||||
"start": {
|
||||
"triggerHint": "Wählen Sie das Ereignis aus, das Ihren Workflow startet.",
|
||||
"chooseAction": "Aktion auswählen",
|
||||
"actionHint": "Wählen Sie aus, was nach dem Trigger passiert."
|
||||
},
|
||||
"node": {
|
||||
"kindTrigger": "Auslöser",
|
||||
"kindLogic": "Logik",
|
||||
@@ -2730,6 +2741,7 @@
|
||||
"incidentSpecificTitle": "Ereignisspezifische Felder",
|
||||
"incidentNoExtraFields": "Dieses Ereignis fügt keine Felder über die üblichen hinaus hinzu.",
|
||||
"conditionNoteTitle": "Richtlinien für Bedingungen",
|
||||
"functionFlattenTitle": "Vor vs. nach dem Abflachen",
|
||||
"conditionNoteEmpty": "Um einen leeren Wert zu prüfen, verwenden Sie \"\". Beispiel:",
|
||||
"conditionNoteNull": "Um einen Null-Wert zu prüfen, verwenden Sie null. Beispiel:",
|
||||
"conditionNoteCustom": "Um eine benutzerdefinierte Spalte hinzuzufügen, geben Sie ihren Namen ein und drücken Sie die Eingabetaste.",
|
||||
@@ -2755,19 +2767,56 @@
|
||||
"enable": "Schritt aktivieren",
|
||||
"disabledBadge": "Deaktiviert",
|
||||
"incompleteBadge": "Später einrichten",
|
||||
"destinationSetUpLater": "Später einrichten",
|
||||
"destinationSetUpLaterPrompt": "Sie haben noch kein Ziel? Aktivieren Sie diese Option, um es später einzurichten — Sie können jetzt einen Entwurf speichern und testen.",
|
||||
"destinationSetUpLaterActive": "Das Ziel wird vorerst übersprungen — Name und Kommentar werden trotzdem gespeichert. Sie benötigen ein Ziel, bevor Sie veröffentlichen.",
|
||||
"functionSetUpLater": "Später einrichten",
|
||||
"functionSetUpLaterPrompt": "Noch keine Funktion? Aktiviere dies, um sie später einzurichten — du kannst einen Entwurf speichern und jetzt testen.",
|
||||
"functionSetUpLaterActive": "Die Funktion wird vorerst übersprungen — Name und Kommentar werden weiterhin gespeichert. Du brauchst eine Funktion, bevor du veröffentlichst."
|
||||
"unsavedFnTitle": "Ungespeicherte Funktionsänderungen",
|
||||
"unsavedFnMessage": "Dieser Schritt enthält Funktionscode, der nicht in Ihrer Bibliothek gespeichert ist. Speichern Sie ihn, verwerfen Sie die Änderungen oder bearbeiten Sie weiter.",
|
||||
"unsavedFnSave": "Funktion speichern",
|
||||
"unsavedFnDiscard": "Änderungen verwerfen",
|
||||
"unsavedFnKeep": "Weiter bearbeiten"
|
||||
},
|
||||
"commonFieldsAbove": "gemeinsame Felder oben",
|
||||
"canvas": {
|
||||
"undo": "Rückgängig machen",
|
||||
"tidyUp": "Aufräumen",
|
||||
"insertStep": "Schritt einfügen",
|
||||
"deleteEdge": "Verbindung löschen"
|
||||
"edgeDeleteHint": "Drücken Sie Backspace/Entf, um die Kante zu entfernen."
|
||||
},
|
||||
"ndv": {
|
||||
"input": "Eingabe",
|
||||
"output": "Ausgabe",
|
||||
"config": "Konfiguration",
|
||||
"noInput": "Keine Eingabedatensätze.",
|
||||
"noOutput": "Keine Datensätze weitergeleitet.",
|
||||
"prevStep": "Vorheriger Schritt · {name}",
|
||||
"nextStep": "Nächster Schritt · {name}",
|
||||
"prevStepBtn": "Vorheriger Schritt",
|
||||
"nextStepBtn": "Nächster Schritt",
|
||||
"collapseSteps": "Schritte einklappen",
|
||||
"expandSteps": "Schritte ausklappen",
|
||||
"inputTag": "Eingabe",
|
||||
"openStep": "{name} öffnen",
|
||||
"runToView": "Führen Sie einen Test aus, um diese Daten anzuzeigen.",
|
||||
"noBranchItems": "Keine Elemente in diesem Zweig.",
|
||||
"noUpstream": "Keine vorgelagerten Schritte.",
|
||||
"noRunYet": "Noch kein Lauf.",
|
||||
"runLabel": "Ausführen",
|
||||
"switchRun": "Lauf wechseln",
|
||||
"notInRun": "Dieser Schritt ist in diesem Lauf nicht enthalten.",
|
||||
"runTest": "Test ausführen",
|
||||
"noItems": "Keine Elemente",
|
||||
"itemsCount": "{count} Element | {count} Elemente",
|
||||
"viewTable": "Tabelle",
|
||||
"viewJson": "JSON",
|
||||
"itemN": "Element {n}",
|
||||
"executeStep": "Schritt ausführen",
|
||||
"sample": "Beispiel",
|
||||
"editStep": "Schritt bearbeiten",
|
||||
"testInputHint": "Testeingabe — Bearbeiten, dann Schritt ausführen.",
|
||||
"noRunsList": "Noch keine Läufe."
|
||||
},
|
||||
"runOverlay": {
|
||||
"banner": "Es werden Schrittdaten aus einem früheren Lauf ({run}) angezeigt. Sie können frei bearbeiten — dieser Lauf wird nie geändert.",
|
||||
"clear": "Laufdaten löschen",
|
||||
"ghostNodes": "{count} Schritt aus diesem Lauf existiert nicht mehr im Workflow, daher wird sein Status nicht angezeigt. | {count} Schritte aus diesem Lauf existieren nicht mehr im Workflow, daher wird ihr Status nicht angezeigt.",
|
||||
"missingNode": "Dieser Schritt existiert nicht mehr in diesem Workflow — er wurde nach diesem Lauf entfernt."
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Nicht gespeicherte Änderungen",
|
||||
@@ -2781,7 +2830,20 @@
|
||||
"expand": "Aufklappen",
|
||||
"close": "Ergebnisse löschen und schließen",
|
||||
"nodesTitle": "Schritte",
|
||||
"empty": "Führen Sie einen Test aus, um hier die Schrittergebnisse zu sehen."
|
||||
"empty": "Führen Sie einen Test aus, um hier die Schrittergebnisse zu sehen.",
|
||||
"openNode": "Knoten öffnen",
|
||||
"viewConfig": "Konfiguration anzeigen",
|
||||
"hideConfig": "Konfiguration ausblenden",
|
||||
"noConfig": "Keine Konfiguration",
|
||||
"functionLabel": "Funktion",
|
||||
"destinationLabel": "Ziel",
|
||||
"destinationUnsupported": "Nicht unterstützter Typ",
|
||||
"destinationMissing": "Dieses Ziel existiert nicht mehr oder kann nicht gelesen werden.",
|
||||
"headersLabel": "Header",
|
||||
"templateLabel": "Vorlage",
|
||||
"outputFormatLabel": "Ausgabeformat",
|
||||
"tlsVerifyLabel": "TLS-Verifizierung",
|
||||
"tlsDisabled": "Deaktiviert"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
|
||||
@@ -847,13 +847,17 @@
|
||||
"destination": {
|
||||
"createNew": "Create New Destination",
|
||||
"select": "Destination",
|
||||
"loadError": "Error while pulling destinations."
|
||||
"loadError": "Error while pulling destinations.",
|
||||
"unsupportedType": "Unsupported Type — Workflows Support Custom Destinations Only"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "Create New Function",
|
||||
"createNewNoteWorkflow": "Note: The function will be created, then you will be redirected to the previous form to associate the newly created function with the workflow.",
|
||||
"select": "Select Function",
|
||||
"loadError": "Failed to load functions",
|
||||
"saved": "Function Saved Successfully",
|
||||
"updated": "Function Updated Successfully",
|
||||
"saveError": "Failed To Save Function",
|
||||
"flatten": "After Flattening",
|
||||
"guidelinesTitle": "Function Execution Guidelines:",
|
||||
"rbf": "RBF (Run Before Flattening):",
|
||||
@@ -2559,6 +2563,7 @@
|
||||
"runs": {
|
||||
"title": "Workflow Runs",
|
||||
"edit": "Edit Workflow",
|
||||
"debugInEditor": "Debug In Editor",
|
||||
"staleGraphWarning": "This workflow has been edited since this run — {count} step it executed no longer exists, so its status isn't shown here. | This workflow has been edited since this run — {count} steps it executed no longer exist, so their status isn't shown here."
|
||||
},
|
||||
"history": {
|
||||
@@ -2576,6 +2581,7 @@
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"open": "History",
|
||||
"openFullView": "Open Full Runs View",
|
||||
"loadError": "Error While Loading Executions",
|
||||
"loadRunError": "Error While Loading This Run"
|
||||
},
|
||||
@@ -2594,7 +2600,7 @@
|
||||
"triggerRequired": "A Trigger Is Required",
|
||||
"addStepRequired": "Add At Least One Step After The Trigger",
|
||||
"connectAllNodes": "Connect All Nodes Before Saving",
|
||||
"finishStepsBeforePublish": "Finish configuring all steps before publishing",
|
||||
"finishStepsBeforePublish": "{count} step needs setup before publishing — highlighted on the canvas. | {count} steps need setup before publishing — highlighted on the canvas.",
|
||||
"draft": "Draft",
|
||||
"saveDraft": "Save as Draft",
|
||||
"publish": "Publish",
|
||||
@@ -2708,6 +2714,11 @@
|
||||
"linkError": "Unable To Link Alerts."
|
||||
},
|
||||
"chooseTrigger": "Choose a Trigger",
|
||||
"start": {
|
||||
"triggerHint": "Select the event that starts your workflow",
|
||||
"chooseAction": "Choose an Action",
|
||||
"actionHint": "Select what happens after the trigger"
|
||||
},
|
||||
"node": {
|
||||
"kindTrigger": "Trigger",
|
||||
"kindLogic": "Logic",
|
||||
@@ -2730,6 +2741,7 @@
|
||||
"incidentSpecificTitle": "Event-Specific Fields",
|
||||
"incidentNoExtraFields": "This event adds no fields beyond the common ones.",
|
||||
"conditionNoteTitle": "Condition Guidelines",
|
||||
"functionFlattenTitle": "Before vs After Flattening",
|
||||
"conditionNoteEmpty": "To Check For An Empty Value, Use \"\". Example:",
|
||||
"conditionNoteNull": "To Check For A Null Value, Use null. Example:",
|
||||
"conditionNoteCustom": "To Add A Custom Column, Type Its Name And Press Enter.",
|
||||
@@ -2755,19 +2767,56 @@
|
||||
"enable": "Enable Step",
|
||||
"disabledBadge": "Disabled",
|
||||
"incompleteBadge": "Set up later",
|
||||
"destinationSetUpLater": "Set up later",
|
||||
"destinationSetUpLaterPrompt": "Don't have a destination yet? Turn this on to set it up later — you can save a draft and test now.",
|
||||
"destinationSetUpLaterActive": "The destination is skipped for now — the name and comment still save. You'll need a destination before publishing.",
|
||||
"functionSetUpLater": "Set up later",
|
||||
"functionSetUpLaterPrompt": "Don't have a function yet? Turn this on to set it up later — you can save a draft and test now.",
|
||||
"functionSetUpLaterActive": "The function is skipped for now — the name and comment still save. You'll need a function before publishing."
|
||||
"unsavedFnTitle": "Unsaved Function Changes",
|
||||
"unsavedFnMessage": "This step has function code that isn't saved to your library. Save it, discard the changes, or keep editing.",
|
||||
"unsavedFnSave": "Save Function",
|
||||
"unsavedFnDiscard": "Discard Changes",
|
||||
"unsavedFnKeep": "Keep Editing"
|
||||
},
|
||||
"commonFieldsAbove": "common fields above",
|
||||
"canvas": {
|
||||
"undo": "Undo",
|
||||
"tidyUp": "Tidy Up",
|
||||
"insertStep": "Insert Step",
|
||||
"deleteEdge": "Delete Connection"
|
||||
"edgeDeleteHint": "Press Backspace/Delete to remove the edge"
|
||||
},
|
||||
"ndv": {
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"config": "Configuration",
|
||||
"noInput": "No input records.",
|
||||
"noOutput": "No records forwarded.",
|
||||
"prevStep": "Previous Step · {name}",
|
||||
"nextStep": "Next Step · {name}",
|
||||
"prevStepBtn": "Previous Step",
|
||||
"nextStepBtn": "Next Step",
|
||||
"collapseSteps": "Collapse Steps",
|
||||
"expandSteps": "Expand Steps",
|
||||
"inputTag": "Input",
|
||||
"openStep": "Open {name}",
|
||||
"runToView": "Run A Test To View This Data.",
|
||||
"noBranchItems": "No Items On This Branch.",
|
||||
"noUpstream": "No Upstream Steps.",
|
||||
"noRunYet": "No Run Yet.",
|
||||
"runLabel": "Run",
|
||||
"switchRun": "Switch Run",
|
||||
"notInRun": "This Step Is Not Included In This Run.",
|
||||
"runTest": "Run Test",
|
||||
"noItems": "No Items",
|
||||
"itemsCount": "{count} Item | {count} Items",
|
||||
"viewTable": "Table",
|
||||
"viewJson": "JSON",
|
||||
"itemN": "Item {n}",
|
||||
"executeStep": "Run Step",
|
||||
"sample": "Sample",
|
||||
"editStep": "Edit Step",
|
||||
"testInputHint": "Test Input — Edit, Then Run Step.",
|
||||
"noRunsList": "No Runs Yet."
|
||||
},
|
||||
"runOverlay": {
|
||||
"banner": "Showing step data from a past run ({run}). You can edit freely — this run is never changed.",
|
||||
"clear": "Clear Run Data",
|
||||
"ghostNodes": "{count} step from this run no longer exists in the workflow, so its status isn't shown. | {count} steps from this run no longer exist in the workflow, so their status isn't shown.",
|
||||
"missingNode": "That step no longer exists in this workflow — it was removed after this run."
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Unsaved Changes",
|
||||
@@ -2781,7 +2830,20 @@
|
||||
"expand": "Expand",
|
||||
"close": "Clear & Close Results",
|
||||
"nodesTitle": "Steps",
|
||||
"empty": "Run A Test To See Step Results Here."
|
||||
"empty": "Run A Test To See Step Results Here.",
|
||||
"openNode": "Open Node",
|
||||
"viewConfig": "View Configuration",
|
||||
"hideConfig": "Hide Configuration",
|
||||
"noConfig": "No Configuration",
|
||||
"functionLabel": "Function",
|
||||
"destinationLabel": "Destination",
|
||||
"destinationUnsupported": "Unsupported Type",
|
||||
"destinationMissing": "This Destination No Longer Exists Or Cannot Be Read.",
|
||||
"headersLabel": "Headers",
|
||||
"templateLabel": "Template",
|
||||
"outputFormatLabel": "Output Format",
|
||||
"tlsVerifyLabel": "TLS Verification",
|
||||
"tlsDisabled": "Disabled"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
|
||||
@@ -847,13 +847,17 @@
|
||||
"destination": {
|
||||
"createNew": "Crear nuevo destino",
|
||||
"select": "Destino",
|
||||
"loadError": "Error al obtener los destinos."
|
||||
"loadError": "Error al obtener los destinos.",
|
||||
"unsupportedType": "Tipo no compatible — los flujos de trabajo solo admiten destinos personalizados"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "Crear nueva función",
|
||||
"createNewNoteWorkflow": "Nota: La función se creará y luego serás redirigido al formulario anterior para asociar la función recién creada con el flujo de trabajo.",
|
||||
"select": "Seleccionar función",
|
||||
"loadError": "Error al cargar las funciones",
|
||||
"saved": "Función guardada correctamente",
|
||||
"updated": "Función actualizada correctamente",
|
||||
"saveError": "Error al guardar la función",
|
||||
"flatten": "Después del aplanamiento",
|
||||
"guidelinesTitle": "Directrices de ejecución de funciones:",
|
||||
"rbf": "RBF (Ejecutar antes del aplanamiento):",
|
||||
@@ -2559,6 +2563,7 @@
|
||||
"runs": {
|
||||
"title": "Ejecuciones de flujo de trabajo",
|
||||
"edit": "Editar flujo de trabajo",
|
||||
"debugInEditor": "Depurar en el editor",
|
||||
"staleGraphWarning": "Este flujo de trabajo ha sido editado desde esta ejecución — {count} paso que ejecutó ya no existe, por lo que su estado no se muestra aquí. | Este flujo de trabajo ha sido editado desde esta ejecución — {count} pasos que ejecutó ya no existen, por lo que sus estados no se muestran aquí."
|
||||
},
|
||||
"history": {
|
||||
@@ -2576,6 +2581,7 @@
|
||||
"success": "Éxito",
|
||||
"failed": "Fallido",
|
||||
"open": "Historial",
|
||||
"openFullView": "Abrir vista completa de ejecuciones",
|
||||
"loadError": "Error al cargar ejecuciones",
|
||||
"loadRunError": "Error al cargar esta ejecución"
|
||||
},
|
||||
@@ -2594,7 +2600,7 @@
|
||||
"triggerRequired": "Se requiere un desencadenante",
|
||||
"addStepRequired": "Agrega al menos un paso después del desencadenante",
|
||||
"connectAllNodes": "Conecta todos los nodos antes de guardar",
|
||||
"finishStepsBeforePublish": "Termina de configurar todos los pasos antes de publicar",
|
||||
"finishStepsBeforePublish": "{count} paso necesita configuración antes de publicar — resaltado en el lienzo. | {count} pasos necesitan configuración antes de publicar — resaltados en el lienzo.",
|
||||
"draft": "Borrador",
|
||||
"saveDraft": "Guardar como borrador",
|
||||
"publish": "Publicar",
|
||||
@@ -2708,6 +2714,11 @@
|
||||
"linkError": "No se pueden vincular las alertas."
|
||||
},
|
||||
"chooseTrigger": "Elige un disparador",
|
||||
"start": {
|
||||
"triggerHint": "Seleccione el evento que inicia su flujo de trabajo",
|
||||
"chooseAction": "Seleccione una acción",
|
||||
"actionHint": "Seleccione lo que sucede después del disparador"
|
||||
},
|
||||
"node": {
|
||||
"kindTrigger": "Disparador",
|
||||
"kindLogic": "Lógica",
|
||||
@@ -2730,6 +2741,7 @@
|
||||
"incidentSpecificTitle": "Campos específicos del evento",
|
||||
"incidentNoExtraFields": "Este evento no añade campos más allá de los comunes.",
|
||||
"conditionNoteTitle": "Directrices de condición",
|
||||
"functionFlattenTitle": "Antes vs Después del Aplanado",
|
||||
"conditionNoteEmpty": "Para verificar un valor vacío, use \"\". Ejemplo:",
|
||||
"conditionNoteNull": "Para verificar un valor nulo, use null. Ejemplo:",
|
||||
"conditionNoteCustom": "Para agregar una columna personalizada, escriba su nombre y presione Enter.",
|
||||
@@ -2755,19 +2767,56 @@
|
||||
"enable": "Activar paso",
|
||||
"disabledBadge": "Desactivado",
|
||||
"incompleteBadge": "Configurar más tarde",
|
||||
"destinationSetUpLater": "Configurar más tarde",
|
||||
"destinationSetUpLaterPrompt": "¿No tienes un destino todavía? Activa esto para configurarlo más tarde: puedes guardar un borrador y probar ahora.",
|
||||
"destinationSetUpLaterActive": "El destino se omite por ahora: el nombre y el comentario aún se guardan. Necesitarás un destino antes de publicar.",
|
||||
"functionSetUpLater": "Configurar más tarde",
|
||||
"functionSetUpLaterPrompt": "¿Aún no tienes una función? Activa esto para configurarla más tarde — puedes guardar un borrador y probar ahora.",
|
||||
"functionSetUpLaterActive": "La función se omite por ahora — el nombre y el comentario se guardan igualmente. Necesitarás una función antes de publicar."
|
||||
"unsavedFnTitle": "Cambios de función sin guardar",
|
||||
"unsavedFnMessage": "Este paso tiene código de función que no está guardado en su biblioteca. Guárdelo, descarte los cambios o siga editando.",
|
||||
"unsavedFnSave": "Guardar función",
|
||||
"unsavedFnDiscard": "Descartar cambios",
|
||||
"unsavedFnKeep": "Seguir editando"
|
||||
},
|
||||
"commonFieldsAbove": "campos comunes arriba",
|
||||
"canvas": {
|
||||
"undo": "Deshacer",
|
||||
"tidyUp": "Limpiar",
|
||||
"insertStep": "Insertar paso",
|
||||
"deleteEdge": "Eliminar conexión"
|
||||
"edgeDeleteHint": "Presione Retroceso/Suprimir para eliminar la conexión"
|
||||
},
|
||||
"ndv": {
|
||||
"input": "Entrada",
|
||||
"output": "Salida",
|
||||
"config": "Configuración",
|
||||
"noInput": "No hay registros de entrada.",
|
||||
"noOutput": "No hay registros reenviados.",
|
||||
"prevStep": "Paso anterior · {name}",
|
||||
"nextStep": "Paso siguiente · {name}",
|
||||
"prevStepBtn": "Paso anterior",
|
||||
"nextStepBtn": "Paso siguiente",
|
||||
"collapseSteps": "Contraer pasos",
|
||||
"expandSteps": "Expandir pasos",
|
||||
"inputTag": "Entrada",
|
||||
"openStep": "Abrir {name}",
|
||||
"runToView": "Ejecute una prueba para ver estos datos.",
|
||||
"noBranchItems": "No hay elementos en esta rama.",
|
||||
"noUpstream": "No hay pasos anteriores.",
|
||||
"noRunYet": "Aún no se ha ejecutado.",
|
||||
"runLabel": "Ejecución",
|
||||
"switchRun": "Cambiar ejecución",
|
||||
"notInRun": "Este paso no está incluido en esta ejecución.",
|
||||
"runTest": "Ejecutar prueba",
|
||||
"noItems": "No hay elementos",
|
||||
"itemsCount": "{count} elemento | {count} elementos",
|
||||
"viewTable": "Tabla",
|
||||
"viewJson": "JSON",
|
||||
"itemN": "Elemento {n}",
|
||||
"executeStep": "Ejecutar paso",
|
||||
"sample": "Muestra",
|
||||
"editStep": "Editar paso",
|
||||
"testInputHint": "Entrada de prueba — edite y luego ejecute el paso.",
|
||||
"noRunsList": "Aún no hay ejecuciones."
|
||||
},
|
||||
"runOverlay": {
|
||||
"banner": "Mostrando datos de paso de una ejecución anterior ({run}). Puede editar libremente — esta ejecución nunca se modifica.",
|
||||
"clear": "Borrar datos de ejecución",
|
||||
"ghostNodes": "{count} paso de esta ejecución ya no existe en el flujo de trabajo, por lo que su estado no se muestra. | {count} pasos de esta ejecución ya no existen en el flujo de trabajo, por lo que sus estados no se muestran.",
|
||||
"missingNode": "Ese paso ya no existe en este flujo de trabajo: se eliminó después de esta ejecución."
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Cambios sin guardar",
|
||||
@@ -2781,7 +2830,20 @@
|
||||
"expand": "Expandir",
|
||||
"close": "Limpiar y cerrar resultados",
|
||||
"nodesTitle": "Pasos",
|
||||
"empty": "Ejecuta una prueba para ver los resultados de los pasos aquí."
|
||||
"empty": "Ejecuta una prueba para ver los resultados de los pasos aquí.",
|
||||
"openNode": "Abrir nodo",
|
||||
"viewConfig": "Ver configuración",
|
||||
"hideConfig": "Ocultar configuración",
|
||||
"noConfig": "Sin configuración",
|
||||
"functionLabel": "Función",
|
||||
"destinationLabel": "Destino",
|
||||
"destinationUnsupported": "Tipo no compatible",
|
||||
"destinationMissing": "Este destino ya no existe o no se puede leer.",
|
||||
"headersLabel": "Encabezados",
|
||||
"templateLabel": "Plantilla",
|
||||
"outputFormatLabel": "Formato de salida",
|
||||
"tlsVerifyLabel": "Verificación TLS",
|
||||
"tlsDisabled": "Deshabilitada"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
|
||||
@@ -847,13 +847,17 @@
|
||||
"destination": {
|
||||
"createNew": "Créer une nouvelle destination",
|
||||
"select": "Destination",
|
||||
"loadError": "Erreur lors de la récupération des destinations."
|
||||
"loadError": "Erreur lors de la récupération des destinations.",
|
||||
"unsupportedType": "Type non pris en charge — Les Workflows ne prennent en charge que les destinations personnalisées"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "Créer une nouvelle fonction",
|
||||
"createNewNoteWorkflow": "Remarque : La fonction sera créée, puis vous serez redirigé vers le formulaire précédent pour associer la fonction nouvellement créée au workflow.",
|
||||
"select": "Sélectionner une fonction",
|
||||
"loadError": "Échec du chargement des fonctions",
|
||||
"saved": "Fonction enregistrée avec succès",
|
||||
"updated": "Fonction mise à jour avec succès",
|
||||
"saveError": "Échec de l'enregistrement de la fonction",
|
||||
"flatten": "Après aplatissement",
|
||||
"guidelinesTitle": "Directives d'exécution de la fonction :",
|
||||
"rbf": "RBF (Exécution avant aplatissement) :",
|
||||
@@ -2559,6 +2563,7 @@
|
||||
"runs": {
|
||||
"title": "Exécutions du workflow",
|
||||
"edit": "Modifier le workflow",
|
||||
"debugInEditor": "Déboguer dans l'éditeur",
|
||||
"staleGraphWarning": "Ce workflow a été modifié depuis cette exécution — {count} étape qu'il a exécutée n'existe plus, donc son statut n'est pas affiché ici. | Ce workflow a été modifié depuis cette exécution — {count} étapes qu'il a exécutées n'existent plus, donc leur statut n'est pas affiché ici."
|
||||
},
|
||||
"history": {
|
||||
@@ -2576,6 +2581,7 @@
|
||||
"success": "Succès",
|
||||
"failed": "Échoué",
|
||||
"open": "Historique",
|
||||
"openFullView": "Ouvrir la vue complète des exécutions",
|
||||
"loadError": "Erreur lors du chargement des exécutions",
|
||||
"loadRunError": "Erreur lors du chargement de cette exécution"
|
||||
},
|
||||
@@ -2594,7 +2600,7 @@
|
||||
"triggerRequired": "Un déclencheur est requis",
|
||||
"addStepRequired": "Ajoutez au moins une étape après le déclencheur",
|
||||
"connectAllNodes": "Connectez tous les nœuds avant d'enregistrer",
|
||||
"finishStepsBeforePublish": "Terminez la configuration de toutes les étapes avant de publier",
|
||||
"finishStepsBeforePublish": "{count} étape nécessite une configuration avant la publication — mise en évidence sur le canevas. | {count} étapes nécessitent une configuration avant la publication — mises en évidence sur le canevas.",
|
||||
"draft": "Brouillon",
|
||||
"saveDraft": "Enregistrer comme brouillon",
|
||||
"publish": "Publier",
|
||||
@@ -2708,6 +2714,11 @@
|
||||
"linkError": "Impossible de lier les alertes."
|
||||
},
|
||||
"chooseTrigger": "Choisir un déclencheur",
|
||||
"start": {
|
||||
"triggerHint": "Sélectionnez l'événement qui démarre votre workflow",
|
||||
"chooseAction": "Choisir une action",
|
||||
"actionHint": "Sélectionnez ce qui se passe après le déclencheur"
|
||||
},
|
||||
"node": {
|
||||
"kindTrigger": "Déclencheur",
|
||||
"kindLogic": "Logique",
|
||||
@@ -2730,6 +2741,7 @@
|
||||
"incidentSpecificTitle": "Champs spécifiques de l'événement",
|
||||
"incidentNoExtraFields": "Cet événement n'ajoute aucun champ au-delà des champs courants.",
|
||||
"conditionNoteTitle": "Directives de condition",
|
||||
"functionFlattenTitle": "Avant vs Après aplatissement",
|
||||
"conditionNoteEmpty": "Pour vérifier une valeur vide, utilisez \"\". Exemple :",
|
||||
"conditionNoteNull": "Pour vérifier une valeur nulle, utilisez null. Exemple :",
|
||||
"conditionNoteCustom": "Pour ajouter une colonne personnalisée, tapez son nom et appuyez sur Entrée.",
|
||||
@@ -2755,19 +2767,56 @@
|
||||
"enable": "Activer l'étape",
|
||||
"disabledBadge": "Désactivé",
|
||||
"incompleteBadge": "Configurer plus tard",
|
||||
"destinationSetUpLater": "Configurer plus tard",
|
||||
"destinationSetUpLaterPrompt": "Vous n'avez pas encore de destination ? Activez cette option pour la configurer plus tard — vous pouvez enregistrer un brouillon et tester maintenant.",
|
||||
"destinationSetUpLaterActive": "La destination est ignorée pour le moment — le nom et le commentaire sont quand même enregistrés. Vous aurez besoin d'une destination avant de publier.",
|
||||
"functionSetUpLater": "Configurer plus tard",
|
||||
"functionSetUpLaterPrompt": "Vous n'avez pas encore de fonction ? Activez cette option pour la configurer plus tard — vous pouvez enregistrer un brouillon et tester maintenant.",
|
||||
"functionSetUpLaterActive": "La fonction est ignorée pour le moment — le nom et le commentaire sont tout de même enregistrés. Vous aurez besoin d'une fonction avant de publier."
|
||||
"unsavedFnTitle": "Modifications de fonction non enregistrées",
|
||||
"unsavedFnMessage": "Cette étape contient un code de fonction non enregistré dans votre bibliothèque. Enregistrez-le, abandonnez les modifications ou continuez à modifier.",
|
||||
"unsavedFnSave": "Enregistrer la fonction",
|
||||
"unsavedFnDiscard": "Abandonner les modifications",
|
||||
"unsavedFnKeep": "Continuer à modifier"
|
||||
},
|
||||
"commonFieldsAbove": "champs communs ci-dessus",
|
||||
"canvas": {
|
||||
"undo": "Défaire",
|
||||
"tidyUp": "Nettoyer",
|
||||
"insertStep": "Insérer une étape",
|
||||
"deleteEdge": "Supprimer la connexion"
|
||||
"edgeDeleteHint": "Appuyez sur Retour arrière/Suppr pour supprimer la connexion"
|
||||
},
|
||||
"ndv": {
|
||||
"input": "Entrée",
|
||||
"output": "Sortie",
|
||||
"config": "Configuration",
|
||||
"noInput": "Aucun enregistrement d'entrée.",
|
||||
"noOutput": "Aucun enregistrement transmis.",
|
||||
"prevStep": "Étape précédente · {name}",
|
||||
"nextStep": "Étape suivante · {name}",
|
||||
"prevStepBtn": "Étape précédente",
|
||||
"nextStepBtn": "Étape suivante",
|
||||
"collapseSteps": "Réduire les étapes",
|
||||
"expandSteps": "Développer les étapes",
|
||||
"inputTag": "Entrée",
|
||||
"openStep": "Ouvrir {name}",
|
||||
"runToView": "Exécutez un test pour afficher ces données.",
|
||||
"noBranchItems": "Aucun élément sur cette branche.",
|
||||
"noUpstream": "Aucune étape en amont.",
|
||||
"noRunYet": "Aucune exécution pour l'instant.",
|
||||
"runLabel": "Exécuter",
|
||||
"switchRun": "Changer d'exécution",
|
||||
"notInRun": "Cette étape n'est pas incluse dans cette exécution.",
|
||||
"runTest": "Exécuter le test",
|
||||
"noItems": "Aucun élément",
|
||||
"itemsCount": "{count} élément | {count} éléments",
|
||||
"viewTable": "Tableau",
|
||||
"viewJson": "JSON",
|
||||
"itemN": "Élément {n}",
|
||||
"executeStep": "Exécuter l'étape",
|
||||
"sample": "Échantillon",
|
||||
"editStep": "Modifier l'étape",
|
||||
"testInputHint": "Entrée de test — Modifiez, puis exécutez l'étape.",
|
||||
"noRunsList": "Aucune exécution pour l'instant."
|
||||
},
|
||||
"runOverlay": {
|
||||
"banner": "Affichage des données d'étape d'une exécution passée ({run}). Vous pouvez modifier librement — cette exécution n'est jamais modifiée.",
|
||||
"clear": "Effacer les données d'exécution",
|
||||
"ghostNodes": "{count} étape de cette exécution n'existe plus dans le workflow, son statut n'est donc pas affiché. | {count} étapes de cette exécution n'existent plus dans le workflow, leur statut n'est donc pas affiché.",
|
||||
"missingNode": "Cette étape n'existe plus dans ce workflow — elle a été supprimée après cette exécution."
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Modifications non enregistrées",
|
||||
@@ -2781,7 +2830,20 @@
|
||||
"expand": "Développer",
|
||||
"close": "Effacer et fermer les résultats",
|
||||
"nodesTitle": "Étapes",
|
||||
"empty": "Exécutez un test pour voir les résultats des étapes ici."
|
||||
"empty": "Exécutez un test pour voir les résultats des étapes ici.",
|
||||
"openNode": "Ouvrir le nœud",
|
||||
"viewConfig": "Afficher la configuration",
|
||||
"hideConfig": "Masquer la configuration",
|
||||
"noConfig": "Aucune configuration",
|
||||
"functionLabel": "Fonction",
|
||||
"destinationLabel": "Destination",
|
||||
"destinationUnsupported": "Type non pris en charge",
|
||||
"destinationMissing": "Cette destination n'existe plus ou ne peut pas être lue.",
|
||||
"headersLabel": "En-têtes",
|
||||
"templateLabel": "Modèle",
|
||||
"outputFormatLabel": "Format de sortie",
|
||||
"tlsVerifyLabel": "Vérification TLS",
|
||||
"tlsDisabled": "Désactivé"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
|
||||
@@ -847,13 +847,17 @@
|
||||
"destination": {
|
||||
"createNew": "Crea nuova destinazione",
|
||||
"select": "Destinazione",
|
||||
"loadError": "Errore durante il recupero delle destinazioni."
|
||||
"loadError": "Errore durante il recupero delle destinazioni.",
|
||||
"unsupportedType": "Tipo non supportato — Workflow supporta solo destinazioni personalizzate"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "Crea nuova funzione",
|
||||
"createNewNoteWorkflow": "Nota: La funzione verrà creata, poi verrai reindirizzato al modulo precedente per associare la funzione appena creata al workflow.",
|
||||
"select": "Seleziona funzione",
|
||||
"loadError": "Caricamento funzioni fallito",
|
||||
"saved": "Funzione salvata con successo",
|
||||
"updated": "Funzione aggiornata con successo",
|
||||
"saveError": "Salvataggio della funzione non riuscito",
|
||||
"flatten": "Dopo l'appiattimento",
|
||||
"guidelinesTitle": "Linee guida per l'esecuzione della funzione:",
|
||||
"rbf": "RBF (Esecuzione prima dell'appiattimento):",
|
||||
@@ -2559,6 +2563,7 @@
|
||||
"runs": {
|
||||
"title": "Esecuzioni del flusso di lavoro",
|
||||
"edit": "Modifica flusso di lavoro",
|
||||
"debugInEditor": "Debug nell'editor",
|
||||
"staleGraphWarning": "Questo workflow è stato modificato dopo questa esecuzione — il {count} step che ha eseguito non esiste più, quindi il suo stato non viene mostrato qui. | Questo workflow è stato modificato dopo questa esecuzione — i {count} step che ha eseguito non esistono più, quindi i loro stati non vengono mostrati qui."
|
||||
},
|
||||
"history": {
|
||||
@@ -2576,6 +2581,7 @@
|
||||
"success": "Successo",
|
||||
"failed": "Fallito",
|
||||
"open": "Cronologia",
|
||||
"openFullView": "Apri la visualizzazione completa delle esecuzioni",
|
||||
"loadError": "Errore durante il caricamento delle esecuzioni",
|
||||
"loadRunError": "Errore durante il caricamento di questa esecuzione"
|
||||
},
|
||||
@@ -2594,7 +2600,7 @@
|
||||
"triggerRequired": "È richiesto un trigger",
|
||||
"addStepRequired": "Aggiungi almeno un passo dopo il trigger",
|
||||
"connectAllNodes": "Collega tutti i nodi prima di salvare",
|
||||
"finishStepsBeforePublish": "Completa la configurazione di tutti i passaggi prima di pubblicare",
|
||||
"finishStepsBeforePublish": "{count} passaggio richiede configurazione prima della pubblicazione — evidenziato sulla tela. | {count} passaggi richiedono configurazione prima della pubblicazione — evidenziati sulla tela.",
|
||||
"draft": "Bozza",
|
||||
"saveDraft": "Salva come bozza",
|
||||
"publish": "Pubblica",
|
||||
@@ -2708,6 +2714,11 @@
|
||||
"linkError": "Impossibile collegare gli avvisi."
|
||||
},
|
||||
"chooseTrigger": "Scegli un trigger",
|
||||
"start": {
|
||||
"triggerHint": "Seleziona l'evento che avvia il tuo workflow",
|
||||
"chooseAction": "Scegli un'azione",
|
||||
"actionHint": "Seleziona cosa succede dopo il trigger"
|
||||
},
|
||||
"node": {
|
||||
"kindTrigger": "Trigger",
|
||||
"kindLogic": "Logica",
|
||||
@@ -2730,6 +2741,7 @@
|
||||
"incidentSpecificTitle": "Campi specifici dell'evento",
|
||||
"incidentNoExtraFields": "Questo evento non aggiunge campi oltre a quelli comuni.",
|
||||
"conditionNoteTitle": "Linee guida per le condizioni",
|
||||
"functionFlattenTitle": "Prima vs dopo l'appiattimento",
|
||||
"conditionNoteEmpty": "Per controllare un valore vuoto, usa \"\". Esempio:",
|
||||
"conditionNoteNull": "Per controllare un valore nullo, usa null. Esempio:",
|
||||
"conditionNoteCustom": "Per aggiungere una colonna personalizzata, digita il suo nome e premi Invio.",
|
||||
@@ -2755,19 +2767,56 @@
|
||||
"enable": "Attiva passaggio",
|
||||
"disabledBadge": "Disattivato",
|
||||
"incompleteBadge": "Configura più tardi",
|
||||
"destinationSetUpLater": "Configura più tardi",
|
||||
"destinationSetUpLaterPrompt": "Non hai ancora una destinazione? Attiva questa opzione per configurarla in seguito — puoi salvare una bozza e testare ora.",
|
||||
"destinationSetUpLaterActive": "La destinazione viene saltata per ora — nome e commento vengono comunque salvati. Ti servirà una destinazione prima di pubblicare.",
|
||||
"functionSetUpLater": "Configura più tardi",
|
||||
"functionSetUpLaterPrompt": "Non hai ancora una funzione? Attiva questa opzione per configurarla più tardi — puoi salvare una bozza e fare test subito.",
|
||||
"functionSetUpLaterActive": "La funzione viene saltata per ora — nome e commento vengono comunque salvati. Ti servirà una funzione prima di pubblicare."
|
||||
"unsavedFnTitle": "Modifiche alla funzione non salvate",
|
||||
"unsavedFnMessage": "Questo passaggio contiene codice di funzione non salvato nella tua libreria. Salvalo, scarta le modifiche o continua a modificare.",
|
||||
"unsavedFnSave": "Salva funzione",
|
||||
"unsavedFnDiscard": "Scarta modifiche",
|
||||
"unsavedFnKeep": "Continua a modificare"
|
||||
},
|
||||
"commonFieldsAbove": "campi comuni qui sopra",
|
||||
"canvas": {
|
||||
"undo": "Annulla",
|
||||
"tidyUp": "Riordina",
|
||||
"insertStep": "Inserisci passaggio",
|
||||
"deleteEdge": "Elimina connessione"
|
||||
"edgeDeleteHint": "Premi Backspace/Canc per rimuovere il collegamento"
|
||||
},
|
||||
"ndv": {
|
||||
"input": "Ingresso",
|
||||
"output": "Uscita",
|
||||
"config": "Configurazione",
|
||||
"noInput": "Nessun record di ingresso.",
|
||||
"noOutput": "Nessun record inoltrato.",
|
||||
"prevStep": "Passaggio precedente · {name}",
|
||||
"nextStep": "Passaggio successivo · {name}",
|
||||
"prevStepBtn": "Passaggio precedente",
|
||||
"nextStepBtn": "Passaggio successivo",
|
||||
"collapseSteps": "Comprimi passaggi",
|
||||
"expandSteps": "Espandi passaggi",
|
||||
"inputTag": "Ingresso",
|
||||
"openStep": "Apri {name}",
|
||||
"runToView": "Esegui un test per visualizzare questi dati.",
|
||||
"noBranchItems": "Nessun elemento in questo ramo.",
|
||||
"noUpstream": "Nessun passaggio a monte.",
|
||||
"noRunYet": "Nessuna esecuzione ancora.",
|
||||
"runLabel": "Esegui",
|
||||
"switchRun": "Cambia esecuzione",
|
||||
"notInRun": "Questo passaggio non è incluso in questa esecuzione.",
|
||||
"runTest": "Esegui test",
|
||||
"noItems": "Nessun elemento",
|
||||
"itemsCount": "{count} elemento | {count} elementi",
|
||||
"viewTable": "Tabella",
|
||||
"viewJson": "JSON",
|
||||
"itemN": "Elemento {n}",
|
||||
"executeStep": "Esegui passaggio",
|
||||
"sample": "Campione",
|
||||
"editStep": "Modifica passaggio",
|
||||
"testInputHint": "Ingresso di test — modifica, quindi esegui il passaggio.",
|
||||
"noRunsList": "Nessuna esecuzione ancora."
|
||||
},
|
||||
"runOverlay": {
|
||||
"banner": "Stai visualizzando i dati del passaggio da un'esecuzione passata ({run}). Puoi modificare liberamente — questa esecuzione non viene mai modificata.",
|
||||
"clear": "Cancella i dati dell'esecuzione",
|
||||
"ghostNodes": "{count} passo di questa esecuzione non esiste più nel workflow, quindi il suo stato non viene mostrato. | {count} passi di questa esecuzione non esistono più nel workflow, quindi il loro stato non viene mostrato.",
|
||||
"missingNode": "Quel passo non esiste più in questo workflow — è stato rimosso dopo questa esecuzione."
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Modifiche non salvate",
|
||||
@@ -2781,7 +2830,20 @@
|
||||
"expand": "Espandi",
|
||||
"close": "Cancella e chiudi i risultati",
|
||||
"nodesTitle": "Passaggi",
|
||||
"empty": "Esegui un test per vedere i risultati dei passaggi qui."
|
||||
"empty": "Esegui un test per vedere i risultati dei passaggi qui.",
|
||||
"openNode": "Apri nodo",
|
||||
"viewConfig": "Visualizza configurazione",
|
||||
"hideConfig": "Nascondi configurazione",
|
||||
"noConfig": "Nessuna configurazione",
|
||||
"functionLabel": "Funzione",
|
||||
"destinationLabel": "Destinazione",
|
||||
"destinationUnsupported": "Tipo non supportato",
|
||||
"destinationMissing": "Questa destinazione non esiste più o non può essere letta.",
|
||||
"headersLabel": "Intestazioni",
|
||||
"templateLabel": "Template",
|
||||
"outputFormatLabel": "Formato di output",
|
||||
"tlsVerifyLabel": "Verifica TLS",
|
||||
"tlsDisabled": "Disabilitato"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
|
||||
@@ -847,13 +847,17 @@
|
||||
"destination": {
|
||||
"createNew": "新しい宛先を作成",
|
||||
"select": "宛先",
|
||||
"loadError": "宛先の取得中にエラーが発生しました。"
|
||||
"loadError": "宛先の取得中にエラーが発生しました。",
|
||||
"unsupportedType": "サポートされていないタイプ — ワークフローはカスタム宛先のみをサポートします"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "新しい関数を作成",
|
||||
"createNewNoteWorkflow": "注:関数が作成され、その後、以前のフォームにリダイレクトされて、新しく作成された関数をワークフローに関連付けます。",
|
||||
"select": "関数を選択",
|
||||
"loadError": "関数の読み込みに失敗しました",
|
||||
"saved": "関数が正常に保存されました",
|
||||
"updated": "関数が正常に更新されました",
|
||||
"saveError": "関数の保存に失敗しました",
|
||||
"flatten": "フラット化後",
|
||||
"guidelinesTitle": "関数実行ガイドライン:",
|
||||
"rbf": "RBF(フラット化前に実行):",
|
||||
@@ -2559,6 +2563,7 @@
|
||||
"runs": {
|
||||
"title": "ワークフロー実行",
|
||||
"edit": "ワークフローを編集",
|
||||
"debugInEditor": "エディタでデバッグ",
|
||||
"staleGraphWarning": "このワークフローはこの実行後に編集されました — 実行した{count}個のステップはもう存在しないため、ここではステータスが表示されません。 | このワークフローはこの実行後に編集されました — 実行した{count}個のステップはもう存在しないため、ここではステータスが表示されません。"
|
||||
},
|
||||
"history": {
|
||||
@@ -2576,6 +2581,7 @@
|
||||
"success": "成功",
|
||||
"failed": "失敗",
|
||||
"open": "履歴",
|
||||
"openFullView": "すべての実行ビューを開く",
|
||||
"loadError": "実行の読み込み中にエラーが発生しました",
|
||||
"loadRunError": "この実行の読み込み中にエラーが発生しました"
|
||||
},
|
||||
@@ -2594,7 +2600,7 @@
|
||||
"triggerRequired": "トリガーが必要です",
|
||||
"addStepRequired": "トリガーの後に少なくとも1つのステップを追加してください",
|
||||
"connectAllNodes": "保存する前にすべてのノードを接続してください",
|
||||
"finishStepsBeforePublish": "公開前にすべてのステップの設定を完了してください",
|
||||
"finishStepsBeforePublish": "{count} ステップは公開前にセットアップが必要です — キャンバス上でハイライトされています。 | {count} ステップは公開前にセットアップが必要です — キャンバス上でハイライトされています。",
|
||||
"draft": "下書き",
|
||||
"saveDraft": "下書きとして保存",
|
||||
"publish": "公開",
|
||||
@@ -2708,6 +2714,11 @@
|
||||
"linkError": "アラートをリンクできませんでした。"
|
||||
},
|
||||
"chooseTrigger": "トリガーを選択",
|
||||
"start": {
|
||||
"triggerHint": "ワークフローを開始するイベントを選択してください",
|
||||
"chooseAction": "アクションを選択",
|
||||
"actionHint": "トリガー後の動作を選択してください"
|
||||
},
|
||||
"node": {
|
||||
"kindTrigger": "トリガー",
|
||||
"kindLogic": "ロジック",
|
||||
@@ -2730,6 +2741,7 @@
|
||||
"incidentSpecificTitle": "イベント固有フィールド",
|
||||
"incidentNoExtraFields": "このイベントは共通フィールド以外のフィールドを追加しません。",
|
||||
"conditionNoteTitle": "条件のガイドライン",
|
||||
"functionFlattenTitle": "フラット化前と後",
|
||||
"conditionNoteEmpty": "空の値を確認するには、\"\" を使用します。例:",
|
||||
"conditionNoteNull": "null値を確認するには、null を使用します。例:",
|
||||
"conditionNoteCustom": "カスタム列を追加するには、名前を入力してEnterキーを押します。",
|
||||
@@ -2755,19 +2767,56 @@
|
||||
"enable": "ステップを有効にする",
|
||||
"disabledBadge": "無効",
|
||||
"incompleteBadge": "後で設定",
|
||||
"destinationSetUpLater": "後で設定",
|
||||
"destinationSetUpLaterPrompt": "宛先がまだありませんか? これをオンにして後で設定できます。下書きを保存して今すぐテストできます。",
|
||||
"destinationSetUpLaterActive": "宛先は今のところスキップされます。名前とコメントは引き続き保存されます。公開する前に宛先が必要です。",
|
||||
"functionSetUpLater": "後で設定",
|
||||
"functionSetUpLaterPrompt": "まだ関数がありませんか?これをオンにすると後で設定できます — 今すぐ下書きを保存してテストできます。",
|
||||
"functionSetUpLaterActive": "今のところ関数はスキップされます — 名前とコメントは保存されます。公開前に関数が必要です。"
|
||||
"unsavedFnTitle": "未保存の関数変更",
|
||||
"unsavedFnMessage": "このステップにはライブラリに保存されていない関数コードがあります。保存するか、変更を破棄するか、編集を続けてください。",
|
||||
"unsavedFnSave": "関数を保存",
|
||||
"unsavedFnDiscard": "変更を破棄",
|
||||
"unsavedFnKeep": "編集を続ける"
|
||||
},
|
||||
"commonFieldsAbove": "上記の共通フィールド",
|
||||
"canvas": {
|
||||
"undo": "元に戻す",
|
||||
"tidyUp": "整理",
|
||||
"insertStep": "ステップを挿入",
|
||||
"deleteEdge": "接続を削除"
|
||||
"edgeDeleteHint": "Backspace/Delete キーを押すとエッジを削除できます"
|
||||
},
|
||||
"ndv": {
|
||||
"input": "入力",
|
||||
"output": "出力",
|
||||
"config": "構成",
|
||||
"noInput": "入力レコードがありません。",
|
||||
"noOutput": "転送されたレコードがありません。",
|
||||
"prevStep": "前のステップ · {name}",
|
||||
"nextStep": "次のステップ · {name}",
|
||||
"prevStepBtn": "前のステップ",
|
||||
"nextStepBtn": "次のステップ",
|
||||
"collapseSteps": "ステップを折りたたむ",
|
||||
"expandSteps": "ステップを展開",
|
||||
"inputTag": "入力",
|
||||
"openStep": "{name} を開く",
|
||||
"runToView": "このデータを表示するにはテストを実行してください。",
|
||||
"noBranchItems": "このブランチにはアイテムがありません。",
|
||||
"noUpstream": "上流のステップがありません。",
|
||||
"noRunYet": "まだ実行されていません。",
|
||||
"runLabel": "実行",
|
||||
"switchRun": "実行を切り替え",
|
||||
"notInRun": "このステップはこの実行に含まれていません。",
|
||||
"runTest": "テストを実行",
|
||||
"noItems": "アイテムがありません",
|
||||
"itemsCount": "{count} アイテム | {count} アイテム",
|
||||
"viewTable": "テーブル",
|
||||
"viewJson": "JSON",
|
||||
"itemN": "アイテム {n}",
|
||||
"executeStep": "ステップを実行",
|
||||
"sample": "サンプル",
|
||||
"editStep": "ステップを編集",
|
||||
"testInputHint": "テスト入力 — 編集してからステップを実行してください。",
|
||||
"noRunsList": "実行はまだありません。"
|
||||
},
|
||||
"runOverlay": {
|
||||
"banner": "過去の実行({run})のステップデータを表示しています。自由に編集できます — この実行が変更されることはありません。",
|
||||
"clear": "実行データをクリア",
|
||||
"ghostNodes": "この実行の {count} ステップはワークフロー内に存在しなくなったため、ステータスは表示されません。 | この実行の {count} ステップはワークフロー内に存在しなくなったため、ステータスは表示されません。",
|
||||
"missingNode": "そのステップはこのワークフロー内に存在しなくなっています — この実行後に削除されました。"
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "未保存の変更",
|
||||
@@ -2781,7 +2830,20 @@
|
||||
"expand": "展開する",
|
||||
"close": "結果をクリアして閉じる",
|
||||
"nodesTitle": "ステップ",
|
||||
"empty": "テストを実行すると、ここにステップ結果が表示されます。"
|
||||
"empty": "テストを実行すると、ここにステップ結果が表示されます。",
|
||||
"openNode": "ノードを開く",
|
||||
"viewConfig": "設定を表示",
|
||||
"hideConfig": "設定を非表示",
|
||||
"noConfig": "設定なし",
|
||||
"functionLabel": "関数",
|
||||
"destinationLabel": "宛先",
|
||||
"destinationUnsupported": "未対応のタイプ",
|
||||
"destinationMissing": "この宛先は存在しないか、読み取ることができません。",
|
||||
"headersLabel": "ヘッダー",
|
||||
"templateLabel": "テンプレート",
|
||||
"outputFormatLabel": "出力形式",
|
||||
"tlsVerifyLabel": "TLS 検証",
|
||||
"tlsDisabled": "無効"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
|
||||
@@ -847,13 +847,17 @@
|
||||
"destination": {
|
||||
"createNew": "새 대상 만들기",
|
||||
"select": "대상",
|
||||
"loadError": "대상을 불러오는 중 오류가 발생했습니다."
|
||||
"loadError": "대상을 불러오는 중 오류가 발생했습니다.",
|
||||
"unsupportedType": "지원되지 않는 유형 — 워크플로우는 사용자 지정 대상만 지원합니다"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "새 함수 생성",
|
||||
"createNewNoteWorkflow": "참고: 함수가 생성된 후, 이전 양식으로 리디렉션되어 새로 생성된 함수를 워크플로우에 연결하게 됩니다.",
|
||||
"select": "함수 선택",
|
||||
"loadError": "함수 로드 실패",
|
||||
"saved": "함수가 성공적으로 저장되었습니다",
|
||||
"updated": "함수가 성공적으로 업데이트되었습니다",
|
||||
"saveError": "함수를 저장하지 못했습니다",
|
||||
"flatten": "평탄화 후",
|
||||
"guidelinesTitle": "함수 실행 지침:",
|
||||
"rbf": "RBF (평탄화 전 실행):",
|
||||
@@ -2559,6 +2563,7 @@
|
||||
"runs": {
|
||||
"title": "워크플로 실행",
|
||||
"edit": "워크플로 편집",
|
||||
"debugInEditor": "편집기에서 디버그",
|
||||
"staleGraphWarning": "이 워크플로는 이 실행 이후 편집되었습니다 — 실행된 {count} 단계가 더 이상 존재하지 않으므로 상태가 여기에 표시되지 않습니다. | 이 워크플로는 이 실행 이후 편집되었습니다 — 실행된 {count} 단계가 더 이상 존재하지 않으므로 상태가 여기에 표시되지 않습니다."
|
||||
},
|
||||
"history": {
|
||||
@@ -2576,6 +2581,7 @@
|
||||
"success": "성공",
|
||||
"failed": "실패",
|
||||
"open": "기록",
|
||||
"openFullView": "전체 실행 보기 열기",
|
||||
"loadError": "실행을 로드하는 중 오류 발생",
|
||||
"loadRunError": "이 실행을 로드하는 중 오류 발생"
|
||||
},
|
||||
@@ -2594,7 +2600,7 @@
|
||||
"triggerRequired": "트리거가 필요합니다.",
|
||||
"addStepRequired": "트리거 뒤에 최소 하나의 단계를 추가하세요.",
|
||||
"connectAllNodes": "저장하기 전에 모든 노드를 연결하세요.",
|
||||
"finishStepsBeforePublish": "게시하기 전에 모든 단계 구성을 완료하세요",
|
||||
"finishStepsBeforePublish": "게시 전에 {count}개 단계의 설정이 필요합니다 — 캔버스에서 강조 표시됩니다. | 게시 전에 {count}개 단계의 설정이 필요합니다 — 캔버스에서 강조 표시됩니다.",
|
||||
"draft": "초안",
|
||||
"saveDraft": "초안으로 저장",
|
||||
"publish": "게시",
|
||||
@@ -2708,6 +2714,11 @@
|
||||
"linkError": "알림을 연결할 수 없습니다."
|
||||
},
|
||||
"chooseTrigger": "트리거 선택",
|
||||
"start": {
|
||||
"triggerHint": "워크플로우를 시작하는 이벤트를 선택하세요",
|
||||
"chooseAction": "액션 선택",
|
||||
"actionHint": "트리거 후 수행할 작업을 선택하세요"
|
||||
},
|
||||
"node": {
|
||||
"kindTrigger": "트리거",
|
||||
"kindLogic": "로직",
|
||||
@@ -2730,6 +2741,7 @@
|
||||
"incidentSpecificTitle": "이벤트별 필드",
|
||||
"incidentNoExtraFields": "이 이벤트는 공통 필드 외에 추가 필드가 없습니다.",
|
||||
"conditionNoteTitle": "조건 가이드라인",
|
||||
"functionFlattenTitle": "평면화 전후",
|
||||
"conditionNoteEmpty": "빈 값을 확인하려면 \"\"를 사용하세요. 예:",
|
||||
"conditionNoteNull": "널 값을 확인하려면 null을 사용하세요. 예:",
|
||||
"conditionNoteCustom": "사용자 정의 열을 추가하려면 이름을 입력하고 Enter를 누르세요.",
|
||||
@@ -2755,19 +2767,56 @@
|
||||
"enable": "단계 활성화",
|
||||
"disabledBadge": "비활성화됨",
|
||||
"incompleteBadge": "나중에 설정",
|
||||
"destinationSetUpLater": "아직 대상이 없으신가요? 이 옵션을 켜면 나중에 설정할 수 있습니다 — 지금 초안을 저장하고 테스트할 수 있습니다.",
|
||||
"destinationSetUpLaterPrompt": "아직 대상이 없으신가요? 이 옵션을 켜면 나중에 설정할 수 있습니다 — 지금 초안을 저장하고 테스트할 수 있습니다.",
|
||||
"destinationSetUpLaterActive": "대상은 지금 건너뜁니다 — 이름과 설명은 계속 저장됩니다. 게시하려면 대상이 필요합니다.",
|
||||
"functionSetUpLater": "아직 함수가 없으신가요? 이 옵션을 켜면 나중에 설정할 수 있습니다 — 지금 초안을 저장하고 테스트할 수 있습니다.",
|
||||
"functionSetUpLaterPrompt": "함수는 지금 건너뜁니다 — 이름과 댓글은 여전히 저장됩니다. 게시하기 전에 함수가 필요합니다.",
|
||||
"functionSetUpLaterActive": "나중에 설정"
|
||||
"unsavedFnTitle": "저장되지 않은 함수 변경 사항",
|
||||
"unsavedFnMessage": "이 단계에는 라이브러리에 저장되지 않은 함수 코드가 있습니다. 저장하거나 변경 사항을 취소하거나 계속 편집할 수 있습니다.",
|
||||
"unsavedFnSave": "함수 저장",
|
||||
"unsavedFnDiscard": "변경 사항 취소",
|
||||
"unsavedFnKeep": "계속 편집"
|
||||
},
|
||||
"commonFieldsAbove": "위의 공통 필드",
|
||||
"canvas": {
|
||||
"undo": "실행 취소",
|
||||
"tidyUp": "정리",
|
||||
"insertStep": "단계 삽입",
|
||||
"deleteEdge": "연결 삭제"
|
||||
"edgeDeleteHint": "Backspace/Delete를 눌러 엣지를 제거하세요"
|
||||
},
|
||||
"ndv": {
|
||||
"input": "입력",
|
||||
"output": "출력",
|
||||
"config": "구성",
|
||||
"noInput": "입력 레코드가 없습니다.",
|
||||
"noOutput": "전달된 레코드가 없습니다.",
|
||||
"prevStep": "이전 단계 · {name}",
|
||||
"nextStep": "다음 단계 · {name}",
|
||||
"prevStepBtn": "이전 단계",
|
||||
"nextStepBtn": "다음 단계",
|
||||
"collapseSteps": "단계 접기",
|
||||
"expandSteps": "단계 펼치기",
|
||||
"inputTag": "입력",
|
||||
"openStep": "{name} 열기",
|
||||
"runToView": "이 데이터를 보려면 테스트를 실행하세요.",
|
||||
"noBranchItems": "이 분기에 항목이 없습니다.",
|
||||
"noUpstream": "상위 단계가 없습니다.",
|
||||
"noRunYet": "아직 실행이 없습니다.",
|
||||
"runLabel": "실행",
|
||||
"switchRun": "실행 전환",
|
||||
"notInRun": "이 단계는 이 실행에 포함되지 않습니다.",
|
||||
"runTest": "테스트 실행",
|
||||
"noItems": "항목 없음",
|
||||
"itemsCount": "{count} 항목 | {count} 항목",
|
||||
"viewTable": "테이블",
|
||||
"viewJson": "JSON",
|
||||
"itemN": "항목 {n}",
|
||||
"executeStep": "단계 실행",
|
||||
"sample": "샘플",
|
||||
"editStep": "단계 편집",
|
||||
"testInputHint": "테스트 입력 — 편집 후 단계를 실행하세요.",
|
||||
"noRunsList": "아직 실행 기록이 없습니다."
|
||||
},
|
||||
"runOverlay": {
|
||||
"banner": "과거 실행({run})의 단계 데이터를 표시합니다. 자유롭게 편집할 수 있습니다 — 이 실행은 변경되지 않습니다.",
|
||||
"clear": "실행 데이터 지우기",
|
||||
"ghostNodes": "이 실행의 {count} 단계는 더 이상 워크플로에 존재하지 않으므로 상태가 표시되지 않습니다. | 이 실행의 {count} 단계는 더 이상 워크플로에 존재하지 않으므로 상태가 표시되지 않습니다.",
|
||||
"missingNode": "해당 단계는 이 워크플로에 더 이상 존재하지 않습니다. 이 실행 후에 제거되었습니다."
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "저장되지 않은 변경 사항",
|
||||
@@ -2781,7 +2830,20 @@
|
||||
"expand": "펼치기",
|
||||
"close": "결과 지우기 및 닫기",
|
||||
"nodesTitle": "단계",
|
||||
"empty": "여기에서 테스트를 실행하여 단계 결과를 확인하세요."
|
||||
"empty": "여기에서 테스트를 실행하여 단계 결과를 확인하세요.",
|
||||
"openNode": "노드 열기",
|
||||
"viewConfig": "구성 보기",
|
||||
"hideConfig": "구성 숨기기",
|
||||
"noConfig": "구성 없음",
|
||||
"functionLabel": "함수",
|
||||
"destinationLabel": "대상",
|
||||
"destinationUnsupported": "지원되지 않는 유형",
|
||||
"destinationMissing": "이 대상은 더 이상 존재하지 않거나 읽을 수 없습니다.",
|
||||
"headersLabel": "헤더",
|
||||
"templateLabel": "템플릿",
|
||||
"outputFormatLabel": "출력 형식",
|
||||
"tlsVerifyLabel": "TLS 검증",
|
||||
"tlsDisabled": "비활성화"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
|
||||
@@ -847,13 +847,17 @@
|
||||
"destination": {
|
||||
"createNew": "Nieuwe bestemming maken",
|
||||
"select": "Bestemming",
|
||||
"loadError": "Fout bij het ophalen van bestemmingen."
|
||||
"loadError": "Fout bij het ophalen van bestemmingen.",
|
||||
"unsupportedType": "Niet-ondersteund type — Workflows ondersteunen alleen aangepaste bestemmingen"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "Nieuwe functie aanmaken",
|
||||
"createNewNoteWorkflow": "Opmerking: De functie wordt aangemaakt, waarna u wordt doorgestuurd naar het vorige formulier om de nieuw aangemaakte functie aan de workflow te koppelen.",
|
||||
"select": "Functie selecteren",
|
||||
"loadError": "Functies laden mislukt",
|
||||
"saved": "Functie succesvol opgeslagen",
|
||||
"updated": "Functie succesvol bijgewerkt",
|
||||
"saveError": "Functie opslaan mislukt",
|
||||
"flatten": "Na afvlakking",
|
||||
"guidelinesTitle": "Richtlijnen voor functie-uitvoering:",
|
||||
"rbf": "RBF (Uitvoeren vóór afvlakking):",
|
||||
@@ -2559,6 +2563,7 @@
|
||||
"runs": {
|
||||
"title": "Workflow-uitvoeringen",
|
||||
"edit": "Workflow bewerken",
|
||||
"debugInEditor": "Debuggen in editor",
|
||||
"staleGraphWarning": "Deze workflow is bewerkt sinds deze run — {count} stap die werd uitgevoerd bestaat niet meer, dus de status wordt hier niet getoond. | Deze workflow is bewerkt sinds deze run — {count} stappen die werden uitgevoerd bestaan niet meer, dus hun status wordt hier niet getoond."
|
||||
},
|
||||
"history": {
|
||||
@@ -2576,6 +2581,7 @@
|
||||
"success": "Succes",
|
||||
"failed": "Mislukt",
|
||||
"open": "Geschiedenis",
|
||||
"openFullView": "Volledige runsweergave openen",
|
||||
"loadError": "Fout bij het laden van uitvoeringen",
|
||||
"loadRunError": "Fout bij het laden van deze uitvoering"
|
||||
},
|
||||
@@ -2594,7 +2600,7 @@
|
||||
"triggerRequired": "Een trigger is vereist",
|
||||
"addStepRequired": "Voeg ten minste één stap toe na de trigger",
|
||||
"connectAllNodes": "Verbind alle knooppunten voordat u opslaat",
|
||||
"finishStepsBeforePublish": "Voltooi alle stappen voordat u publiceert.",
|
||||
"finishStepsBeforePublish": "{count} stap moet worden geconfigureerd voordat je publiceert — gemarkeerd op het canvas. | {count} stappen moeten worden geconfigureerd voordat je publiceert — gemarkeerd op het canvas.",
|
||||
"draft": "Concept",
|
||||
"saveDraft": "Opslaan als concept",
|
||||
"publish": "Publiceren",
|
||||
@@ -2708,6 +2714,11 @@
|
||||
"linkError": "Kan meldingen niet koppelen."
|
||||
},
|
||||
"chooseTrigger": "Kies een trigger",
|
||||
"start": {
|
||||
"triggerHint": "Selecteer de gebeurtenis die je workflow start",
|
||||
"chooseAction": "Kies een actie",
|
||||
"actionHint": "Selecteer wat er na de trigger gebeurt"
|
||||
},
|
||||
"node": {
|
||||
"kindTrigger": "Trigger",
|
||||
"kindLogic": "Logica",
|
||||
@@ -2730,6 +2741,7 @@
|
||||
"incidentSpecificTitle": "Gebeurtenisspecifieke velden",
|
||||
"incidentNoExtraFields": "Deze gebeurtenis voegt geen velden toe naast de algemene velden.",
|
||||
"conditionNoteTitle": "Richtlijnen voor voorwaarden",
|
||||
"functionFlattenTitle": "Vóór vs. na afvlakken",
|
||||
"conditionNoteEmpty": "Om een lege waarde te controleren, gebruik \"\". Voorbeeld:",
|
||||
"conditionNoteNull": "Om een null-waarde te controleren, gebruik null. Voorbeeld:",
|
||||
"conditionNoteCustom": "Om een aangepaste kolom toe te voegen, typt u de naam en drukt u op Enter.",
|
||||
@@ -2755,19 +2767,56 @@
|
||||
"enable": "Stap inschakelen",
|
||||
"disabledBadge": "Uitgeschakeld",
|
||||
"incompleteBadge": "Stel later in",
|
||||
"destinationSetUpLater": "Stel later in",
|
||||
"destinationSetUpLaterPrompt": "Heb je nog geen bestemming? Schakel dit in om deze later in te stellen — je kunt nu een concept opslaan en testen.",
|
||||
"destinationSetUpLaterActive": "De bestemming wordt voorlopig overgeslagen — de naam en opmerking worden nog wel opgeslagen. Je hebt een bestemming nodig voordat je publiceert.",
|
||||
"functionSetUpLater": "Stel later in",
|
||||
"functionSetUpLaterPrompt": "Nog geen functie? Schakel dit in om het later in te stellen — u kunt nu een concept opslaan en testen.",
|
||||
"functionSetUpLaterActive": "De functie wordt voorlopig overgeslagen — de naam en opmerking worden nog steeds opgeslagen. U hebt een functie nodig voordat u publiceert."
|
||||
"unsavedFnTitle": "Niet-opgeslagen functiewijzigingen",
|
||||
"unsavedFnMessage": "Deze stap bevat functiecode die niet in je bibliotheek is opgeslagen. Sla deze op, verwerp de wijzigingen of blijf bewerken.",
|
||||
"unsavedFnSave": "Functie opslaan",
|
||||
"unsavedFnDiscard": "Wijzigingen verwerpen",
|
||||
"unsavedFnKeep": "Blijf bewerken"
|
||||
},
|
||||
"commonFieldsAbove": "algemene velden hierboven",
|
||||
"canvas": {
|
||||
"undo": "Ongedaan maken",
|
||||
"tidyUp": "Opruimen",
|
||||
"insertStep": "Stap invoegen",
|
||||
"deleteEdge": "Verbinding verwijderen"
|
||||
"edgeDeleteHint": "Druk op Backspace/Delete om de verbinding te verwijderen"
|
||||
},
|
||||
"ndv": {
|
||||
"input": "Invoer",
|
||||
"output": "Uitvoer",
|
||||
"config": "Configuratie",
|
||||
"noInput": "Geen invoerrecords.",
|
||||
"noOutput": "Geen records doorgestuurd.",
|
||||
"prevStep": "Vorige stap · {name}",
|
||||
"nextStep": "Volgende stap · {name}",
|
||||
"prevStepBtn": "Vorige stap",
|
||||
"nextStepBtn": "Volgende stap",
|
||||
"collapseSteps": "Stappen invouwen",
|
||||
"expandSteps": "Stappen uitvouwen",
|
||||
"inputTag": "Invoer",
|
||||
"openStep": "Open {name}",
|
||||
"runToView": "Voer een test uit om deze gegevens te bekijken.",
|
||||
"noBranchItems": "Geen items op deze vertakking.",
|
||||
"noUpstream": "Geen upstream-stappen.",
|
||||
"noRunYet": "Nog geen run.",
|
||||
"runLabel": "Run",
|
||||
"switchRun": "Wissel run",
|
||||
"notInRun": "Deze stap is niet opgenomen in deze run.",
|
||||
"runTest": "Test uitvoeren",
|
||||
"noItems": "Geen items",
|
||||
"itemsCount": "{count} item | {count} items",
|
||||
"viewTable": "Tabel",
|
||||
"viewJson": "JSON",
|
||||
"itemN": "Item {n}",
|
||||
"executeStep": "Stap uitvoeren",
|
||||
"sample": "Voorbeeld",
|
||||
"editStep": "Stap bewerken",
|
||||
"testInputHint": "Testinvoer — bewerk en voer vervolgens de stap uit.",
|
||||
"noRunsList": "Nog geen runs."
|
||||
},
|
||||
"runOverlay": {
|
||||
"banner": "Toont stapgegevens van een eerdere run ({run}). Je kunt vrij bewerken — deze run wordt nooit gewijzigd.",
|
||||
"clear": "Run-gegevens wissen",
|
||||
"ghostNodes": "{count} stap van deze uitvoering bestaat niet meer in de workflow, dus de status wordt niet getoond. | {count} stappen van deze uitvoering bestaan niet meer in de workflow, dus hun status wordt niet getoond.",
|
||||
"missingNode": "Die stap bestaat niet meer in deze workflow — deze is na deze uitvoering verwijderd."
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Niet-opgeslagen wijzigingen",
|
||||
@@ -2781,7 +2830,20 @@
|
||||
"expand": "Uitklappen",
|
||||
"close": "Resultaten wissen en sluiten",
|
||||
"nodesTitle": "Stappen",
|
||||
"empty": "Voer een test uit om hier de stapresultaten te zien."
|
||||
"empty": "Voer een test uit om hier de stapresultaten te zien.",
|
||||
"openNode": "Node openen",
|
||||
"viewConfig": "Configuratie bekijken",
|
||||
"hideConfig": "Configuratie verbergen",
|
||||
"noConfig": "Geen configuratie",
|
||||
"functionLabel": "Functie",
|
||||
"destinationLabel": "Bestemming",
|
||||
"destinationUnsupported": "Niet-ondersteund type",
|
||||
"destinationMissing": "Deze bestemming bestaat niet meer of kan niet worden gelezen.",
|
||||
"headersLabel": "Headers",
|
||||
"templateLabel": "Template",
|
||||
"outputFormatLabel": "Uitvoerformaat",
|
||||
"tlsVerifyLabel": "TLS-verificatie",
|
||||
"tlsDisabled": "Uitgeschakeld"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
|
||||
@@ -847,13 +847,17 @@
|
||||
"destination": {
|
||||
"createNew": "Utwórz nowe miejsce docelowe",
|
||||
"select": "Miejsce docelowe",
|
||||
"loadError": "Błąd podczas pobierania miejsc docelowych."
|
||||
"loadError": "Błąd podczas pobierania miejsc docelowych.",
|
||||
"unsupportedType": "Nieobsługiwany typ — Przepływy pracy obsługują tylko niestandardowe miejsca docelowe"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "Utwórz nową funkcję",
|
||||
"createNewNoteWorkflow": "Uwaga: Funkcja zostanie utworzona, a następnie zostaniesz przekierowany do poprzedniego formularza, aby powiązać nowo utworzoną funkcję z przepływem pracy.",
|
||||
"select": "Wybierz funkcję",
|
||||
"loadError": "Nie udało się załadować funkcji",
|
||||
"saved": "Funkcja zapisana pomyślnie",
|
||||
"updated": "Funkcja zaktualizowana pomyślnie",
|
||||
"saveError": "Nie udało się zapisać funkcji",
|
||||
"flatten": "Po spłaszczeniu",
|
||||
"guidelinesTitle": "Wytyczne dotyczące wykonywania funkcji:",
|
||||
"rbf": "RBF (Uruchom przed spłaszczeniem):",
|
||||
@@ -2559,6 +2563,7 @@
|
||||
"runs": {
|
||||
"title": "Uruchomienia przepływu pracy",
|
||||
"edit": "Edytuj przepływ pracy",
|
||||
"debugInEditor": "Debuguj w edytorze",
|
||||
"staleGraphWarning": "Ten przepływ pracy został edytowany od czasu tego uruchomienia — {count} wykonany krok już nie istnieje, więc jego stan nie jest tutaj pokazany. | Ten przepływ pracy został edytowany od czasu tego uruchomienia — {count} wykonane kroki już nie istnieją, więc ich stan nie jest tutaj pokazany."
|
||||
},
|
||||
"history": {
|
||||
@@ -2576,6 +2581,7 @@
|
||||
"success": "Sukces",
|
||||
"failed": "Niepowodzenie",
|
||||
"open": "Historia",
|
||||
"openFullView": "Otwórz pełny widok uruchomień",
|
||||
"loadError": "Błąd podczas ładowania uruchomień",
|
||||
"loadRunError": "Błąd podczas ładowania tego uruchomienia"
|
||||
},
|
||||
@@ -2594,7 +2600,7 @@
|
||||
"triggerRequired": "Wymagany jest wyzwalacz",
|
||||
"addStepRequired": "Dodaj co najmniej jeden krok po wyzwalaczu",
|
||||
"connectAllNodes": "Połącz wszystkie węzły przed zapisaniem",
|
||||
"finishStepsBeforePublish": "Skonfiguruj wszystkie kroki przed publikacją",
|
||||
"finishStepsBeforePublish": "{count} krok wymaga konfiguracji przed opublikowaniem — podświetlony na płótnie. | {count} kroków wymaga konfiguracji przed opublikowaniem — podświetlone na płótnie.",
|
||||
"draft": "Wersja robocza",
|
||||
"saveDraft": "Zapisz jako wersję roboczą",
|
||||
"publish": "Opublikuj",
|
||||
@@ -2708,6 +2714,11 @@
|
||||
"linkError": "Nie udało się połączyć alertów."
|
||||
},
|
||||
"chooseTrigger": "Wybierz wyzwalacz",
|
||||
"start": {
|
||||
"triggerHint": "Wybierz zdarzenie, które uruchamia Twój przepływ pracy",
|
||||
"chooseAction": "Wybierz akcję",
|
||||
"actionHint": "Wybierz, co ma się wydarzyć po wyzwalaczu"
|
||||
},
|
||||
"node": {
|
||||
"kindTrigger": "Wyzwalacz",
|
||||
"kindLogic": "Logika",
|
||||
@@ -2730,6 +2741,7 @@
|
||||
"incidentSpecificTitle": "Pola specyficzne dla zdarzenia",
|
||||
"incidentNoExtraFields": "To zdarzenie nie dodaje żadnych pól poza standardowymi.",
|
||||
"conditionNoteTitle": "Wytyczne dotyczące warunków",
|
||||
"functionFlattenTitle": "Przed vs Po spłaszczeniu",
|
||||
"conditionNoteEmpty": "Aby sprawdzić pustą wartość, użyj \"\". Przykład:",
|
||||
"conditionNoteNull": "Aby sprawdzić wartość null, użyj null. Przykład:",
|
||||
"conditionNoteCustom": "Aby dodać niestandardową kolumnę, wpisz jej nazwę i naciśnij Enter.",
|
||||
@@ -2755,19 +2767,56 @@
|
||||
"enable": "Włącz krok",
|
||||
"disabledBadge": "Wyłączony",
|
||||
"incompleteBadge": "Skonfiguruj później",
|
||||
"destinationSetUpLater": "Skonfiguruj później",
|
||||
"destinationSetUpLaterPrompt": "Nie masz jeszcze miejsca docelowego? Włącz to, aby skonfigurować je później — możesz zapisać wersję roboczą i przetestować teraz.",
|
||||
"destinationSetUpLaterActive": "Miejsce docelowe jest na razie pomijane — nazwa i komentarz nadal są zapisywane. Będziesz potrzebować miejsca docelowego przed opublikowaniem.",
|
||||
"functionSetUpLater": "Skonfiguruj później",
|
||||
"functionSetUpLaterPrompt": "Nie masz jeszcze funkcji? Włącz to, aby skonfigurować ją później — możesz zapisać wersję roboczą i przetestować teraz.",
|
||||
"functionSetUpLaterActive": "Funkcja jest na razie pomijana — nazwa i komentarz nadal zostaną zapisane. Będziesz potrzebować funkcji przed publikacją."
|
||||
"unsavedFnTitle": "Niezapisane zmiany funkcji",
|
||||
"unsavedFnMessage": "Ten krok zawiera kod funkcji, który nie jest zapisany w Twojej bibliotece. Zapisz go, odrzuć zmiany lub kontynuuj edycję.",
|
||||
"unsavedFnSave": "Zapisz funkcję",
|
||||
"unsavedFnDiscard": "Odrzuć zmiany",
|
||||
"unsavedFnKeep": "Kontynuuj edycję"
|
||||
},
|
||||
"commonFieldsAbove": "Wspólne pola powyżej",
|
||||
"canvas": {
|
||||
"undo": "Cofnij",
|
||||
"tidyUp": "Uporządkuj",
|
||||
"insertStep": "Wstaw krok",
|
||||
"deleteEdge": "Usuń połączenie"
|
||||
"edgeDeleteHint": "Naciśnij Backspace/Delete, aby usunąć krawędź"
|
||||
},
|
||||
"ndv": {
|
||||
"input": "Wejście",
|
||||
"output": "Wyjście",
|
||||
"config": "Konfiguracja",
|
||||
"noInput": "Brak rekordów wejściowych.",
|
||||
"noOutput": "Brak przekazanych rekordów.",
|
||||
"prevStep": "Poprzedni krok · {name}",
|
||||
"nextStep": "Następny krok · {name}",
|
||||
"prevStepBtn": "Poprzedni krok",
|
||||
"nextStepBtn": "Następny krok",
|
||||
"collapseSteps": "Zwiń kroki",
|
||||
"expandSteps": "Rozwiń kroki",
|
||||
"inputTag": "Wejście",
|
||||
"openStep": "Otwórz {name}",
|
||||
"runToView": "Uruchom test, aby zobaczyć te dane.",
|
||||
"noBranchItems": "Brak elementów w tej gałęzi.",
|
||||
"noUpstream": "Brak poprzedzających kroków.",
|
||||
"noRunYet": "Brak uruchomienia.",
|
||||
"runLabel": "Uruchom",
|
||||
"switchRun": "Zmień uruchomienie",
|
||||
"notInRun": "Ten krok nie jest uwzględniony w tym uruchomieniu.",
|
||||
"runTest": "Uruchom test",
|
||||
"noItems": "Brak elementów",
|
||||
"itemsCount": "{count} element | {count} elementów",
|
||||
"viewTable": "Tabela",
|
||||
"viewJson": "JSON",
|
||||
"itemN": "Element {n}",
|
||||
"executeStep": "Uruchom krok",
|
||||
"sample": "Próbka",
|
||||
"editStep": "Edytuj krok",
|
||||
"testInputHint": "Wejście testowe — edytuj, a następnie uruchom krok.",
|
||||
"noRunsList": "Brak uruchomień."
|
||||
},
|
||||
"runOverlay": {
|
||||
"banner": "Wyświetlanie danych kroku z poprzedniego uruchomienia ({run}). Możesz swobodnie edytować — to uruchomienie nigdy nie jest zmieniane.",
|
||||
"clear": "Wyczyść dane uruchomienia",
|
||||
"ghostNodes": "{count} krok z tego uruchomienia już nie istnieje w przepływie pracy, więc jego status nie jest wyświetlany. | {count} kroków z tego uruchomienia już nie istnieje w przepływie pracy, więc ich status nie jest wyświetlany.",
|
||||
"missingNode": "Ten krok już nie istnieje w tym przepływie pracy — został usunięty po tym uruchomieniu."
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Niezapisane zmiany",
|
||||
@@ -2781,7 +2830,20 @@
|
||||
"expand": "Rozwiń",
|
||||
"close": "Wyczyść i zamknij wyniki",
|
||||
"nodesTitle": "Kroki",
|
||||
"empty": "Uruchom test, aby zobaczyć wyniki kroków tutaj."
|
||||
"empty": "Uruchom test, aby zobaczyć wyniki kroków tutaj.",
|
||||
"openNode": "Otwórz węzeł",
|
||||
"viewConfig": "Zobacz konfigurację",
|
||||
"hideConfig": "Ukryj konfigurację",
|
||||
"noConfig": "Brak konfiguracji",
|
||||
"functionLabel": "Funkcja",
|
||||
"destinationLabel": "Miejsce docelowe",
|
||||
"destinationUnsupported": "Nieobsługiwany typ",
|
||||
"destinationMissing": "To miejsce docelowe już nie istnieje lub nie można go odczytać.",
|
||||
"headersLabel": "Nagłówki",
|
||||
"templateLabel": "Szablon",
|
||||
"outputFormatLabel": "Format wyjściowy",
|
||||
"tlsVerifyLabel": "Weryfikacja TLS",
|
||||
"tlsDisabled": "Wyłączone"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
|
||||
@@ -847,13 +847,17 @@
|
||||
"destination": {
|
||||
"createNew": "Criar novo destino",
|
||||
"select": "Destino",
|
||||
"loadError": "Erro ao carregar destinos."
|
||||
"loadError": "Erro ao carregar destinos.",
|
||||
"unsupportedType": "Tipo não suportado — Fluxos de trabalho suportam apenas destinos personalizados"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "Criar nova função",
|
||||
"createNewNoteWorkflow": "Nota: A função será criada e você será redirecionado ao formulário anterior para associar a função recém-criada ao fluxo de trabalho.",
|
||||
"select": "Selecionar função",
|
||||
"loadError": "Falha ao carregar funções",
|
||||
"saved": "Função salva com sucesso",
|
||||
"updated": "Função atualizada com sucesso",
|
||||
"saveError": "Falha ao salvar função",
|
||||
"flatten": "Após achatamento",
|
||||
"guidelinesTitle": "Diretrizes de execução da função:",
|
||||
"rbf": "RBF (Executar antes do achatamento):",
|
||||
@@ -2559,6 +2563,7 @@
|
||||
"runs": {
|
||||
"title": "Execuções do Fluxo de Trabalho",
|
||||
"edit": "Editar Fluxo de Trabalho",
|
||||
"debugInEditor": "Depurar no editor",
|
||||
"staleGraphWarning": "Este workflow foi editado desde esta execução — {count} passo que ele executou não existe mais, portanto seu status não é mostrado aqui. | Este workflow foi editado desde esta execução — {count} passos que ele executou não existem mais, portanto seus status não são mostrados aqui."
|
||||
},
|
||||
"history": {
|
||||
@@ -2576,6 +2581,7 @@
|
||||
"success": "Sucesso",
|
||||
"failed": "Falhou",
|
||||
"open": "Histórico",
|
||||
"openFullView": "Abrir visão completa de execuções",
|
||||
"loadError": "Erro ao Carregar Execuções",
|
||||
"loadRunError": "Erro ao Carregar Esta Execução"
|
||||
},
|
||||
@@ -2594,7 +2600,7 @@
|
||||
"triggerRequired": "Um Acionador é Obrigatório",
|
||||
"addStepRequired": "Adicione pelo Menos Uma Etapa Após o Acionador",
|
||||
"connectAllNodes": "Conecte Todos os Nós Antes de Salvar",
|
||||
"finishStepsBeforePublish": "Conclua a configuração de todas as etapas antes de publicar",
|
||||
"finishStepsBeforePublish": "{count} passo precisa de configuração antes de publicar — destacado no canvas. | {count} passos precisam de configuração antes de publicar — destacados no canvas.",
|
||||
"draft": "Rascunho",
|
||||
"saveDraft": "Salvar como rascunho",
|
||||
"publish": "Publicar",
|
||||
@@ -2708,6 +2714,11 @@
|
||||
"linkError": "Não foi possível vincular os alertas."
|
||||
},
|
||||
"chooseTrigger": "Escolher um Gatilho",
|
||||
"start": {
|
||||
"triggerHint": "Selecione o evento que inicia seu fluxo de trabalho",
|
||||
"chooseAction": "Escolha uma ação",
|
||||
"actionHint": "Selecione o que acontece após o gatilho"
|
||||
},
|
||||
"node": {
|
||||
"kindTrigger": "Gatilho",
|
||||
"kindLogic": "Lógica",
|
||||
@@ -2730,6 +2741,7 @@
|
||||
"incidentSpecificTitle": "Campos Específicos do Evento",
|
||||
"incidentNoExtraFields": "Este evento não adiciona campos além dos comuns.",
|
||||
"conditionNoteTitle": "Diretrizes de Condição",
|
||||
"functionFlattenTitle": "Antes vs Depois do Achatamento",
|
||||
"conditionNoteEmpty": "Para verificar um valor vazio, use \"\". Exemplo:",
|
||||
"conditionNoteNull": "Para verificar um valor nulo, use null. Exemplo:",
|
||||
"conditionNoteCustom": "Para adicionar uma coluna personalizada, digite seu nome e pressione Enter.",
|
||||
@@ -2755,19 +2767,56 @@
|
||||
"enable": "Ativar etapa",
|
||||
"disabledBadge": "Desativado",
|
||||
"incompleteBadge": "Configurar depois",
|
||||
"destinationSetUpLater": "Configurar depois",
|
||||
"destinationSetUpLaterPrompt": "Ainda não tem um destino? Ative esta opção para configurá-lo depois — você pode salvar um rascunho e testar agora.",
|
||||
"destinationSetUpLaterActive": "O destino é ignorado por enquanto — o nome e o comentário ainda são salvos. Você precisará de um destino antes de publicar.",
|
||||
"functionSetUpLater": "Configurar depois",
|
||||
"functionSetUpLaterPrompt": "Ainda não tem uma função? Ative esta opção para configurá-la depois — você pode salvar um rascunho e testar agora.",
|
||||
"functionSetUpLaterActive": "A função é ignorada por enquanto — o nome e o comentário ainda são salvos. Você precisará de uma função antes de publicar."
|
||||
"unsavedFnTitle": "Alterações não salvas na função",
|
||||
"unsavedFnMessage": "Esta etapa tem código de função que não está salvo na sua biblioteca. Salve-o, descarte as alterações ou continue editando.",
|
||||
"unsavedFnSave": "Salvar função",
|
||||
"unsavedFnDiscard": "Descartar alterações",
|
||||
"unsavedFnKeep": "Continuar editando"
|
||||
},
|
||||
"commonFieldsAbove": "campos comuns acima",
|
||||
"canvas": {
|
||||
"undo": "Desfazer",
|
||||
"tidyUp": "Organizar",
|
||||
"insertStep": "Inserir etapa",
|
||||
"deleteEdge": "Excluir conexão"
|
||||
"edgeDeleteHint": "Pressione Backspace/Delete para remover a aresta"
|
||||
},
|
||||
"ndv": {
|
||||
"input": "Entrada",
|
||||
"output": "Saída",
|
||||
"config": "Configuração",
|
||||
"noInput": "Nenhum registro de entrada.",
|
||||
"noOutput": "Nenhum registro encaminhado.",
|
||||
"prevStep": "Etapa anterior · {name}",
|
||||
"nextStep": "Próxima etapa · {name}",
|
||||
"prevStepBtn": "Etapa anterior",
|
||||
"nextStepBtn": "Próxima etapa",
|
||||
"collapseSteps": "Recolher etapas",
|
||||
"expandSteps": "Expandir etapas",
|
||||
"inputTag": "Entrada",
|
||||
"openStep": "Abrir {name}",
|
||||
"runToView": "Execute um teste para visualizar estes dados.",
|
||||
"noBranchItems": "Nenhum item neste ramo.",
|
||||
"noUpstream": "Nenhuma etapa anterior.",
|
||||
"noRunYet": "Nenhuma execução ainda.",
|
||||
"runLabel": "Executar",
|
||||
"switchRun": "Alternar execução",
|
||||
"notInRun": "Esta etapa não está incluída nesta execução.",
|
||||
"runTest": "Executar teste",
|
||||
"noItems": "Nenhum item",
|
||||
"itemsCount": "{count} Item | {count} Itens",
|
||||
"viewTable": "Tabela",
|
||||
"viewJson": "JSON",
|
||||
"itemN": "Item {n}",
|
||||
"executeStep": "Executar etapa",
|
||||
"sample": "Amostra",
|
||||
"editStep": "Editar etapa",
|
||||
"testInputHint": "Entrada de teste — Edite e depois execute a etapa.",
|
||||
"noRunsList": "Nenhuma execução ainda."
|
||||
},
|
||||
"runOverlay": {
|
||||
"banner": "Mostrando dados da etapa de uma execução passada ({run}). Você pode editar livremente — esta execução nunca é alterada.",
|
||||
"clear": "Limpar dados da execução",
|
||||
"ghostNodes": "{count} passo desta execução não existe mais no fluxo de trabalho, portanto seu status não é exibido. | {count} passos desta execução não existem mais no fluxo de trabalho, portanto seus status não são exibidos.",
|
||||
"missingNode": "Esse passo não existe mais neste fluxo de trabalho — ele foi removido após esta execução."
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Alterações não salvas",
|
||||
@@ -2781,7 +2830,20 @@
|
||||
"expand": "Expandir",
|
||||
"close": "Limpar e Fechar Resultados",
|
||||
"nodesTitle": "Etapas",
|
||||
"empty": "Execute um teste para ver os resultados das etapas aqui."
|
||||
"empty": "Execute um teste para ver os resultados das etapas aqui.",
|
||||
"openNode": "Abrir Nó",
|
||||
"viewConfig": "Ver Configuração",
|
||||
"hideConfig": "Ocultar Configuração",
|
||||
"noConfig": "Sem Configuração",
|
||||
"functionLabel": "Função",
|
||||
"destinationLabel": "Destino",
|
||||
"destinationUnsupported": "Tipo Não Suportado",
|
||||
"destinationMissing": "Este destino não existe mais ou não pode ser lido.",
|
||||
"headersLabel": "Cabeçalhos",
|
||||
"templateLabel": "Modelo",
|
||||
"outputFormatLabel": "Formato de Saída",
|
||||
"tlsVerifyLabel": "Verificação TLS",
|
||||
"tlsDisabled": "Desativado"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
|
||||
@@ -847,13 +847,17 @@
|
||||
"destination": {
|
||||
"createNew": "Создать нового получателя",
|
||||
"select": "Получатель",
|
||||
"loadError": "Ошибка при загрузке получателей."
|
||||
"loadError": "Ошибка при загрузке получателей.",
|
||||
"unsupportedType": "Неподдерживаемый тип — Рабочие процессы поддерживают только пользовательские назначения"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "Создать новую функцию",
|
||||
"createNewNoteWorkflow": "Примечание: Функция будет создана, после чего вы будете перенаправлены на предыдущую форму для связывания новой функции с рабочим процессом.",
|
||||
"select": "Выбрать функцию",
|
||||
"loadError": "Не удалось загрузить функции",
|
||||
"saved": "Функция успешно сохранена",
|
||||
"updated": "Функция успешно обновлена",
|
||||
"saveError": "Не удалось сохранить функцию",
|
||||
"flatten": "После упрощения",
|
||||
"guidelinesTitle": "Рекомендации по выполнению функции:",
|
||||
"rbf": "RBF (Запуск до упрощения):",
|
||||
@@ -2559,6 +2563,7 @@
|
||||
"runs": {
|
||||
"title": "Запуски рабочих процессов",
|
||||
"edit": "Редактировать рабочий процесс",
|
||||
"debugInEditor": "Отладить в редакторе",
|
||||
"staleGraphWarning": "Этот рабочий процесс был изменён с момента этого запуска — выполненный шаг ({count}) больше не существует, поэтому его статус здесь не показан. | Этот рабочий процесс был изменён с момента этого запуска — выполненные шаги ({count}) больше не существуют, поэтому их статус здесь не показан."
|
||||
},
|
||||
"history": {
|
||||
@@ -2576,6 +2581,7 @@
|
||||
"success": "Успех",
|
||||
"failed": "Не удалось",
|
||||
"open": "История",
|
||||
"openFullView": "Открыть полное представление запусков",
|
||||
"loadError": "Ошибка при загрузке выполнений",
|
||||
"loadRunError": "Ошибка при загрузке этого запуска"
|
||||
},
|
||||
@@ -2594,7 +2600,7 @@
|
||||
"triggerRequired": "Требуется триггер",
|
||||
"addStepRequired": "Добавьте хотя бы один шаг после триггера",
|
||||
"connectAllNodes": "Соедините все узлы перед сохранением",
|
||||
"finishStepsBeforePublish": "Завершите настройку всех шагов перед публикацией",
|
||||
"finishStepsBeforePublish": "{count} шаг требует настройки перед публикацией — выделен на холсте. | {count} шагов требуют настройки перед публикацией — выделены на холсте.",
|
||||
"draft": "Черновик",
|
||||
"saveDraft": "Сохранить как черновик",
|
||||
"publish": "Опубликовать",
|
||||
@@ -2708,6 +2714,11 @@
|
||||
"linkError": "Не удалось привязать оповещения."
|
||||
},
|
||||
"chooseTrigger": "Выберите триггер",
|
||||
"start": {
|
||||
"triggerHint": "Выберите событие, которое запускает ваш рабочий процесс",
|
||||
"chooseAction": "Выберите действие",
|
||||
"actionHint": "Выберите, что происходит после триггера"
|
||||
},
|
||||
"node": {
|
||||
"kindTrigger": "Триггер",
|
||||
"kindLogic": "Логика",
|
||||
@@ -2730,6 +2741,7 @@
|
||||
"incidentSpecificTitle": "Поля, специфичные для события",
|
||||
"incidentNoExtraFields": "Это событие не добавляет полей, кроме общих.",
|
||||
"conditionNoteTitle": "Рекомендации по условиям",
|
||||
"functionFlattenTitle": "До и после уплощения",
|
||||
"conditionNoteEmpty": "Чтобы проверить пустое значение, используйте \"\". Пример:",
|
||||
"conditionNoteNull": "Чтобы проверить значение null, используйте null. Пример:",
|
||||
"conditionNoteCustom": "Чтобы добавить пользовательский столбец, введите его имя и нажмите Enter.",
|
||||
@@ -2755,19 +2767,56 @@
|
||||
"enable": "Включить шаг",
|
||||
"disabledBadge": "Отключено",
|
||||
"incompleteBadge": "Настроить позже",
|
||||
"destinationSetUpLater": "Настроить позже",
|
||||
"destinationSetUpLaterPrompt": "Еще нет назначения? Включите эту опцию, чтобы настроить его позже — вы можете сохранить черновик и протестировать сейчас.",
|
||||
"destinationSetUpLaterActive": "Назначение пока пропускается — имя и комментарий сохраняются. Перед публикацией вам потребуется назначение.",
|
||||
"functionSetUpLater": "Настроить позже",
|
||||
"functionSetUpLaterPrompt": "Ещё нет функции? Включите это, чтобы настроить её позже — вы можете сохранить черновик и протестировать сейчас.",
|
||||
"functionSetUpLaterActive": "Функция пока пропускается — имя и комментарий всё равно сохраняются. Вам понадобится функция перед публикацией."
|
||||
"unsavedFnTitle": "Несохранённые изменения функции",
|
||||
"unsavedFnMessage": "Этот шаг содержит код функции, который не сохранён в вашей библиотеке. Сохраните его, отмените изменения или продолжайте редактирование.",
|
||||
"unsavedFnSave": "Сохранить функцию",
|
||||
"unsavedFnDiscard": "Отменить изменения",
|
||||
"unsavedFnKeep": "Продолжить редактирование"
|
||||
},
|
||||
"commonFieldsAbove": "общие поля выше",
|
||||
"canvas": {
|
||||
"undo": "Отменить",
|
||||
"tidyUp": "Упорядочить",
|
||||
"insertStep": "Вставить шаг",
|
||||
"deleteEdge": "Удалить соединение"
|
||||
"edgeDeleteHint": "Нажмите Backspace/Delete, чтобы удалить связь"
|
||||
},
|
||||
"ndv": {
|
||||
"input": "Вход",
|
||||
"output": "Выход",
|
||||
"config": "Конфигурация",
|
||||
"noInput": "Нет входных записей.",
|
||||
"noOutput": "Нет переданных записей.",
|
||||
"prevStep": "Предыдущий шаг · {name}",
|
||||
"nextStep": "Следующий шаг · {name}",
|
||||
"prevStepBtn": "Предыдущий шаг",
|
||||
"nextStepBtn": "Следующий шаг",
|
||||
"collapseSteps": "Свернуть шаги",
|
||||
"expandSteps": "Развернуть шаги",
|
||||
"inputTag": "Вход",
|
||||
"openStep": "Открыть {name}",
|
||||
"runToView": "Запустите тест, чтобы просмотреть эти данные.",
|
||||
"noBranchItems": "Нет элементов в этой ветке.",
|
||||
"noUpstream": "Нет вышестоящих шагов.",
|
||||
"noRunYet": "Запусков ещё не было.",
|
||||
"runLabel": "Запуск",
|
||||
"switchRun": "Переключить запуск",
|
||||
"notInRun": "Этот шаг не включён в этот запуск.",
|
||||
"runTest": "Запустить тест",
|
||||
"noItems": "Нет элементов",
|
||||
"itemsCount": "{count} элемент | {count} элементов",
|
||||
"viewTable": "Таблица",
|
||||
"viewJson": "JSON",
|
||||
"itemN": "Элемент {n}",
|
||||
"executeStep": "Выполнить шаг",
|
||||
"sample": "Пример",
|
||||
"editStep": "Редактировать шаг",
|
||||
"testInputHint": "Тестовый вход — отредактируйте, затем выполните шаг.",
|
||||
"noRunsList": "Запусков пока нет."
|
||||
},
|
||||
"runOverlay": {
|
||||
"banner": "Показаны данные шага из прошлого запуска ({run}). Вы можете свободно редактировать — этот запуск никогда не изменяется.",
|
||||
"clear": "Очистить данные запуска",
|
||||
"ghostNodes": "{count} шаг из этого запуска больше не существует в рабочем процессе, поэтому его статус не отображается. | {count} шагов из этого запуска больше не существуют в рабочем процессе, поэтому их статус не отображается.",
|
||||
"missingNode": "Этот шаг больше не существует в этом рабочем процессе — он был удален после этого запуска."
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Несохранённые изменения",
|
||||
@@ -2781,7 +2830,20 @@
|
||||
"expand": "Развернуть",
|
||||
"close": "Очистить и закрыть результаты",
|
||||
"nodesTitle": "Шаги",
|
||||
"empty": "Запустите тест, чтобы увидеть здесь результаты шагов."
|
||||
"empty": "Запустите тест, чтобы увидеть здесь результаты шагов.",
|
||||
"openNode": "Открыть узел",
|
||||
"viewConfig": "Просмотреть конфигурацию",
|
||||
"hideConfig": "Скрыть конфигурацию",
|
||||
"noConfig": "Нет конфигурации",
|
||||
"functionLabel": "Функция",
|
||||
"destinationLabel": "Назначение",
|
||||
"destinationUnsupported": "Неподдерживаемый тип",
|
||||
"destinationMissing": "Это назначение больше не существует или не может быть прочитано.",
|
||||
"headersLabel": "Заголовки",
|
||||
"templateLabel": "Шаблон",
|
||||
"outputFormatLabel": "Формат вывода",
|
||||
"tlsVerifyLabel": "Проверка TLS",
|
||||
"tlsDisabled": "Отключено"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
|
||||
@@ -847,13 +847,17 @@
|
||||
"destination": {
|
||||
"createNew": "Yeni Hedef Oluştur",
|
||||
"select": "Hedef",
|
||||
"loadError": "Hedefler getirilirken hata oluştu."
|
||||
"loadError": "Hedefler getirilirken hata oluştu.",
|
||||
"unsupportedType": "Desteklenmeyen Tür — İş Akışları Yalnızca Özel Hedefleri Destekler"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "Yeni İşlev Oluştur",
|
||||
"createNewNoteWorkflow": "Not: İşlev oluşturulacak, ardından yeni oluşturulan işlevi iş akışıyla ilişkilendirmek için önceki forma yönlendirileceksiniz.",
|
||||
"select": "İşlev Seç",
|
||||
"loadError": "İşlevler yüklenemedi",
|
||||
"saved": "Fonksiyon Başarıyla Kaydedildi",
|
||||
"updated": "Fonksiyon Başarıyla Güncellendi",
|
||||
"saveError": "Fonksiyon Kaydedilemedi",
|
||||
"flatten": "Düzleştirmeden Sonra",
|
||||
"guidelinesTitle": "İşlev Yürütme Yönergeleri:",
|
||||
"rbf": "RBF (Düzleştirmeden Önce Çalıştır):",
|
||||
@@ -2559,6 +2563,7 @@
|
||||
"runs": {
|
||||
"title": "İş Akışı Çalıştırmaları",
|
||||
"edit": "İş Akışını Düzenle",
|
||||
"debugInEditor": "Düzenleyicide Hata Ayıkla",
|
||||
"staleGraphWarning": "Bu iş akışı bu çalıştırmadan bu yana düzenlendi — yürüttüğü {count} adım artık mevcut değil, bu yüzden durumu burada gösterilmiyor. | Bu iş akışı bu çalıştırmadan bu yana düzenlendi — yürüttüğü {count} adım artık mevcut değil, bu yüzden durumu burada gösterilmiyor."
|
||||
},
|
||||
"history": {
|
||||
@@ -2576,6 +2581,7 @@
|
||||
"success": "Başarılı",
|
||||
"failed": "Başarısız",
|
||||
"open": "Geçmiş",
|
||||
"openFullView": "Çalıştırmaların Tam Görünümünü Aç",
|
||||
"loadError": "Yürütmeler Yüklenirken Hata",
|
||||
"loadRunError": "Bu Çalıştırma Yüklenirken Hata"
|
||||
},
|
||||
@@ -2594,7 +2600,7 @@
|
||||
"triggerRequired": "Bir Tetikleyici Gerekli",
|
||||
"addStepRequired": "Tetikleyiciden Sonra En Az Bir Adım Ekleyin",
|
||||
"connectAllNodes": "Kaydetmeden Önce Tüm Düğümleri Bağlayın",
|
||||
"finishStepsBeforePublish": "Yayınlamadan önce tüm adımları yapılandırmayı bitirin",
|
||||
"finishStepsBeforePublish": "{count} adım yayınlamadan önce kurulum gerektiriyor — tuval üzerinde vurgulanır. | {count} adım yayınlamadan önce kurulum gerektiriyor — tuval üzerinde vurgulanır.",
|
||||
"draft": "Taslak",
|
||||
"saveDraft": "Taslak Olarak Kaydet",
|
||||
"publish": "Yayınla",
|
||||
@@ -2708,6 +2714,11 @@
|
||||
"linkError": "Uyarılar Bağlanamıyor."
|
||||
},
|
||||
"chooseTrigger": "Tetikleyici Seç",
|
||||
"start": {
|
||||
"triggerHint": "İş akışınızı başlatan olayı seçin",
|
||||
"chooseAction": "Bir Eylem Seçin",
|
||||
"actionHint": "Tetikleyiciden sonra ne olacağını seçin"
|
||||
},
|
||||
"node": {
|
||||
"kindTrigger": "Tetikleyici",
|
||||
"kindLogic": "Mantık",
|
||||
@@ -2730,6 +2741,7 @@
|
||||
"incidentSpecificTitle": "Olay Özel Alanları",
|
||||
"incidentNoExtraFields": "Bu olay, ortak alanların dışında herhangi bir alan eklemez.",
|
||||
"conditionNoteTitle": "Koşul Yönergeleri",
|
||||
"functionFlattenTitle": "Düzleştirme Öncesi ve Sonrası",
|
||||
"conditionNoteEmpty": "Boş Bir Değer Kontrol Etmek İçin \"\" Kullanın. Örnek:",
|
||||
"conditionNoteNull": "Null Bir Değer Kontrol Etmek İçin null Kullanın. Örnek:",
|
||||
"conditionNoteCustom": "Özel Bir Sütun Eklemek İçin Adını Yazın ve Enter'a Basın.",
|
||||
@@ -2755,19 +2767,56 @@
|
||||
"enable": "Adımı Etkinleştir",
|
||||
"disabledBadge": "Devre Dışı",
|
||||
"incompleteBadge": "Daha Sonra Kur",
|
||||
"destinationSetUpLater": "Daha Sonra Kur",
|
||||
"destinationSetUpLaterPrompt": "Henüz bir hedefiniz yok mu? Daha sonra ayarlamak için bunu açın — şimdi bir taslak kaydedip test edebilirsiniz.",
|
||||
"destinationSetUpLaterActive": "Hedef şimdilik atlanıyor — ad ve yorum yine de kaydedilir. Yayınlamadan önce bir hedefe ihtiyacınız olacak.",
|
||||
"functionSetUpLater": "Daha Sonra Kur",
|
||||
"functionSetUpLaterPrompt": "Henüz bir fonksiyonunuz yok mu? Bunu açın ve daha sonra ayarlayın — şimdi taslak kaydedip test edebilirsiniz.",
|
||||
"functionSetUpLaterActive": "Fonksiyon şimdilik atlanıyor — ad ve yorum yine de kaydedilir. Yayınlamadan önce bir fonksiyona ihtiyacınız olacak."
|
||||
"unsavedFnTitle": "Kaydedilmemiş Fonksiyon Değişiklikleri",
|
||||
"unsavedFnMessage": "Bu adım, kitaplığınıza kaydedilmemiş fonksiyon kodu içeriyor. Kaydedin, değişiklikleri atın veya düzenlemeye devam edin.",
|
||||
"unsavedFnSave": "Fonksiyonu Kaydet",
|
||||
"unsavedFnDiscard": "Değişiklikleri At",
|
||||
"unsavedFnKeep": "Düzenlemeye Devam Et"
|
||||
},
|
||||
"commonFieldsAbove": "yukarıdaki ortak alanlar",
|
||||
"canvas": {
|
||||
"undo": "Geri Al",
|
||||
"tidyUp": "Toparla",
|
||||
"insertStep": "Adım Ekle",
|
||||
"deleteEdge": "Bağlantıyı Sil"
|
||||
"edgeDeleteHint": "Bağlantıyı kaldırmak için Backspace/Delete tuşuna basın"
|
||||
},
|
||||
"ndv": {
|
||||
"input": "Girdi",
|
||||
"output": "Çıktı",
|
||||
"config": "Yapılandırma",
|
||||
"noInput": "Girdi kaydı yok.",
|
||||
"noOutput": "İletilen kayıt yok.",
|
||||
"prevStep": "Önceki Adım · {name}",
|
||||
"nextStep": "Sonraki Adım · {name}",
|
||||
"prevStepBtn": "Önceki Adım",
|
||||
"nextStepBtn": "Sonraki Adım",
|
||||
"collapseSteps": "Adımları Daralt",
|
||||
"expandSteps": "Adımları Genişlet",
|
||||
"inputTag": "Girdi",
|
||||
"openStep": "Aç {name}",
|
||||
"runToView": "Bu Veriyi Görüntülemek İçin Bir Test Çalıştırın.",
|
||||
"noBranchItems": "Bu Daldaki Öğe Yok.",
|
||||
"noUpstream": "Yukarı Akış Adımı Yok.",
|
||||
"noRunYet": "Henüz Çalıştırma Yok.",
|
||||
"runLabel": "Çalıştır",
|
||||
"switchRun": "Çalıştırmayı Değiştir",
|
||||
"notInRun": "Bu Adım Bu Çalıştırmaya Dahil Değil.",
|
||||
"runTest": "Testi Çalıştır",
|
||||
"noItems": "Öğe Yok",
|
||||
"itemsCount": "{count} Öğe | {count} Öğe",
|
||||
"viewTable": "Tablo",
|
||||
"viewJson": "JSON",
|
||||
"itemN": "Öğe {n}",
|
||||
"executeStep": "Adımı Çalıştır",
|
||||
"sample": "Örnek",
|
||||
"editStep": "Adımı Düzenle",
|
||||
"testInputHint": "Test Girdisi — Düzenleyin, Sonra Adımı Çalıştırın.",
|
||||
"noRunsList": "Henüz Çalıştırma Yok."
|
||||
},
|
||||
"runOverlay": {
|
||||
"banner": "Geçmiş bir çalıştırmadaki ({run}) adım verileri gösteriliyor. Serbestçe düzenleyebilirsiniz — bu çalıştırma asla değiştirilmez.",
|
||||
"clear": "Çalıştırma Verilerini Temizle",
|
||||
"ghostNodes": "{count} adım bu çalıştırmadan artık iş akışında mevcut değil, bu yüzden durumu gösterilmiyor. | {count} adım bu çalıştırmadan artık iş akışında mevcut değil, bu yüzden durumları gösterilmiyor.",
|
||||
"missingNode": "Bu adım artık bu iş akışında mevcut değil — bu çalıştırmadan sonra kaldırıldı."
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Kaydedilmemiş Değişiklikler",
|
||||
@@ -2781,7 +2830,20 @@
|
||||
"expand": "Genişlet",
|
||||
"close": "Sonuçları Temizle ve Kapat",
|
||||
"nodesTitle": "Adımlar",
|
||||
"empty": "Burada adım sonuçlarını görmek için bir test çalıştırın."
|
||||
"empty": "Burada adım sonuçlarını görmek için bir test çalıştırın.",
|
||||
"openNode": "Düğümü Aç",
|
||||
"viewConfig": "Yapılandırmayı Görüntüle",
|
||||
"hideConfig": "Yapılandırmayı Gizle",
|
||||
"noConfig": "Yapılandırma Yok",
|
||||
"functionLabel": "İşlev",
|
||||
"destinationLabel": "Hedef",
|
||||
"destinationUnsupported": "Desteklenmeyen Tür",
|
||||
"destinationMissing": "Bu hedef artık mevcut değil veya okunamıyor.",
|
||||
"headersLabel": "Başlıklar",
|
||||
"templateLabel": "Şablon",
|
||||
"outputFormatLabel": "Çıktı Biçimi",
|
||||
"tlsVerifyLabel": "TLS Doğrulaması",
|
||||
"tlsDisabled": "Devre Dışı"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
|
||||
@@ -847,13 +847,17 @@
|
||||
"destination": {
|
||||
"createNew": "Tạo đích mới",
|
||||
"select": "Đích",
|
||||
"loadError": "Có lỗi khi lấy danh sách đích."
|
||||
"loadError": "Có lỗi khi lấy danh sách đích.",
|
||||
"unsupportedType": "Loại không được hỗ trợ — Quy trình làm việc chỉ hỗ trợ đích tùy chỉnh"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "Tạo hàm mới",
|
||||
"createNewNoteWorkflow": "Lưu ý: Hàm sẽ được tạo, sau đó bạn sẽ được chuyển hướng đến biểu mẫu trước đó để liên kết hàm mới tạo với quy trình làm việc.",
|
||||
"select": "Chọn hàm",
|
||||
"loadError": "Tải hàm thất bại",
|
||||
"saved": "Đã lưu hàm thành công",
|
||||
"updated": "Đã cập nhật hàm thành công",
|
||||
"saveError": "Không thể lưu hàm",
|
||||
"flatten": "Sau khi làm phẳng",
|
||||
"guidelinesTitle": "Hướng dẫn thực thi hàm:",
|
||||
"rbf": "RBF (Chạy trước khi làm phẳng):",
|
||||
@@ -2559,6 +2563,7 @@
|
||||
"runs": {
|
||||
"title": "Lần chạy quy trình",
|
||||
"edit": "Sửa quy trình",
|
||||
"debugInEditor": "Gỡ lỗi trong trình chỉnh sửa",
|
||||
"staleGraphWarning": "Workflow này đã được chỉnh sửa kể từ lần chạy này — {count} bước mà nó thực hiện không còn tồn tại, vì vậy trạng thái tương ứng không được hiển thị ở đây. | Workflow này đã được chỉnh sửa kể từ lần chạy này — {count} bước mà nó thực hiện không còn tồn tại, vì vậy trạng thái tương ứng không được hiển thị ở đây."
|
||||
},
|
||||
"history": {
|
||||
@@ -2576,6 +2581,7 @@
|
||||
"success": "Thành công",
|
||||
"failed": "Thất bại",
|
||||
"open": "Lịch sử",
|
||||
"openFullView": "Mở chế độ xem đầy đủ các lần chạy",
|
||||
"loadError": "Lỗi khi tải các lần thực thi",
|
||||
"loadRunError": "Lỗi khi tải lần chạy này"
|
||||
},
|
||||
@@ -2594,7 +2600,7 @@
|
||||
"triggerRequired": "Cần có một bộ kích hoạt",
|
||||
"addStepRequired": "Thêm ít nhất một bước sau bộ kích hoạt",
|
||||
"connectAllNodes": "Kết nối tất cả các nút trước khi lưu",
|
||||
"finishStepsBeforePublish": "Hoàn tất cấu hình tất cả các bước trước khi xuất bản",
|
||||
"finishStepsBeforePublish": "{count} bước cần được thiết lập trước khi xuất bản — được đánh dấu trên canvas. | {count} bước cần được thiết lập trước khi xuất bản — được đánh dấu trên canvas.",
|
||||
"draft": "Bản nháp",
|
||||
"saveDraft": "Lưu thành bản nháp",
|
||||
"publish": "Xuất bản",
|
||||
@@ -2708,6 +2714,11 @@
|
||||
"linkError": "Không thể liên kết cảnh báo."
|
||||
},
|
||||
"chooseTrigger": "Chọn một trình kích hoạt",
|
||||
"start": {
|
||||
"triggerHint": "Chọn sự kiện bắt đầu quy trình làm việc của bạn",
|
||||
"chooseAction": "Chọn một hành động",
|
||||
"actionHint": "Chọn điều gì xảy ra sau khi kích hoạt"
|
||||
},
|
||||
"node": {
|
||||
"kindTrigger": "Trình kích hoạt",
|
||||
"kindLogic": "Logic",
|
||||
@@ -2730,6 +2741,7 @@
|
||||
"incidentSpecificTitle": "Các trường cụ thể cho sự kiện",
|
||||
"incidentNoExtraFields": "Sự kiện này không thêm trường nào ngoài các trường chung.",
|
||||
"conditionNoteTitle": "Hướng dẫn điều kiện",
|
||||
"functionFlattenTitle": "Trước và sau khi làm phẳng",
|
||||
"conditionNoteEmpty": "Để kiểm tra giá trị rỗng, sử dụng \"\". Ví dụ:",
|
||||
"conditionNoteNull": "Để kiểm tra giá trị null, sử dụng null. Ví dụ:",
|
||||
"conditionNoteCustom": "Để thêm cột tùy chỉnh, nhập tên của nó và nhấn Enter.",
|
||||
@@ -2755,19 +2767,56 @@
|
||||
"enable": "Kích hoạt bước",
|
||||
"disabledBadge": "Đã tắt",
|
||||
"incompleteBadge": "Thiết lập sau",
|
||||
"destinationSetUpLater": "Thiết lập sau",
|
||||
"destinationSetUpLaterPrompt": "Chưa có đích? Bật tùy chọn này để thiết lập sau — bạn có thể lưu bản nháp và kiểm tra ngay bây giờ.",
|
||||
"destinationSetUpLaterActive": "Đích hiện đang được bỏ qua — tên và ghi chú vẫn được lưu. Bạn cần có một đích trước khi xuất bản.",
|
||||
"functionSetUpLater": "Thiết lập sau",
|
||||
"functionSetUpLaterPrompt": "Chưa có hàm? Bật tùy chọn này để thiết lập sau — bạn có thể lưu bản nháp và kiểm tra ngay bây giờ.",
|
||||
"functionSetUpLaterActive": "Hàm sẽ được bỏ qua trong lúc này — tên và nhận xét vẫn được lưu. Bạn cần một hàm trước khi xuất bản."
|
||||
"unsavedFnTitle": "Thay đổi hàm chưa lưu",
|
||||
"unsavedFnMessage": "Bước này có mã hàm chưa được lưu vào thư viện của bạn. Lưu nó, hủy thay đổi, hoặc tiếp tục chỉnh sửa.",
|
||||
"unsavedFnSave": "Lưu hàm",
|
||||
"unsavedFnDiscard": "Hủy thay đổi",
|
||||
"unsavedFnKeep": "Tiếp tục chỉnh sửa"
|
||||
},
|
||||
"commonFieldsAbove": "các trường chung ở trên",
|
||||
"canvas": {
|
||||
"undo": "Hoàn tác",
|
||||
"tidyUp": "Dọn dẹp",
|
||||
"insertStep": "Chèn bước",
|
||||
"deleteEdge": "Xóa kết nối"
|
||||
"edgeDeleteHint": "Nhấn Backspace/Delete để xóa đường nối"
|
||||
},
|
||||
"ndv": {
|
||||
"input": "Đầu vào",
|
||||
"output": "Đầu ra",
|
||||
"config": "Cấu hình",
|
||||
"noInput": "Không có bản ghi đầu vào.",
|
||||
"noOutput": "Không có bản ghi nào được chuyển tiếp.",
|
||||
"prevStep": "Bước trước · {name}",
|
||||
"nextStep": "Bước tiếp theo · {name}",
|
||||
"prevStepBtn": "Bước trước",
|
||||
"nextStepBtn": "Bước tiếp theo",
|
||||
"collapseSteps": "Thu gọn các bước",
|
||||
"expandSteps": "Mở rộng các bước",
|
||||
"inputTag": "Đầu vào",
|
||||
"openStep": "Mở {name}",
|
||||
"runToView": "Chạy thử để xem dữ liệu này.",
|
||||
"noBranchItems": "Không có mục nào trên nhánh này.",
|
||||
"noUpstream": "Không có bước thượng nguồn.",
|
||||
"noRunYet": "Chưa có lần chạy nào.",
|
||||
"runLabel": "Chạy",
|
||||
"switchRun": "Chuyển lần chạy",
|
||||
"notInRun": "Bước này không nằm trong lần chạy này.",
|
||||
"runTest": "Chạy thử",
|
||||
"noItems": "Không có mục nào",
|
||||
"itemsCount": "{count} mục | {count} mục",
|
||||
"viewTable": "Bảng",
|
||||
"viewJson": "JSON",
|
||||
"itemN": "Mục {n}",
|
||||
"executeStep": "Chạy bước",
|
||||
"sample": "Mẫu",
|
||||
"editStep": "Chỉnh sửa bước",
|
||||
"testInputHint": "Đầu vào thử nghiệm — Chỉnh sửa, sau đó chạy bước.",
|
||||
"noRunsList": "Chưa có lần chạy nào."
|
||||
},
|
||||
"runOverlay": {
|
||||
"banner": "Đang hiển thị dữ liệu bước từ lần chạy trước ({run}). Bạn có thể chỉnh sửa tự do — lần chạy này sẽ không bao giờ bị thay đổi.",
|
||||
"clear": "Xóa dữ liệu lần chạy",
|
||||
"ghostNodes": "{count} bước từ lần chạy này không còn tồn tại trong quy trình nữa, nên trạng thái tương ứng không được hiển thị. | {count} bước từ lần chạy này không còn tồn tại trong quy trình nữa, nên trạng thái tương ứng không được hiển thị.",
|
||||
"missingNode": "Bước đó không còn tồn tại trong quy trình này — nó đã bị xóa sau lần chạy này."
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Các thay đổi chưa lưu",
|
||||
@@ -2781,7 +2830,20 @@
|
||||
"expand": "Mở rộng",
|
||||
"close": "Xóa & Đóng Kết Quả",
|
||||
"nodesTitle": "Các bước",
|
||||
"empty": "Chạy một bài kiểm tra để xem kết quả các bước tại đây."
|
||||
"empty": "Chạy một bài kiểm tra để xem kết quả các bước tại đây.",
|
||||
"openNode": "Mở Node",
|
||||
"viewConfig": "Xem cấu hình",
|
||||
"hideConfig": "Ẩn cấu hình",
|
||||
"noConfig": "Không có cấu hình",
|
||||
"functionLabel": "Hàm",
|
||||
"destinationLabel": "Đích",
|
||||
"destinationUnsupported": "Loại không được hỗ trợ",
|
||||
"destinationMissing": "Đích này không còn tồn tại hoặc không thể đọc được.",
|
||||
"headersLabel": "Header",
|
||||
"templateLabel": "Mẫu",
|
||||
"outputFormatLabel": "Định dạng đầu ra",
|
||||
"tlsVerifyLabel": "Xác minh TLS",
|
||||
"tlsDisabled": "Đã tắt"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
|
||||
@@ -847,13 +847,17 @@
|
||||
"destination": {
|
||||
"createNew": "创建新目标",
|
||||
"select": "目标",
|
||||
"loadError": "获取目标时出错。"
|
||||
"loadError": "获取目标时出错。",
|
||||
"unsupportedType": "不支持的类型 — 工作流仅支持自定义目标"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "创建新函数",
|
||||
"createNewNoteWorkflow": "注意:函数将被创建,然后您将被重定向到之前的表单,将新创建的函数与工作流关联。",
|
||||
"select": "选择函数",
|
||||
"loadError": "加载函数失败",
|
||||
"saved": "函数保存成功",
|
||||
"updated": "函数更新成功",
|
||||
"saveError": "保存函数失败",
|
||||
"flatten": "展平后",
|
||||
"guidelinesTitle": "函数执行指南:",
|
||||
"rbf": "RBF(展平前运行):",
|
||||
@@ -2559,6 +2563,7 @@
|
||||
"runs": {
|
||||
"title": "工作流运行",
|
||||
"edit": "编辑工作流",
|
||||
"debugInEditor": "在编辑器中调试",
|
||||
"staleGraphWarning": "此工作流自本次运行后已被编辑 — {count} 个已执行的步骤不再存在,因此此处不显示其状态。 | 此工作流自本次运行后已被编辑 — {count} 个已执行的步骤不再存在,因此此处不显示其状态。"
|
||||
},
|
||||
"history": {
|
||||
@@ -2576,6 +2581,7 @@
|
||||
"success": "成功",
|
||||
"failed": "失败",
|
||||
"open": "历史",
|
||||
"openFullView": "打开完整运行视图",
|
||||
"loadError": "加载执行时出错",
|
||||
"loadRunError": "加载本次运行时出错"
|
||||
},
|
||||
@@ -2594,7 +2600,7 @@
|
||||
"triggerRequired": "需要触发器",
|
||||
"addStepRequired": "在触发器后至少添加一个步骤",
|
||||
"connectAllNodes": "保存前连接所有节点",
|
||||
"finishStepsBeforePublish": "完成所有步骤配置后再发布",
|
||||
"finishStepsBeforePublish": "{count} 个步骤在发布前需要设置 — 已在画布上高亮显示。 | {count} 个步骤在发布前需要设置 — 已在画布上高亮显示。",
|
||||
"draft": "草稿",
|
||||
"saveDraft": "保存为草稿",
|
||||
"publish": "发布",
|
||||
@@ -2708,6 +2714,11 @@
|
||||
"linkError": "无法链接告警。"
|
||||
},
|
||||
"chooseTrigger": "选择触发器",
|
||||
"start": {
|
||||
"triggerHint": "选择触发工作流的事件",
|
||||
"chooseAction": "选择一个操作",
|
||||
"actionHint": "选择触发后执行的操作"
|
||||
},
|
||||
"node": {
|
||||
"kindTrigger": "触发器",
|
||||
"kindLogic": "逻辑",
|
||||
@@ -2730,6 +2741,7 @@
|
||||
"incidentSpecificTitle": "事件特定字段",
|
||||
"incidentNoExtraFields": "此事件除了公共字段外,不添加任何字段。",
|
||||
"conditionNoteTitle": "条件指南",
|
||||
"functionFlattenTitle": "展平前后对比",
|
||||
"conditionNoteEmpty": "要检查空值,请使用 \"\"。例如:",
|
||||
"conditionNoteNull": "要检查空值,请使用 null。例如:",
|
||||
"conditionNoteCustom": "要添加自定义列,请键入其名称并按回车。",
|
||||
@@ -2755,19 +2767,56 @@
|
||||
"enable": "启用步骤",
|
||||
"disabledBadge": "已禁用",
|
||||
"incompleteBadge": "稍后设置",
|
||||
"destinationSetUpLater": "稍后设置",
|
||||
"destinationSetUpLaterPrompt": "还没有目标?开启此项可稍后设置 — 现在即可保存草稿并测试。",
|
||||
"destinationSetUpLaterActive": "目标暂时跳过 — 名称和备注仍会保存。发布前需要先提供目标。",
|
||||
"functionSetUpLater": "稍后设置",
|
||||
"functionSetUpLaterPrompt": "还没有函数?开启此项可稍后设置 — 现在即可保存草稿并测试。",
|
||||
"functionSetUpLaterActive": "函数暂时跳过 — 名称和备注仍会保存。发布前需要先提供函数。"
|
||||
"unsavedFnTitle": "未保存的函数更改",
|
||||
"unsavedFnMessage": "此步骤包含未保存到函数库的函数代码。请保存、放弃更改或继续编辑。",
|
||||
"unsavedFnSave": "保存函数",
|
||||
"unsavedFnDiscard": "放弃更改",
|
||||
"unsavedFnKeep": "继续编辑"
|
||||
},
|
||||
"commonFieldsAbove": "以上为公共字段",
|
||||
"canvas": {
|
||||
"undo": "撤销",
|
||||
"tidyUp": "整理",
|
||||
"insertStep": "插入步骤",
|
||||
"deleteEdge": "删除连接"
|
||||
"edgeDeleteHint": "按 Backspace/Delete 键删除连线"
|
||||
},
|
||||
"ndv": {
|
||||
"input": "输入",
|
||||
"output": "输出",
|
||||
"config": "配置",
|
||||
"noInput": "没有输入记录。",
|
||||
"noOutput": "没有转发的记录。",
|
||||
"prevStep": "上一步 · {name}",
|
||||
"nextStep": "下一步 · {name}",
|
||||
"prevStepBtn": "上一步",
|
||||
"nextStepBtn": "下一步",
|
||||
"collapseSteps": "折叠步骤",
|
||||
"expandSteps": "展开步骤",
|
||||
"inputTag": "输入",
|
||||
"openStep": "打开 {name}",
|
||||
"runToView": "运行测试以查看此数据。",
|
||||
"noBranchItems": "此分支上没有条目。",
|
||||
"noUpstream": "没有上游步骤。",
|
||||
"noRunYet": "尚未运行。",
|
||||
"runLabel": "运行",
|
||||
"switchRun": "切换运行",
|
||||
"notInRun": "此步骤未包含在此次运行中。",
|
||||
"runTest": "运行测试",
|
||||
"noItems": "没有条目",
|
||||
"itemsCount": "{count} 个条目 | {count} 个条目",
|
||||
"viewTable": "表格",
|
||||
"viewJson": "JSON",
|
||||
"itemN": "条目 {n}",
|
||||
"executeStep": "运行步骤",
|
||||
"sample": "样本",
|
||||
"editStep": "编辑步骤",
|
||||
"testInputHint": "测试输入 — 编辑,然后运行步骤。",
|
||||
"noRunsList": "尚无运行记录。"
|
||||
},
|
||||
"runOverlay": {
|
||||
"banner": "正在显示来自历史运行({run})的步骤数据。您可以自由编辑 — 此运行不会被更改。",
|
||||
"clear": "清除运行数据",
|
||||
"ghostNodes": "{count} 个来自此运行的步骤已不再存在于工作流中,因此其状态不显示。 | {count} 个来自此运行的步骤已不再存在于工作流中,因此其状态不显示。",
|
||||
"missingNode": "该步骤已不再存在于该工作流中 — 它已在此运行后被移除。"
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "未保存的更改",
|
||||
@@ -2781,7 +2830,20 @@
|
||||
"expand": "展开",
|
||||
"close": "清除并关闭结果",
|
||||
"nodesTitle": "步骤",
|
||||
"empty": "运行测试以在此处查看步骤结果。"
|
||||
"empty": "运行测试以在此处查看步骤结果。",
|
||||
"openNode": "打开节点",
|
||||
"viewConfig": "查看配置",
|
||||
"hideConfig": "隐藏配置",
|
||||
"noConfig": "无配置",
|
||||
"functionLabel": "函数",
|
||||
"destinationLabel": "目标",
|
||||
"destinationUnsupported": "不支持的类型",
|
||||
"destinationMissing": "该目标已不存在或无法读取。",
|
||||
"headersLabel": "标头",
|
||||
"templateLabel": "模板",
|
||||
"outputFormatLabel": "输出格式",
|
||||
"tlsVerifyLabel": "TLS 验证",
|
||||
"tlsDisabled": "已禁用"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
|
||||
@@ -847,13 +847,17 @@
|
||||
"destination": {
|
||||
"createNew": "建立新目標",
|
||||
"select": "目標",
|
||||
"loadError": "載入目標時發生錯誤。"
|
||||
"loadError": "載入目標時發生錯誤。",
|
||||
"unsupportedType": "不支援的類型 — 工作流程僅支援自訂目標"
|
||||
},
|
||||
"function": {
|
||||
"createNew": "建立新函式",
|
||||
"createNewNoteWorkflow": "注意:函式將會建立,然後您將被重新導向至先前的表單,以將新建立的函式與工作流程關聯。",
|
||||
"select": "選取函式",
|
||||
"loadError": "無法載入函式",
|
||||
"saved": "函式已成功儲存",
|
||||
"updated": "函式已成功更新",
|
||||
"saveError": "儲存函式失敗",
|
||||
"flatten": "扁平化後",
|
||||
"guidelinesTitle": "函式執行指南:",
|
||||
"rbf": "RBF(扁平化前執行):",
|
||||
@@ -2559,6 +2563,7 @@
|
||||
"runs": {
|
||||
"title": "工作流程執行記錄",
|
||||
"edit": "編輯工作流程",
|
||||
"debugInEditor": "在編輯器中除錯",
|
||||
"staleGraphWarning": "此工作流程自本次執行後已遭編輯 — 其執行的 {count} 個步驟已不再存在,因此此處未顯示其狀態。 | 此工作流程自本次執行後已遭編輯 — 其執行的 {count} 個步驟已不再存在,因此此處未顯示其狀態。"
|
||||
},
|
||||
"history": {
|
||||
@@ -2576,6 +2581,7 @@
|
||||
"success": "成功",
|
||||
"failed": "失敗",
|
||||
"open": "歷史",
|
||||
"openFullView": "開啟完整執行檢視",
|
||||
"loadError": "載入執行記錄時發生錯誤",
|
||||
"loadRunError": "載入此次執行時發生錯誤"
|
||||
},
|
||||
@@ -2594,7 +2600,7 @@
|
||||
"triggerRequired": "需要觸發器",
|
||||
"addStepRequired": "在觸發器之後至少新增一個步驟",
|
||||
"connectAllNodes": "儲存前連接所有節點",
|
||||
"finishStepsBeforePublish": "完成所有步驟的設定後再發布",
|
||||
"finishStepsBeforePublish": "{count} 個步驟需要在發布前設定 — 已在畫布上標示。 | {count} 個步驟需要在發布前設定 — 已在畫布上標示。",
|
||||
"draft": "草稿",
|
||||
"saveDraft": "儲存為草稿",
|
||||
"publish": "發布",
|
||||
@@ -2708,6 +2714,11 @@
|
||||
"linkError": "無法連結警示。"
|
||||
},
|
||||
"chooseTrigger": "選擇觸發器",
|
||||
"start": {
|
||||
"triggerHint": "選擇啟動工作流程的事件",
|
||||
"chooseAction": "選擇動作",
|
||||
"actionHint": "選擇觸發後要進行的動作"
|
||||
},
|
||||
"node": {
|
||||
"kindTrigger": "觸發器",
|
||||
"kindLogic": "邏輯",
|
||||
@@ -2730,6 +2741,7 @@
|
||||
"incidentSpecificTitle": "事件特定欄位",
|
||||
"incidentNoExtraFields": "此事件除了常見欄位外,不會新增任何欄位。",
|
||||
"conditionNoteTitle": "條件指南",
|
||||
"functionFlattenTitle": "扁平化前後比較",
|
||||
"conditionNoteEmpty": "若要檢查空值,請使用 \"\"。範例:",
|
||||
"conditionNoteNull": "若要檢查 null 值,請使用 null。範例:",
|
||||
"conditionNoteCustom": "若要新增自訂欄位,請輸入其名稱並按 Enter。",
|
||||
@@ -2755,19 +2767,56 @@
|
||||
"enable": "啟用步驟",
|
||||
"disabledBadge": "已停用",
|
||||
"incompleteBadge": "稍後設定",
|
||||
"destinationSetUpLater": "稍後設定",
|
||||
"destinationSetUpLaterPrompt": "還沒有目標嗎?開啟此選項以便稍後設定 — 您現在可以儲存草稿並進行測試。",
|
||||
"destinationSetUpLaterActive": "目前會略過此目標 — 名稱與註解仍會儲存。發布前您需要設定目標。",
|
||||
"functionSetUpLater": "稍後設定",
|
||||
"functionSetUpLaterPrompt": "還沒有函數?開啟此選項以稍後設定 — 您現在可以儲存草稿並進行測試。",
|
||||
"functionSetUpLaterActive": "函數目前會略過 — 名稱和備註仍會儲存。發布前您需要設定函數。"
|
||||
"unsavedFnTitle": "未儲存的函式變更",
|
||||
"unsavedFnMessage": "此步驟包含尚未儲存至您的函式庫的函式程式碼。您可以儲存、捨棄變更,或繼續編輯。",
|
||||
"unsavedFnSave": "儲存函式",
|
||||
"unsavedFnDiscard": "捨棄變更",
|
||||
"unsavedFnKeep": "繼續編輯"
|
||||
},
|
||||
"commonFieldsAbove": "上方的通用欄位",
|
||||
"canvas": {
|
||||
"undo": "復原",
|
||||
"tidyUp": "整理",
|
||||
"insertStep": "插入步驟",
|
||||
"deleteEdge": "刪除連線"
|
||||
"edgeDeleteHint": "按 Backspace/Delete 鍵以移除連線"
|
||||
},
|
||||
"ndv": {
|
||||
"input": "輸入",
|
||||
"output": "輸出",
|
||||
"config": "組態",
|
||||
"noInput": "沒有輸入記錄。",
|
||||
"noOutput": "沒有轉發的記錄。",
|
||||
"prevStep": "上一個步驟 · {name}",
|
||||
"nextStep": "下一個步驟 · {name}",
|
||||
"prevStepBtn": "上一個步驟",
|
||||
"nextStepBtn": "下一個步驟",
|
||||
"collapseSteps": "摺疊步驟",
|
||||
"expandSteps": "展開步驟",
|
||||
"inputTag": "輸入",
|
||||
"openStep": "開啟 {name}",
|
||||
"runToView": "執行測試以檢視此資料。",
|
||||
"noBranchItems": "此分支上沒有項目。",
|
||||
"noUpstream": "沒有上游步驟。",
|
||||
"noRunYet": "尚未執行。",
|
||||
"runLabel": "執行",
|
||||
"switchRun": "切換執行",
|
||||
"notInRun": "此步驟未包含在此次執行中。",
|
||||
"runTest": "執行測試",
|
||||
"noItems": "沒有項目",
|
||||
"itemsCount": "{count} 個項目 | {count} 個項目",
|
||||
"viewTable": "表格",
|
||||
"viewJson": "JSON",
|
||||
"itemN": "項目 {n}",
|
||||
"executeStep": "執行步驟",
|
||||
"sample": "樣本",
|
||||
"editStep": "編輯步驟",
|
||||
"testInputHint": "測試輸入 — 編輯後執行步驟。",
|
||||
"noRunsList": "尚無執行記錄。"
|
||||
},
|
||||
"runOverlay": {
|
||||
"banner": "顯示過去某次執行 ({run}) 的步驟資料。您可以自由編輯 — 此執行永遠不會被變更。",
|
||||
"clear": "清除執行資料",
|
||||
"ghostNodes": "此執行中的 {count} 個步驟已不存在於此工作流程,因此不顯示其狀態。 | 此執行中的 {count} 個步驟已不存在於此工作流程,因此不顯示其狀態。",
|
||||
"missingNode": "該步驟已不存在於此工作流程 — 它在此執行後被移除。"
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "未儲存的變更",
|
||||
@@ -2781,7 +2830,20 @@
|
||||
"expand": "展开",
|
||||
"close": "清除并关闭结果",
|
||||
"nodesTitle": "步骤",
|
||||
"empty": "运行测试以在此处查看步骤结果。"
|
||||
"empty": "运行测试以在此处查看步骤结果。",
|
||||
"openNode": "開啟節點",
|
||||
"viewConfig": "檢視配置",
|
||||
"hideConfig": "隱藏配置",
|
||||
"noConfig": "無配置",
|
||||
"functionLabel": "函式",
|
||||
"destinationLabel": "目標",
|
||||
"destinationUnsupported": "不支援的類型",
|
||||
"destinationMissing": "此目標已不存在或無法讀取。",
|
||||
"headersLabel": "標頭",
|
||||
"templateLabel": "範本",
|
||||
"outputFormatLabel": "輸出格式",
|
||||
"tlsVerifyLabel": "TLS 驗證",
|
||||
"tlsDisabled": "已停用"
|
||||
}
|
||||
},
|
||||
"pipeline": {
|
||||
|
||||
@@ -54,6 +54,10 @@ vi.mock("@vue-flow/core", async () => {
|
||||
"node-change",
|
||||
"nodes-change",
|
||||
"edges-change",
|
||||
"node-mouse-enter",
|
||||
"node-mouse-leave",
|
||||
"edge-mouse-enter",
|
||||
"edge-mouse-leave",
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
@@ -144,6 +148,7 @@ vi.mock("@/components/flow/FlowEdge.vue", () => ({
|
||||
"data",
|
||||
"markerEnd",
|
||||
"style",
|
||||
"insertable",
|
||||
],
|
||||
template: '<div class="mock-flow-edge" />',
|
||||
},
|
||||
@@ -171,6 +176,8 @@ vi.mock("@/plugins/workflows/useWorkflowCanvas", async () => {
|
||||
onDrop: vi.fn(),
|
||||
onDragOver: vi.fn(),
|
||||
openTriggerPicker: vi.fn(),
|
||||
openActionPicker: vi.fn(),
|
||||
openStepPicker: vi.fn(),
|
||||
openInsertPicker: vi.fn(),
|
||||
};
|
||||
return {
|
||||
@@ -181,6 +188,13 @@ vi.mock("@/plugins/workflows/useWorkflowCanvas", async () => {
|
||||
undoWorkflow: vi.fn(),
|
||||
redoWorkflow: vi.fn(),
|
||||
tidyWorkflowLayout: vi.fn(),
|
||||
// The canvas asks for a node's io_type to decide which leaves can offer an
|
||||
// "append step" point — a terminal (output) node cannot. Only ioType is read.
|
||||
nodeMeta: (nodeType: string) => {
|
||||
if (nodeType === "destination") return { ioType: "output" };
|
||||
if (nodeType === "workflow_trigger") return { ioType: "input" };
|
||||
return { ioType: "default" };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -343,16 +357,22 @@ describe("WorkflowCanvas", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The empty canvas now offers a clickable start node (which opens the trigger
|
||||
// picker) in place of the old hint text.
|
||||
// Two DIFFERENT start surfaces, and which one shows is the contract:
|
||||
// • empty canvas → the two-slot SCAFFOLD (Trigger + Action)
|
||||
// • trigger missing, but
|
||||
// steps already placed → the single "Choose a Trigger" card
|
||||
describe("empty-canvas start node", () => {
|
||||
const startNode = (w: any) => w.find('[data-test="workflow-flow-start-node"]');
|
||||
const scaffold = (w: any) => w.find('[data-test="workflow-flow-start-scaffold"]');
|
||||
|
||||
it("shows the start node when the canvas has no nodes", () => {
|
||||
it("shows the two-slot scaffold when the canvas has no nodes", () => {
|
||||
wrapper = mountCanvas();
|
||||
const node = startNode(wrapper);
|
||||
expect(node.exists()).toBe(true);
|
||||
expect(node.text()).toBe("Choose a Trigger");
|
||||
expect(scaffold(wrapper).exists()).toBe(true);
|
||||
// Both slots are offered up front, so the trigger isn't a dead end.
|
||||
expect(wrapper.find('[data-test="workflow-flow-start-trigger"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="workflow-flow-start-action"]').exists()).toBe(true);
|
||||
// The single "trigger missing" card belongs to the non-empty case only.
|
||||
expect(startNode(wrapper).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the start node once a node exists", async () => {
|
||||
@@ -360,15 +380,16 @@ describe("WorkflowCanvas", () => {
|
||||
wrapper = mountCanvas();
|
||||
await nextTick();
|
||||
expect(startNode(wrapper).exists()).toBe(false);
|
||||
expect(scaffold(wrapper).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("re-shows the start node when the last node is removed", async () => {
|
||||
it("re-shows the scaffold when the last node is removed", async () => {
|
||||
wfObj.currentSelectedWorkflow.nodes = [triggerNode()];
|
||||
wrapper = mountCanvas();
|
||||
await nextTick();
|
||||
wfObj.currentSelectedWorkflow.nodes = [];
|
||||
await nextTick();
|
||||
expect(startNode(wrapper).exists()).toBe(true);
|
||||
expect(scaffold(wrapper).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("re-shows the start node when the trigger is deleted mid-graph (steps remain)", async () => {
|
||||
@@ -493,4 +514,186 @@ describe("WorkflowCanvas", () => {
|
||||
expect(vf.setViewport).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Append `+` (option A): straight-down for every non-terminal node, but a node
|
||||
// whose centre slot is taken by a child reveals it on HOVER only; a leaf and a
|
||||
// free-centre (fanned-out) node keep it persistent.
|
||||
describe("append + (Option C: nothing at rest, node hover reveals)", () => {
|
||||
beforeEach(() => vf.findNode.mockReturnValue({ dimensions: { width: 200 } }));
|
||||
const count = (w: any) => w.findAll('[data-test="workflow-flow-append-add"]').length;
|
||||
|
||||
it("shows no append + at rest and reveals only the hovered node's", async () => {
|
||||
wfObj.currentSelectedWorkflow = {
|
||||
nodes: [
|
||||
{ id: "t1", position: { x: 100, y: 40 }, data: { node_type: "workflow_trigger" } },
|
||||
{ id: "c1", position: { x: 100, y: 240 }, data: { node_type: "condition" } },
|
||||
],
|
||||
edges: [{ id: "e1", source: "t1", target: "c1" }],
|
||||
};
|
||||
wrapper = mountCanvas();
|
||||
await nextTick();
|
||||
expect(count(wrapper)).toBe(0); // quiet at rest — even the leaf c1
|
||||
|
||||
flow(wrapper).vm.$emit("node-mouse-enter", { node: { id: "c1" } });
|
||||
await nextTick();
|
||||
expect(count(wrapper)).toBe(1); // only the hovered node's +
|
||||
|
||||
flow(wrapper).vm.$emit("node-mouse-enter", { node: { id: "t1" } });
|
||||
await nextTick();
|
||||
expect(count(wrapper)).toBe(1); // still just one — the newly hovered node
|
||||
});
|
||||
|
||||
it("never offers a + on a terminal (output) node", async () => {
|
||||
wfObj.currentSelectedWorkflow = {
|
||||
nodes: [
|
||||
{ id: "t1", position: { x: 100, y: 40 }, data: { node_type: "workflow_trigger" } },
|
||||
{ id: "d1", position: { x: 100, y: 240 }, data: { node_type: "destination" } },
|
||||
],
|
||||
edges: [{ id: "e1", source: "t1", target: "d1" }],
|
||||
};
|
||||
wrapper = mountCanvas();
|
||||
await nextTick();
|
||||
flow(wrapper).vm.$emit("node-mouse-enter", { node: { id: "d1" } });
|
||||
await nextTick();
|
||||
expect(count(wrapper)).toBe(0); // d1 is terminal → never a +
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge insert + (Option C: revealed by endpoint-node hover)", () => {
|
||||
beforeEach(() => vf.findNode.mockReturnValue({ dimensions: { width: 200 } }));
|
||||
const insertable = (w: any) => w.findComponent({ name: "FlowEdge" }).props("insertable");
|
||||
|
||||
it("hides the mid-edge + until an endpoint node is hovered", async () => {
|
||||
// The mocked VueFlow renders edge-custom for edge id "e1"; wire e1 to n1→n2.
|
||||
wfObj.currentSelectedWorkflow = {
|
||||
nodes: [
|
||||
{ id: "n1", position: { x: 100, y: 40 }, data: { node_type: "condition" } },
|
||||
{ id: "n2", position: { x: 100, y: 240 }, data: { node_type: "condition" } },
|
||||
],
|
||||
edges: [{ id: "e1", source: "n1", target: "n2" }],
|
||||
};
|
||||
wrapper = mountCanvas();
|
||||
await nextTick();
|
||||
expect(insertable(wrapper)).toBe(false); // quiet at rest
|
||||
|
||||
flow(wrapper).vm.$emit("node-mouse-enter", { node: { id: "n1" } });
|
||||
await nextTick();
|
||||
expect(insertable(wrapper)).toBe(true); // source hovered → revealed
|
||||
|
||||
flow(wrapper).vm.$emit("node-mouse-enter", { node: { id: "n2" } });
|
||||
await nextTick();
|
||||
expect(insertable(wrapper)).toBe(true); // target hovered → still revealed
|
||||
});
|
||||
|
||||
it("reveals the mid-edge + when the edge line itself is hovered", async () => {
|
||||
wfObj.currentSelectedWorkflow = {
|
||||
nodes: [
|
||||
{ id: "n1", position: { x: 100, y: 40 }, data: { node_type: "condition" } },
|
||||
{ id: "n2", position: { x: 100, y: 240 }, data: { node_type: "condition" } },
|
||||
],
|
||||
edges: [{ id: "e1", source: "n1", target: "n2" }],
|
||||
};
|
||||
wrapper = mountCanvas();
|
||||
await nextTick();
|
||||
expect(insertable(wrapper)).toBe(false); // no node hovered
|
||||
|
||||
flow(wrapper).vm.$emit("edge-mouse-enter", { edge: { id: "e1" } });
|
||||
await nextTick();
|
||||
expect(insertable(wrapper)).toBe(true); // edge line hovered → revealed
|
||||
});
|
||||
});
|
||||
|
||||
describe("Option C reveal timing + keep-alive (300ms delay)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vf.findNode.mockReturnValue({ dimensions: { width: 200 } });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const count = (w: any) => w.findAll('[data-test="workflow-flow-append-add"]').length;
|
||||
const insertable = (w: any) => w.findComponent({ name: "FlowEdge" }).props("insertable");
|
||||
const oneNode = () => {
|
||||
wfObj.currentSelectedWorkflow = {
|
||||
nodes: [{ id: "n1", position: { x: 100, y: 40 }, data: { node_type: "condition" } }],
|
||||
edges: [],
|
||||
};
|
||||
};
|
||||
const twoNodesOneEdge = () => {
|
||||
wfObj.currentSelectedWorkflow = {
|
||||
nodes: [
|
||||
{ id: "n1", position: { x: 100, y: 40 }, data: { node_type: "condition" } },
|
||||
{ id: "n2", position: { x: 100, y: 240 }, data: { node_type: "condition" } },
|
||||
],
|
||||
edges: [{ id: "e1", source: "n1", target: "n2" }],
|
||||
};
|
||||
};
|
||||
|
||||
it("holds the node's + for the full 300ms after leaving, then hides", async () => {
|
||||
oneNode();
|
||||
wrapper = mountCanvas();
|
||||
flow(wrapper).vm.$emit("node-mouse-enter", { node: { id: "n1" } });
|
||||
await nextTick();
|
||||
expect(count(wrapper)).toBe(1);
|
||||
|
||||
flow(wrapper).vm.$emit("node-mouse-leave");
|
||||
vi.advanceTimersByTime(299);
|
||||
await nextTick();
|
||||
expect(count(wrapper)).toBe(1); // still up just before the delay elapses
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
await nextTick();
|
||||
expect(count(wrapper)).toBe(0); // hidden at 300ms
|
||||
});
|
||||
|
||||
it("cancels the node hide when the cursor lands on the append +", async () => {
|
||||
oneNode();
|
||||
wrapper = mountCanvas();
|
||||
flow(wrapper).vm.$emit("node-mouse-enter", { node: { id: "n1" } });
|
||||
await nextTick();
|
||||
|
||||
flow(wrapper).vm.$emit("node-mouse-leave"); // hide scheduled
|
||||
vi.advanceTimersByTime(100);
|
||||
// Cursor reaches the + chip mid-travel — its mouseenter freezes the timer.
|
||||
await wrapper.find('[data-test="workflow-flow-append-add"]').trigger("mouseenter");
|
||||
vi.advanceTimersByTime(300);
|
||||
await nextTick();
|
||||
expect(count(wrapper)).toBe(1); // still visible — never hid
|
||||
});
|
||||
|
||||
it("holds the edge + for 300ms after the edge line is left, then hides", async () => {
|
||||
twoNodesOneEdge();
|
||||
wrapper = mountCanvas();
|
||||
flow(wrapper).vm.$emit("edge-mouse-enter", { edge: { id: "e1" } });
|
||||
await nextTick();
|
||||
expect(insertable(wrapper)).toBe(true);
|
||||
|
||||
flow(wrapper).vm.$emit("edge-mouse-leave");
|
||||
vi.advanceTimersByTime(299);
|
||||
await nextTick();
|
||||
expect(insertable(wrapper)).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
await nextTick();
|
||||
expect(insertable(wrapper)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the edge + alive when the cursor moves onto it (insert-enter)", async () => {
|
||||
twoNodesOneEdge();
|
||||
wrapper = mountCanvas();
|
||||
flow(wrapper).vm.$emit("edge-mouse-enter", { edge: { id: "e1" } });
|
||||
await nextTick();
|
||||
|
||||
flow(wrapper).vm.$emit("edge-mouse-leave"); // hide scheduled
|
||||
vi.advanceTimersByTime(100);
|
||||
wrapper.findComponent({ name: "FlowEdge" }).vm.$emit("insert-enter"); // onto the +
|
||||
vi.advanceTimersByTime(300);
|
||||
await nextTick();
|
||||
expect(insertable(wrapper)).toBe(true); // frozen while on the chip
|
||||
|
||||
wrapper.findComponent({ name: "FlowEdge" }).vm.$emit("insert-leave"); // off the +
|
||||
vi.advanceTimersByTime(300);
|
||||
await nextTick();
|
||||
expect(insertable(wrapper)).toBe(false); // released → hides
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,8 +41,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
@edges-change="onEdgesChange"
|
||||
@node-drag-start="onNodeDragStart"
|
||||
@node-drag-stop="onNodeDragStop"
|
||||
@node-mouse-enter="onNodeMouseEnter"
|
||||
@node-mouse-leave="onNodeMouseLeave"
|
||||
@edge-mouse-enter="onEdgeMouseEnter"
|
||||
@edge-mouse-leave="onEdgeMouseLeave"
|
||||
@edge-click="onEdgeClick"
|
||||
@pane-click="closeEdgeMenu"
|
||||
@connect="onConnect"
|
||||
@drop="onDrop"
|
||||
@dragover="onDragOver"
|
||||
@@ -52,6 +55,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
not resolve. -->
|
||||
<Background :size="2" :gap="22" />
|
||||
|
||||
<!-- Edge-delete hint — on edge click, a top-center banner reminds the user that
|
||||
Backspace/Delete removes the edge (same affordance as the Pipelines canvas).
|
||||
Inserting a step is done via the mid-edge `+`, so the edge has no menu. -->
|
||||
<div
|
||||
v-if="showEdgeHint && !readOnly"
|
||||
data-test="workflow-edge-delete-hint"
|
||||
class="bg-surface-base text-text-body border-border-default rounded-default absolute top-5 left-1/2 z-1000 flex -translate-x-1/2 items-center border px-4 py-2.5 text-sm shadow-lg dark:shadow-lg"
|
||||
>
|
||||
<OIcon name="info" class="mr-1" size="sm" />
|
||||
{{ t("workflow.canvas.edgeDeleteHint") }}
|
||||
</div>
|
||||
|
||||
<!-- All three VueFlow templates render the same node; handle layout is
|
||||
derived from node_type inside WorkflowNode, so no io_type prop. -->
|
||||
<template #node-input="{ id, data }">
|
||||
@@ -76,6 +91,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:data="edgeProps.data"
|
||||
:marker-end="edgeProps.markerEnd"
|
||||
:style="edgeProps.style"
|
||||
:insertable="!readOnly && isEdgeRevealed(edgeProps.id)"
|
||||
@insert="onEdgeInsert(edgeProps.id, $event)"
|
||||
@insert-enter="onInsertEnter(edgeProps.id)"
|
||||
@insert-leave="onInsertLeave"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -141,73 +160,138 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</Controls>
|
||||
</VueFlow>
|
||||
|
||||
<!-- Edge action menu — opens AT THE CLICK POINT on the clicked edge (no travel to
|
||||
a top hint bar). Insert splices a step onto the edge (A→B becomes A→new→B,
|
||||
rewired); Delete removes the connection (Backspace/Delete still works too).
|
||||
Positioned via a CSS var (like the ghost start node's zoom) so no hardcoded
|
||||
px. Never shown on the read-only Runs canvas. -->
|
||||
<!-- Empty-canvas start scaffold — two DASHED placeholder cards (Trigger on top,
|
||||
Action below, joined by a dashed connector), mirroring the reference
|
||||
builder's start state so the trigger→action shape is legible before
|
||||
anything is placed. Clicking Trigger opens the trigger-kind picker; clicking
|
||||
Action opens the step picker for the addable (non-trigger) node types.
|
||||
|
||||
OVERLAYS, not Vue Flow nodes: real nodes would land in
|
||||
`currentSelectedWorkflow.nodes` and show up in save, validation and the
|
||||
dirty flag. Scaled by the LIVE viewport zoom (like a real node inside
|
||||
`.vue-flow__viewport`, which carries the canvas transform) so the cards
|
||||
track the canvas. Read-only Runs canvases show nothing. -->
|
||||
<div
|
||||
v-if="edgeMenu.show && !readOnly"
|
||||
ref="edgeMenuRef"
|
||||
data-test="workflow-edge-menu"
|
||||
class="bg-surface-base border-border-default rounded-default fixed top-[var(--edge-menu-y,0)] left-[var(--edge-menu-x,0)] z-1000 flex min-w-[10rem] flex-col overflow-hidden border py-1 text-sm shadow-lg dark:shadow-lg"
|
||||
:style="{ '--edge-menu-x': edgeMenu.x + 'px', '--edge-menu-y': edgeMenu.y + 'px' }"
|
||||
v-if="isEmptyCanvas && !readOnly"
|
||||
data-test="workflow-flow-start-scaffold"
|
||||
class="absolute top-24 left-1/2 z-10 flex origin-top -translate-x-1/2 scale-[var(--ghost-zoom,1)] flex-col items-center"
|
||||
:style="{ '--ghost-zoom': viewport.zoom }"
|
||||
>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full justify-start!"
|
||||
data-test="workflows-edge-insert"
|
||||
icon-left="add-circle-outline"
|
||||
@click="onInsertStep"
|
||||
>
|
||||
{{ t("workflow.canvas.insertStep") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
variant="ghost-destructive"
|
||||
size="sm"
|
||||
class="w-full justify-start!"
|
||||
data-test="workflows-edge-delete"
|
||||
icon-left="delete"
|
||||
@click="onDeleteEdge"
|
||||
>
|
||||
{{ t("workflow.canvas.deleteEdge") }}
|
||||
</OButton>
|
||||
<WorkflowStartCard
|
||||
:tag="t('workflow.node.kindTrigger')"
|
||||
:title="t('workflow.chooseTrigger')"
|
||||
:hint="t('workflow.start.triggerHint')"
|
||||
icon="notifications-active"
|
||||
tint="bg-badge-blue-soft-bg text-badge-blue-soft-text"
|
||||
data-test="workflow-flow-start-trigger"
|
||||
@click="openTriggerPicker($event)"
|
||||
/>
|
||||
|
||||
<!-- Dashed connector + `+` between the two slots — same chip as every other
|
||||
"add a step here" affordance; here it opens the Action picker. -->
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="border-border-strong h-5 border-l-2"></span>
|
||||
<FlowAddButton data-test="workflow-flow-start-add" @click="openActionPicker($event)" />
|
||||
<span class="border-border-strong h-5 border-l-2"></span>
|
||||
</div>
|
||||
|
||||
<WorkflowStartCard
|
||||
:tag="t('workflow.node.kindAction')"
|
||||
:title="t('workflow.start.chooseAction')"
|
||||
:hint="t('workflow.start.actionHint')"
|
||||
icon="bolt"
|
||||
tint="bg-badge-success-soft-bg text-badge-success-soft-text"
|
||||
data-test="workflow-flow-start-action"
|
||||
@click="openActionPicker($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty-canvas start node (replaces the old "add a trigger" hint text). An
|
||||
OVERLAY, not a Vue Flow node: a real node would land in
|
||||
`currentSelectedWorkflow.nodes` and show up in save, validation and the
|
||||
dirty flag. It borrows `vue-flow__node-input` so it is chrome-for-chrome
|
||||
the same card a trigger renders as — picking one swaps the icon and
|
||||
label, and the frame never moves. Read-only Runs canvases show nothing.
|
||||
|
||||
The wrapper carries `o2vf_node` because every shared node rule is scoped
|
||||
under it, and on THIS canvas that class sits on the VueFlow element the
|
||||
placeholder is a sibling of — without it the card gets no chrome at all
|
||||
(the pipeline canvas has it on the container, so it inherits it there). -->
|
||||
<!-- Persistent Action slot — once the Trigger is placed but no step follows it
|
||||
yet, the Action ghost stays right below the real trigger node (so "picking
|
||||
one keeps the other"). Anchored in FLOW space (viewport-transformed) to the
|
||||
trigger's measured position + height, so it tracks pan/zoom and sits flush
|
||||
under the node. Clicking it (or the connector `+`) opens the Action picker,
|
||||
which appends + wires the first step after the trigger. -->
|
||||
<div
|
||||
v-if="needsTrigger && !readOnly"
|
||||
class="o2vf_node absolute top-32 left-1/2 z-10 -translate-x-1/2"
|
||||
v-if="actionSlot && !readOnly"
|
||||
data-test="workflow-flow-action-slot"
|
||||
class="absolute top-[var(--wf-oy,0)] left-[var(--wf-ox,0)] z-10 flex origin-top -translate-x-1/2 scale-[var(--wf-oz,1)] flex-col items-center"
|
||||
:style="{
|
||||
'--wf-ox': actionSlot.left + 'px',
|
||||
'--wf-oy': actionSlot.top + 'px',
|
||||
'--wf-oz': actionSlot.zoom,
|
||||
}"
|
||||
>
|
||||
<!-- Scaled by the LIVE viewport zoom: real nodes are drawn inside
|
||||
`.vue-flow__viewport`, which carries the canvas transform, so an
|
||||
unscaled overlay renders larger than the node it stands in for.
|
||||
|
||||
`relative!` / `origin-top!` undo two things `.vue-flow__node` sets for
|
||||
nodes VUE FLOW positions: `position:absolute` (which took this card out
|
||||
of flow, collapsing the centring wrapper to zero width) and
|
||||
`transform-origin:0 0` (which scaled it toward the top-left). -->
|
||||
<div
|
||||
data-test="workflow-flow-start-node"
|
||||
class="vue-flow__node vue-flow__node-input relative! w-max origin-top! scale-[var(--ghost-zoom,1)] cursor-pointer! whitespace-nowrap"
|
||||
:style="{ '--ghost-zoom': viewport.zoom }"
|
||||
@click="openTriggerPicker($event)"
|
||||
>
|
||||
<FlowNodeCard icon="add" io-type="input" :has-input="false" :has-output="false">
|
||||
<template #body>{{ t("workflow.chooseTrigger") }}</template>
|
||||
</FlowNodeCard>
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="border-border-strong h-5 border-l-2"></span>
|
||||
<FlowAddButton data-test="workflow-flow-action-add" @click="openActionPicker($event)" />
|
||||
<span class="border-border-strong h-5 border-l-2"></span>
|
||||
</div>
|
||||
<WorkflowStartCard
|
||||
:tag="t('workflow.node.kindAction')"
|
||||
:title="t('workflow.start.chooseAction')"
|
||||
:hint="t('workflow.start.actionHint')"
|
||||
icon="bolt"
|
||||
tint="bg-badge-success-soft-bg text-badge-success-soft-text"
|
||||
data-test="workflow-flow-action-slot-card"
|
||||
@click="openActionPicker($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Append `+` — every step that can still chain onward gets a straight-down `+`
|
||||
to add the next step / fan out a branch. A leaf's is persistent; a node whose
|
||||
centre slot is already taken by a child reveals it on HOVER only. The `+`'s own
|
||||
mouse events keep the reveal alive while the cursor is on it. Flow-anchored so
|
||||
it tracks the node. -->
|
||||
<div
|
||||
v-for="pt in appendPoints"
|
||||
:key="pt.id"
|
||||
data-test="workflow-flow-append-add"
|
||||
class="absolute top-[var(--wf-oy,0)] left-[var(--wf-ox,0)] z-20 flex origin-top -translate-x-1/2 scale-[var(--wf-oz,1)] flex-col items-center"
|
||||
:style="{ '--wf-ox': pt.left + 'px', '--wf-oy': pt.top + 'px', '--wf-oz': pt.zoom }"
|
||||
@mouseenter="onAppendEnter(pt.id)"
|
||||
@mouseleave="onNodeMouseLeave"
|
||||
>
|
||||
<!-- Connector is ALWAYS drawn for a shown point — a leaf's straight stub, or a
|
||||
branch's short side-nudged curve. Option C: the point only renders while its
|
||||
node is hovered, so at rest there's nothing here. -->
|
||||
<span v-if="!pt.svgW" class="border-border-strong h-5 border-l-2"></span>
|
||||
<svg
|
||||
v-else
|
||||
:width="pt.svgW"
|
||||
height="20"
|
||||
:viewBox="`${-pt.svgW / 2} 0 ${pt.svgW} 20`"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
:d="`M ${pt.cx} 0 Q ${pt.cx} 13 0 20`"
|
||||
fill="none"
|
||||
stroke="var(--color-border-strong)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<FlowAddButton @click="openStepPicker(pt.id, 'out', $event)" />
|
||||
</div>
|
||||
|
||||
<!-- Trigger-missing fallback: no trigger but the canvas isn't empty (a step was
|
||||
placed first, or the trigger was deleted mid-graph). The two-slot scaffold
|
||||
is for the EMPTY canvas only; here we show a single dashed "Choose a Trigger"
|
||||
card so the user can pick a kind (adding it auto-wires a lone orphan step). -->
|
||||
<div
|
||||
v-if="needsTrigger && !isEmptyCanvas && !readOnly"
|
||||
data-test="workflow-flow-start-node"
|
||||
class="absolute top-24 left-1/2 z-10 flex origin-top -translate-x-1/2 scale-[var(--ghost-zoom,1)] flex-col items-center"
|
||||
:style="{ '--ghost-zoom': viewport.zoom }"
|
||||
>
|
||||
<WorkflowStartCard
|
||||
:tag="t('workflow.node.kindTrigger')"
|
||||
:title="t('workflow.chooseTrigger')"
|
||||
:hint="t('workflow.start.triggerHint')"
|
||||
icon="notifications-active"
|
||||
tint="bg-badge-blue-soft-bg text-badge-blue-soft-text"
|
||||
data-test="workflow-flow-start-trigger-fallback"
|
||||
@click="openTriggerPicker($event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -222,15 +306,17 @@ import { Background } from "@vue-flow/background";
|
||||
import { Controls, ControlButton } from "@vue-flow/controls";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import WorkflowNode from "./WorkflowNode.vue";
|
||||
import WorkflowStartCard from "./WorkflowStartCard.vue";
|
||||
import FlowEdge from "@/components/flow/FlowEdge.vue";
|
||||
import FlowNodeCard from "@/components/flow/FlowNodeCard.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import FlowAddButton from "@/components/flow/FlowAddButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import useWorkflowCanvas, {
|
||||
workflowHistory,
|
||||
pushWorkflowHistory,
|
||||
undoWorkflow,
|
||||
redoWorkflow,
|
||||
tidyWorkflowLayout,
|
||||
nodeMeta,
|
||||
} from "./useWorkflowCanvas";
|
||||
|
||||
import "@vue-flow/core/dist/style.css";
|
||||
@@ -249,6 +335,8 @@ const {
|
||||
onDrop,
|
||||
onDragOver,
|
||||
openTriggerPicker,
|
||||
openActionPicker,
|
||||
openStepPicker,
|
||||
openInsertPicker,
|
||||
} = useWorkflowCanvas(t);
|
||||
|
||||
@@ -311,56 +399,20 @@ const vueFlowRef = ref<any>(null);
|
||||
// via WorkflowNode, the hover add/delete + click-to-edit. Run overlays stay.
|
||||
const readOnly = computed(() => workflowObj.readOnly);
|
||||
|
||||
// Edges have no inline buttons; clicking one opens a small action menu AT THE
|
||||
// CLICK POINT with Insert + Delete (no travel to a top hint bar). `edge` is the
|
||||
// clicked edge; x/y are viewport coords for the fixed-positioned menu. Skipped on
|
||||
// the read-only Runs canvas, where edges can't be edited.
|
||||
const edgeMenuRef = ref<HTMLElement | null>(null);
|
||||
const edgeMenu = ref<{ show: boolean; x: number; y: number; edge: any }>({
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
edge: null,
|
||||
});
|
||||
const onEdgeClick = (payload: any) => {
|
||||
// Edges have no menu — a step is inserted via the mid-edge `+` (onEdgeInsert) and a
|
||||
// connection is removed with Backspace/Delete on the selected edge. Clicking an edge
|
||||
// just flashes a top-center hint advertising that shortcut (same as the Pipelines
|
||||
// canvas). Skipped on the read-only Runs canvas, where edges can't be edited.
|
||||
const showEdgeHint = ref(false);
|
||||
let edgeHintTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const onEdgeClick = () => {
|
||||
if (readOnly.value) return;
|
||||
const edge = payload?.edge ?? payload ?? null;
|
||||
if (!edge) return;
|
||||
const evt = payload?.event as MouseEvent | undefined;
|
||||
// Nudge a few px off the cursor so the menu doesn't open under the pointer.
|
||||
edgeMenu.value = {
|
||||
show: true,
|
||||
x: (evt?.clientX ?? 0) + 4,
|
||||
y: (evt?.clientY ?? 0) + 4,
|
||||
edge,
|
||||
};
|
||||
};
|
||||
const closeEdgeMenu = () => {
|
||||
edgeMenu.value = { ...edgeMenu.value, show: false, edge: null };
|
||||
};
|
||||
|
||||
// Insert a step onto the clicked edge (T7) — opens the step picker in "insert"
|
||||
// mode at the menu position; picking a type splices A→new→B (addNodeOnEdge).
|
||||
const onInsertStep = () => {
|
||||
const edge = edgeMenu.value.edge;
|
||||
if (!edge) return;
|
||||
openInsertPicker(edge, { clientX: edgeMenu.value.x, clientY: edgeMenu.value.y } as MouseEvent);
|
||||
closeEdgeMenu();
|
||||
};
|
||||
// Delete the clicked connection (single undo step).
|
||||
const onDeleteEdge = () => {
|
||||
const edge = edgeMenu.value.edge;
|
||||
if (!edge) return;
|
||||
pushWorkflowHistory();
|
||||
removeEdges([edge.id]);
|
||||
closeEdgeMenu();
|
||||
};
|
||||
// Dismiss the menu on any click outside it (canvas pane-click already closes it;
|
||||
// this covers clicks on nodes / toolbar / elsewhere).
|
||||
const onDocMouseDown = (e: MouseEvent) => {
|
||||
if (!edgeMenu.value.show) return;
|
||||
if (edgeMenuRef.value && edgeMenuRef.value.contains(e.target as Node)) return;
|
||||
closeEdgeMenu();
|
||||
if (edgeHintTimeout) clearTimeout(edgeHintTimeout);
|
||||
showEdgeHint.value = true;
|
||||
edgeHintTimeout = setTimeout(() => {
|
||||
showEdgeHint.value = false;
|
||||
edgeHintTimeout = null;
|
||||
}, 3500);
|
||||
};
|
||||
|
||||
// Is the user typing in a field / code editor? Undo/redo + edge-delete must not
|
||||
@@ -386,11 +438,6 @@ const isTextInputTarget = (target: EventTarget | null): boolean => {
|
||||
// Monaco editor is focused.
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (readOnly.value) return;
|
||||
// Escape closes the edge menu regardless of focus.
|
||||
if (event.key === "Escape" && edgeMenu.value.show) {
|
||||
closeEdgeMenu();
|
||||
return;
|
||||
}
|
||||
if (isTextInputTarget(event.target)) return;
|
||||
|
||||
const mod = event.metaKey || event.ctrlKey;
|
||||
@@ -408,16 +455,14 @@ const onKeydown = (event: KeyboardEvent) => {
|
||||
// Snapshot BEFORE removal so the edge-delete is a single undo step.
|
||||
pushWorkflowHistory();
|
||||
removeEdges(selected.map((e) => e.id));
|
||||
closeEdgeMenu();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
window.addEventListener("mousedown", onDocMouseDown);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
window.removeEventListener("mousedown", onDocMouseDown);
|
||||
if (edgeHintTimeout) clearTimeout(edgeHintTimeout);
|
||||
});
|
||||
// The "Choose a Trigger" start node shows whenever the workflow has NO TRIGGER —
|
||||
// not only when the canvas is empty. A workflow needs exactly one trigger, and
|
||||
@@ -432,19 +477,213 @@ const needsTrigger = computed(
|
||||
),
|
||||
);
|
||||
|
||||
// Center the trigger horizontally once nodes have measured dimensions — keep
|
||||
// its Y (near the top) so the steps flow down. Runs once per editor mount.
|
||||
// EXCEPTION: if a Test result is already present, the canvas has just remounted
|
||||
// into the reduced area above the results dock — frame ALL nodes instead so the
|
||||
// user still sees the whole graph.
|
||||
// A brand-new, untouched canvas — nothing placed yet. This is when the two-slot
|
||||
// Trigger + Action start scaffold shows; once ANY node exists we fall back to the
|
||||
// single "Choose a Trigger" card (only if the trigger is missing) and the flow-
|
||||
// anchored `+` add affordances below.
|
||||
const isEmptyCanvas = computed(
|
||||
() => (workflowObj.currentSelectedWorkflow.nodes || []).length === 0,
|
||||
);
|
||||
|
||||
// The trigger node (if placed) and how many NON-trigger steps exist — drive which
|
||||
// start affordances show.
|
||||
const triggerNode = computed<any>(() =>
|
||||
(workflowObj.currentSelectedWorkflow.nodes || []).find(
|
||||
(n: any) => n.data?.node_type === "workflow_trigger",
|
||||
),
|
||||
);
|
||||
const hasTrigger = computed(() => !!triggerNode.value);
|
||||
const stepCount = computed(
|
||||
() =>
|
||||
(workflowObj.currentSelectedWorkflow.nodes || []).filter(
|
||||
(n: any) => n.data?.node_type !== "workflow_trigger",
|
||||
).length,
|
||||
);
|
||||
|
||||
// Flow-coordinate → screen-pixel (relative to the canvas pane, whose origin the
|
||||
// viewport transform is measured from). Used to pin the flow-anchored overlays
|
||||
// (the persistent Action slot + the leaf-append `+`s) under their nodes so they
|
||||
// pan and zoom exactly like a real node. `left`/`top` land the node's BOTTOM-CENTRE
|
||||
// at the screen point; the overlay then grows downward from there.
|
||||
const screenBelow = (node: any) => {
|
||||
const dims = findNode(node.id)?.dimensions;
|
||||
const w = dims?.width ?? 240;
|
||||
const h = dims?.height ?? 54;
|
||||
const { x, y, zoom } = viewport.value;
|
||||
return {
|
||||
left: x + ((node.position?.x ?? 0) + w / 2) * zoom,
|
||||
top: y + ((node.position?.y ?? 0) + h) * zoom,
|
||||
zoom,
|
||||
};
|
||||
};
|
||||
|
||||
// Persistent Action slot: shown once the trigger is placed but no step follows it
|
||||
// yet, pinned flush under the trigger node (so choosing the trigger keeps the
|
||||
// Action ghost in view). Null otherwise.
|
||||
const actionSlot = computed(() => {
|
||||
if (readOnly.value || !hasTrigger.value || stepCount.value !== 0) return null;
|
||||
return screenBelow(triggerNode.value);
|
||||
});
|
||||
|
||||
// Option C reveal state: the currently-hovered node and edge drive the append `+` and
|
||||
// the mid-edge insert `+`. A generous hide delay so the cursor can travel the connector
|
||||
// path onto the small `+` without it vanishing first (the `+`'s own mouseenter cancels
|
||||
// the timer anyway; this covers the gap in between).
|
||||
const HIDE_DELAY = 300;
|
||||
const hoveredNodeId = ref("");
|
||||
const hoveredEdgeId = ref("");
|
||||
let nodeHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let edgeHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const cancelNodeHide = () => {
|
||||
if (nodeHideTimer) clearTimeout(nodeHideTimer);
|
||||
nodeHideTimer = null;
|
||||
};
|
||||
const cancelEdgeHide = () => {
|
||||
if (edgeHideTimer) clearTimeout(edgeHideTimer);
|
||||
edgeHideTimer = null;
|
||||
};
|
||||
const onNodeMouseEnter = (e: any) => {
|
||||
cancelNodeHide();
|
||||
hoveredNodeId.value = e?.node?.id || "";
|
||||
};
|
||||
const onNodeMouseLeave = () => {
|
||||
cancelNodeHide();
|
||||
nodeHideTimer = setTimeout(() => (hoveredNodeId.value = ""), HIDE_DELAY);
|
||||
};
|
||||
// Hovering the EDGE line itself reveals that edge's insert `+`, so you can drop a step
|
||||
// between two nodes without travelling up to a node first.
|
||||
const onEdgeMouseEnter = (e: any) => {
|
||||
cancelEdgeHide();
|
||||
hoveredEdgeId.value = e?.edge?.id || "";
|
||||
};
|
||||
const onEdgeMouseLeave = () => {
|
||||
cancelEdgeHide();
|
||||
edgeHideTimer = setTimeout(() => (hoveredEdgeId.value = ""), HIDE_DELAY);
|
||||
};
|
||||
// Hovering an append `+` keeps its node the hovered one, so the `+` (and any edge
|
||||
// inserts on that node) stay up while the cursor is on the chip.
|
||||
const onAppendEnter = (id: string) => {
|
||||
cancelNodeHide();
|
||||
hoveredNodeId.value = id;
|
||||
};
|
||||
// Cursor moved onto a mid-edge `+`: keep it up regardless of whether it was revealed by
|
||||
// the edge line or an endpoint node (re-assert the edge as hovered, freeze both timers).
|
||||
const onInsertEnter = (edgeId: string) => {
|
||||
cancelNodeHide();
|
||||
cancelEdgeHide();
|
||||
hoveredEdgeId.value = edgeId;
|
||||
};
|
||||
const onInsertLeave = () => {
|
||||
onNodeMouseLeave();
|
||||
onEdgeMouseLeave();
|
||||
};
|
||||
// Option C: a mid-edge insert `+` shows while the edge line is hovered OR while either
|
||||
// of its endpoint nodes is hovered — so it's reachable both ways, and the canvas is
|
||||
// otherwise quiet at rest.
|
||||
const isEdgeRevealed = (edgeId: string) => {
|
||||
if (hoveredEdgeId.value === edgeId) return true;
|
||||
const id = hoveredNodeId.value;
|
||||
if (!id) return false;
|
||||
const e = (workflowObj.currentSelectedWorkflow?.edges || []).find((x: any) => x.id === edgeId);
|
||||
return !!e && (e.source === id || e.target === id);
|
||||
};
|
||||
|
||||
// The append `+` for ONE node. A leaf (no children) shows a persistent STRAIGHT-DOWN
|
||||
// `+` — the "add next step" affordance. A node that ALREADY has children reveals its
|
||||
// `+` on HOVER only (`hoverOnly`, so the canvas isn't cluttered) and nudges it a
|
||||
// little to the side AWAY from the existing edge(s) — with a short connector — so it
|
||||
// reads as a separate branch handle rather than sitting on the edge. Clicking fans out.
|
||||
const appendPointFor = (node: any) => {
|
||||
const base = screenBelow(node); // { left, top, zoom } — below centre
|
||||
const wf = workflowObj.currentSelectedWorkflow;
|
||||
const childIds = (wf.edges || [])
|
||||
.filter((e: any) => e.source === node.id)
|
||||
.map((e: any) => e.target);
|
||||
if (!childIds.length) return { ...base, hoverOnly: false, cx: 0, svgW: 0 }; // leaf → stub
|
||||
|
||||
// Where do the existing children sit relative to the node centre?
|
||||
const nodeW = findNode(node.id)?.dimensions?.width ?? 240;
|
||||
const nodeCx = (node.position?.x ?? 0) + nodeW / 2;
|
||||
const byId = new Map((wf.nodes || []).map((n: any) => [n.id, n]));
|
||||
const TH = 40;
|
||||
let hasLeft = false;
|
||||
let hasRight = false;
|
||||
for (const cid of childIds) {
|
||||
const c: any = byId.get(cid);
|
||||
if (!c) continue;
|
||||
const cw = findNode(cid)?.dimensions?.width ?? 240;
|
||||
const ccx = (c.position?.x ?? 0) + cw / 2;
|
||||
if (ccx > nodeCx + TH) hasRight = true;
|
||||
else if (ccx < nodeCx - TH) hasLeft = true;
|
||||
}
|
||||
// Both sides busy (a fanned-out node like the trigger) → the open space is the V-gap
|
||||
// straight below centre, so drop the `+` there with a clean vertical stub (any side
|
||||
// offset just lands it back on top of an existing branch edge). One side / a lone
|
||||
// centred child → a small nudge to the free side, off the existing edge.
|
||||
let sign: number;
|
||||
let off: number;
|
||||
if (hasLeft && hasRight) {
|
||||
sign = 1;
|
||||
off = 0; // centred in the V-gap — straight-down stub
|
||||
} else {
|
||||
sign = hasRight ? -1 : 1;
|
||||
off = 40; // a little difference, not the old wide swing
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
hoverOnly: true,
|
||||
left: base.left + off * base.zoom * sign,
|
||||
cx: -off * sign, // node's LOCAL x relative to the `+` (connector start)
|
||||
svgW: 2 * off,
|
||||
};
|
||||
};
|
||||
|
||||
// Append `+` points: one under EVERY step that can still chain onward (not a
|
||||
// terminal/output node), so a fan-out branch can be added from any node — but a
|
||||
// point whose slot is taken (`hoverOnly`) shows only while its node is hovered.
|
||||
// Excludes the trigger while the Action slot covers it, so there's never a bare `+`
|
||||
// and a labelled Action card on the same node.
|
||||
const appendPoints = computed(() => {
|
||||
if (readOnly.value) return [];
|
||||
const wf = workflowObj.currentSelectedWorkflow;
|
||||
const nodes = wf.nodes || [];
|
||||
return (
|
||||
nodes
|
||||
.filter((n: any) => {
|
||||
if (nodeMeta(n.data?.node_type)?.ioType === "output") return false; // terminal
|
||||
// The trigger's first step is offered via the labelled Action slot instead.
|
||||
if (hasTrigger.value && stepCount.value === 0) return false;
|
||||
return true;
|
||||
})
|
||||
.map((n: any) => ({ id: n.id, ...appendPointFor(n) }))
|
||||
// Option C: nothing shows at rest — a node's append `+` appears only while that
|
||||
// node (or its own `+`) is hovered. Leaves hover-gate too, so at rest the whole
|
||||
// canvas is quiet.
|
||||
.filter((pt: any) => pt.id === hoveredNodeId.value)
|
||||
);
|
||||
});
|
||||
|
||||
// Mid-edge `+` clicked → splice a step onto that edge (A→new→B). Reuses the same
|
||||
// insert picker the edge action-menu uses.
|
||||
const onEdgeInsert = (edgeId: string, event: MouseEvent) => {
|
||||
const edge = (workflowObj.currentSelectedWorkflow.edges || []).find((e: any) => e.id === edgeId);
|
||||
if (edge) openInsertPicker(edge, event);
|
||||
};
|
||||
|
||||
// Frame the graph once nodes have measured dimensions. Runs once per editor mount.
|
||||
// • Existing/loaded workflow (Edit, or History → Edit) or a run to inspect:
|
||||
// fitView so the WHOLE graph is centered on screen (top-aligning a saved graph
|
||||
// left it pushed off the top — the reported "not centering" bug).
|
||||
// • Brand-new workflow (create): keep the trigger near the top and only center it
|
||||
// horizontally, so the first steps flow DOWN from the top as you build.
|
||||
let centered = false;
|
||||
onNodesInitialized(() => {
|
||||
if (centered) return;
|
||||
const nodes = workflowObj.currentSelectedWorkflow.nodes;
|
||||
const trigger = nodes.find((n: any) => n.data?.node_type === "workflow_trigger");
|
||||
if (!trigger) return;
|
||||
if (workflowObj.testRun.result) {
|
||||
fitView({ padding: 0.2 });
|
||||
if (workflowObj.isEditWorkflow || workflowObj.testRun.result) {
|
||||
nextTick(() => fitView({ padding: 0.2 }));
|
||||
centered = true;
|
||||
return;
|
||||
}
|
||||
@@ -460,15 +699,32 @@ onNodesInitialized(() => {
|
||||
centered = true;
|
||||
});
|
||||
|
||||
// Whenever a run produces a result (the dock opens / re-runs), frame all nodes so
|
||||
// the reduced canvas still shows the whole graph. Guarded on !readOnly so the Runs
|
||||
// inspection canvas isn't reframed on every history load.
|
||||
// NOTE: a run result no longer reframes the canvas. That reframe existed for the old
|
||||
// results DOCK, which shrank the canvas when it opened; the NDV redesign removed the
|
||||
// dock, so running a Test / Run Step leaves the canvas size unchanged — auto-fitting
|
||||
// there just yanked the user's view (an unwanted zoom on every run). The view now stays
|
||||
// exactly where the user left it when a result arrives.
|
||||
|
||||
// Publish validation flags incomplete (dummy) nodes → frame just those and thicken
|
||||
// their border in the node's own type colour (WorkflowNode adds `wf-needs-setup`).
|
||||
// Clear the flag after a few seconds so it's a transient nudge — the persistent
|
||||
// "Set up later" badge still marks them afterwards.
|
||||
let incompleteHighlightTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
watch(
|
||||
() => !!workflowObj.testRun.result,
|
||||
(has) => {
|
||||
if (has && !readOnly.value) nextTick(() => fitView({ padding: 0.2 }));
|
||||
() => workflowObj.incompleteHighlight,
|
||||
(ids) => {
|
||||
if (incompleteHighlightTimer) clearTimeout(incompleteHighlightTimer);
|
||||
if (!ids.length) return;
|
||||
nextTick(() => fitView({ nodes: ids, padding: 0.3, duration: 500 }));
|
||||
incompleteHighlightTimer = setTimeout(() => {
|
||||
workflowObj.incompleteHighlight = [];
|
||||
incompleteHighlightTimer = null;
|
||||
}, 4500);
|
||||
},
|
||||
);
|
||||
onBeforeUnmount(() => {
|
||||
if (incompleteHighlightTimer) clearTimeout(incompleteHighlightTimer);
|
||||
});
|
||||
|
||||
defineExpose({ vueFlowRef });
|
||||
</script>
|
||||
|
||||
@@ -98,6 +98,14 @@ const mountNode = (id: string, data: any) =>
|
||||
|
||||
const card = (wrapper: any) => wrapper.findComponent({ name: "FlowNodeCard" });
|
||||
|
||||
// A badge click opens the node's NDV — the same panel a node click opens, now
|
||||
// carrying this run's Input/Output. There is no separate results dock any more,
|
||||
// so "inspect a step" and "edit a step" are one surface.
|
||||
const expectNdvOpenedOn = (id: string) => {
|
||||
expect(workflowObj.dialog.show).toBe(true);
|
||||
expect(workflowObj.currentSelectedNodeID).toBe(id);
|
||||
};
|
||||
|
||||
describe("WorkflowNode", () => {
|
||||
let wrapper: any = null;
|
||||
|
||||
@@ -481,37 +489,10 @@ describe("WorkflowNode", () => {
|
||||
// The hover-`+` under the card is gone: clicking the node's SOURCE HANDLE
|
||||
// opens the step picker. Terminal (output) nodes render no source handle, so
|
||||
// there is nothing to click on them.
|
||||
describe("source handle -> step picker", () => {
|
||||
const outputClick = (w: any, x = 120, y = 240) =>
|
||||
w.findComponent({ name: "FlowNodeCard" }).vm.$emit("outputClick", {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
} as MouseEvent);
|
||||
|
||||
it("opens the step picker anchored at the click for a logic node", async () => {
|
||||
wrapper = mountNode("c1", CONDITION.data);
|
||||
outputClick(wrapper, 120, 240);
|
||||
await nextTick();
|
||||
expect(workflowObj.stepPicker).toEqual({
|
||||
show: true,
|
||||
source: "c1",
|
||||
handle: "out",
|
||||
mode: "next",
|
||||
// edgeId is only set in insert-on-edge mode (T7); empty here.
|
||||
edgeId: "",
|
||||
position: null,
|
||||
anchor: { x: 120, y: 240 },
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the step picker for the trigger too", async () => {
|
||||
wrapper = mountNode("t1", TRIGGER.data);
|
||||
outputClick(wrapper);
|
||||
await nextTick();
|
||||
expect(workflowObj.stepPicker.show).toBe(true);
|
||||
expect(workflowObj.stepPicker.source).toBe("t1");
|
||||
});
|
||||
|
||||
// The source dot is a connection point only now (adding a next step / fan-out
|
||||
// branch moved to the canvas-level append `+`); it renders where a next step is
|
||||
// legal and is absent on terminal nodes.
|
||||
describe("source handle (connection point)", () => {
|
||||
it("renders a source handle on a logic node", () => {
|
||||
wrapper = mountNode("c1", CONDITION.data);
|
||||
expect(wrapper.findComponent({ name: "FlowNodeCard" }).props("hasOutput")).toBe(true);
|
||||
@@ -521,15 +502,6 @@ describe("WorkflowNode", () => {
|
||||
wrapper = mountNode("d1", DESTINATION.data);
|
||||
expect(wrapper.findComponent({ name: "FlowNodeCard" }).props("hasOutput")).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing on the read-only Runs canvas", async () => {
|
||||
workflowObj.readOnly = true;
|
||||
wrapper = mountNode("c1", CONDITION.data);
|
||||
outputClick(wrapper);
|
||||
await nextTick();
|
||||
expect(workflowObj.stepPicker.show).toBe(false);
|
||||
workflowObj.readOnly = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("test result badge", () => {
|
||||
@@ -663,7 +635,7 @@ describe("WorkflowNode", () => {
|
||||
expect(wrapper.find(".wf-test-count").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("opens the per-node result drawer when the error badge is clicked", async () => {
|
||||
it("opens the NDV when the error badge is clicked", async () => {
|
||||
setResult({
|
||||
errors: { c1: { error_count: 1, errors: [["boom"]] } },
|
||||
ranNodeIds: ["c1"],
|
||||
@@ -671,13 +643,10 @@ describe("WorkflowNode", () => {
|
||||
});
|
||||
wrapper = mountNode("c1", CONDITION.data);
|
||||
await wrapper.find('[data-test="workflow-node-condition-test-error"]').trigger("click");
|
||||
expect(workflowObj.testRun.resultDrawer).toEqual({
|
||||
show: true,
|
||||
nodeId: "c1",
|
||||
});
|
||||
expectNdvOpenedOn("c1");
|
||||
});
|
||||
|
||||
it("opens the drawer when the ok badge is clicked (every ran node is inspectable)", async () => {
|
||||
it("opens the NDV when the ok badge is clicked (every ran node is inspectable)", async () => {
|
||||
setResult({
|
||||
errors: {},
|
||||
inputs: { c1: [{ meta: {}, data: [{ x: 1 }] }] },
|
||||
@@ -686,14 +655,14 @@ describe("WorkflowNode", () => {
|
||||
});
|
||||
wrapper = mountNode("c1", CONDITION.data);
|
||||
await wrapper.find('[data-test="workflow-node-condition-test-ok"]').trigger("click");
|
||||
expect(workflowObj.testRun.resultDrawer).toEqual({ show: true, nodeId: "c1" });
|
||||
expectNdvOpenedOn("c1");
|
||||
});
|
||||
|
||||
it("opens the drawer when the grey (skipped) badge is clicked", async () => {
|
||||
it("opens the NDV when the grey (skipped) badge is clicked", async () => {
|
||||
setResult({ errors: {}, inputs: {}, ranNodeIds: ["c1"], blockedNodeIds: [] });
|
||||
wrapper = mountNode("c1", CONDITION.data);
|
||||
await wrapper.find('[data-test="workflow-node-condition-test-skipped"]').trigger("click");
|
||||
expect(workflowObj.testRun.resultDrawer).toEqual({ show: true, nodeId: "c1" });
|
||||
expectNdvOpenedOn("c1");
|
||||
});
|
||||
|
||||
it("reacts to a test result arriving after mount", async () => {
|
||||
@@ -744,10 +713,7 @@ describe("WorkflowNode", () => {
|
||||
const badge = wrapper.find('[data-test="workflow-node-undefined-test-error"]');
|
||||
expect(badge.exists()).toBe(true);
|
||||
await badge.trigger("click");
|
||||
expect(workflowObj.testRun.resultDrawer).toEqual({
|
||||
show: true,
|
||||
nodeId: "c1",
|
||||
});
|
||||
expectNdvOpenedOn("c1");
|
||||
});
|
||||
|
||||
it("renders the delete button and the actions bar with an undefined type", async () => {
|
||||
|
||||
@@ -28,11 +28,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:has-input="meta?.ioType !== 'input'"
|
||||
:has-output="meta?.ioType !== 'output'"
|
||||
:data-test="`workflow-node-${data?.node_type}`"
|
||||
:class="{ 'wf-node-disabled': isDisabled, 'wf-result-active': isActiveResult }"
|
||||
:class="{
|
||||
'wf-node-disabled': isDisabled,
|
||||
'wf-result-active': isActiveResult,
|
||||
'wf-needs-setup': needsSetupHighlight,
|
||||
}"
|
||||
@click="onClick"
|
||||
@mouseenter="handleNodeHover"
|
||||
@mouseleave="handleNodeLeave"
|
||||
@output-click="onOutputClick"
|
||||
>
|
||||
<!-- Two-line body: the custom NAME (bold) on top when renamed, then a single
|
||||
muted line combining the TYPE and config DETAIL as "Type · detail". Without
|
||||
@@ -241,7 +244,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const { editNode, requestDeleteNode, openStepPicker } = useWorkflowCanvas(t);
|
||||
const { editNode, requestDeleteNode } = useWorkflowCanvas(t);
|
||||
|
||||
// This node's live record in the shared graph — the source for meta-backed
|
||||
// display (custom name, comment, disabled). Reactive: mutating meta re-renders.
|
||||
@@ -256,13 +259,17 @@ const isDisabled = computed(() => isNodeDisabled(node.value));
|
||||
const isIncomplete = computed(() => isNodeIncomplete(node.value));
|
||||
const onToggleDisabled = () => toggleNodeDisabled(props.id);
|
||||
|
||||
// This node is the one currently shown in the results dock — highlight it on the
|
||||
// canvas so, even with the step list hidden (right dock), it's clear which node's
|
||||
// Input/Output is on screen.
|
||||
// This node is the one whose results are open in the NDV — highlight it on the
|
||||
// canvas so it's clear which node's Input/Output is on screen (matters when the NDV
|
||||
// navigates prev/next between steps).
|
||||
const isActiveResult = computed(
|
||||
() => !!workflowObj.testRun.result && workflowObj.testRun.resultDrawer.nodeId === props.id,
|
||||
() => !!workflowObj.testRun.result && workflowObj.currentSelectedNodeID === props.id,
|
||||
);
|
||||
|
||||
// This node was flagged by Publish validation as needing setup (incomplete/dummy) —
|
||||
// flash a warning ring so the user sees exactly which steps block publishing.
|
||||
const needsSetupHighlight = computed(() => workflowObj.incompleteHighlight.includes(props.id));
|
||||
|
||||
// Test result badge state — read from the last Test run. Null (no run, or this
|
||||
// node wasn't part of a `from_node` run) → no badge. A node is a real ✓ only when
|
||||
// records ACTUALLY reached it (it's in the per-node `inputs` map) and it didn't
|
||||
@@ -374,14 +381,10 @@ const handleActionsLeave = () => {
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Clicking the source handle is the "add next step" affordance (it replaced the
|
||||
// hover-`+` that used to sit under the card). Terminal action nodes render no
|
||||
// source handle at all, so this can only fire where a next step is legal — and
|
||||
// it stays inert on the read-only Runs canvas.
|
||||
const onOutputClick = (event: MouseEvent) => {
|
||||
if (workflowObj.readOnly) return;
|
||||
openStepPicker(props.id, "out", event);
|
||||
};
|
||||
// Adding the next step / a fan-out branch is now the canvas-level append `+` under
|
||||
// (or beside) each node — see WorkflowCanvas.appendPoints — so the source handle is
|
||||
// just a connection point again (no click-to-add). The source dot still drags to
|
||||
// wire edges manually.
|
||||
|
||||
// On the read-only Runs canvas the node body isn't editable — the error badge
|
||||
// (openResult) is the only affordance. In the editor, click opens the config.
|
||||
@@ -390,9 +393,13 @@ const onClick = () => {
|
||||
editNode(props.id);
|
||||
};
|
||||
|
||||
// Open the per-node Input/Output result drawer (from the ✓ / error badge).
|
||||
// Clicking a node's ✓/✗ badge opens the node's NDV — the SAME Input · Config · Output
|
||||
// panel a node-click opens, now populated with this run's Input/Output (testRun.result
|
||||
// is set). One UI everywhere: the editor and the read-only Runs view both inspect a
|
||||
// step through the NDV (read-only there — see canvasReadOnly in WorkflowNodeDrawer),
|
||||
// so there's no separate results dock.
|
||||
const openResult = () => {
|
||||
workflowObj.testRun.resultDrawer = { show: true, nodeId: props.id };
|
||||
editNode(props.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!--
|
||||
Empty-canvas start scaffold card (Workflows). A DASHED, ghost placeholder that
|
||||
stands in for a not-yet-placed node — one per slot (Trigger, Action). Purely
|
||||
presentational: the wrapping canvas supplies the click via native @click
|
||||
fall-through, so this component just renders the tag / title / hint + tinted
|
||||
icon and the "waiting to be filled" dashed frame. Mirrors the reference
|
||||
builder's start state so the trigger→action shape reads before anything exists.
|
||||
-->
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:data-test="dataTest"
|
||||
class="bg-surface-base border-border-strong hover:border-accent rounded-surface flex w-64 items-center gap-3 border border-dashed px-3 py-3 text-left transition-colors"
|
||||
>
|
||||
<span :class="tint" class="rounded-default flex size-8 shrink-0 items-center justify-center">
|
||||
<OIcon :name="icon" size="md" />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="text-2xs text-text-secondary font-semibold tracking-wide uppercase">
|
||||
{{ tag }}
|
||||
</span>
|
||||
<span class="text-text-heading text-sm leading-tight font-bold">{{ title }}</span>
|
||||
<span class="text-text-secondary text-xs leading-snug">{{ hint }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import type { I18nText } from "@/types/i18n";
|
||||
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
|
||||
|
||||
defineProps<{
|
||||
/** Small uppercase kind label ("Trigger" / "Action"). */
|
||||
tag: I18nText;
|
||||
/** Primary line — the call to action ("Choose a Trigger"). */
|
||||
title: I18nText;
|
||||
/** One-line description of what this slot starts. */
|
||||
hint: I18nText;
|
||||
/** OIcon glyph for the slot. */
|
||||
icon: IconName;
|
||||
/** Icon-chip tint classes (soft badge bg + text). */
|
||||
tint: string;
|
||||
dataTest?: string;
|
||||
}>();
|
||||
</script>
|
||||
@@ -41,6 +41,7 @@ vi.mock("@/components/flow/forms/ConditionBuilder.vue", () => ({
|
||||
initialConditions: { default: null },
|
||||
normalizeOperators: { type: Boolean, default: false },
|
||||
normalizeColumnNames: { type: Boolean, default: false },
|
||||
optional: { type: Boolean, default: false },
|
||||
},
|
||||
methods: {
|
||||
submit: (...args: any[]) => builderSubmit(...args),
|
||||
@@ -67,7 +68,7 @@ function createWrapper() {
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: {
|
||||
OIcon: { name: "OIcon", props: ["name", "size"], template: "<i />" },
|
||||
OIcon: { name: "OIcon", props: ["name", "size"], template: '<i :data-name="name" />' },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -165,27 +166,48 @@ describe("WorkflowCondition", () => {
|
||||
// the two example snippets the note calls out
|
||||
expect(note.text()).toContain('severity != ""');
|
||||
expect(note.text()).toContain("severity != null");
|
||||
expect(note.findAll("i").length).toBe(3); // one OIcon per bullet
|
||||
// Count the BULLET icons specifically — the note also carries a dismiss
|
||||
// button, so a blanket icon count breaks whenever its chrome changes.
|
||||
expect(note.findAll('[data-name="info"]').length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("submit()", () => {
|
||||
it("proxies the builder's payload", async () => {
|
||||
const payload = { version: 2, conditions: { filterType: "group" } };
|
||||
builderSubmit.mockResolvedValue(payload);
|
||||
// The builder is `optional`, so it returns { version, conditions, complete }.
|
||||
// The wrapper strips `complete` (it must not persist into node data) and sets
|
||||
// the node's incomplete flag from it.
|
||||
it("returns { version, conditions } (complete stripped) for a complete rule", async () => {
|
||||
workflowObj.currentSelectedNodeData = { id: "c1", data: { node_type: "condition" } } as any;
|
||||
const conditions = { filterType: "group" };
|
||||
builderSubmit.mockResolvedValue({ version: 2, conditions, complete: true });
|
||||
const wrapper = createWrapper();
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual(payload);
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({ version: 2, conditions });
|
||||
expect(builderSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves null when the builder rejects the rule (returns null)", async () => {
|
||||
builderSubmit.mockResolvedValue(null);
|
||||
it("clears meta.incomplete when the rule is complete", async () => {
|
||||
workflowObj.currentSelectedNodeData = {
|
||||
id: "c1",
|
||||
data: { node_type: "condition" },
|
||||
meta: { incomplete: "true" },
|
||||
} as any;
|
||||
builderSubmit.mockResolvedValue({ version: 2, conditions: {}, complete: true });
|
||||
const wrapper = createWrapper();
|
||||
await expect((wrapper.vm as any).submit()).resolves.toBeNull();
|
||||
await (wrapper.vm as any).submit();
|
||||
expect(workflowObj.currentSelectedNodeData.meta?.incomplete).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes an undefined builder result to null", async () => {
|
||||
builderSubmit.mockResolvedValue(undefined);
|
||||
it("flags meta.incomplete when the rule is incomplete (placeholder)", async () => {
|
||||
workflowObj.currentSelectedNodeData = { id: "c1", data: { node_type: "condition" } } as any;
|
||||
builderSubmit.mockResolvedValue({ version: 2, conditions: {}, complete: false });
|
||||
const wrapper = createWrapper();
|
||||
const payload = await (wrapper.vm as any).submit();
|
||||
expect(payload).toEqual({ version: 2, conditions: {} });
|
||||
expect(workflowObj.currentSelectedNodeData.meta?.incomplete).toBe("true");
|
||||
});
|
||||
|
||||
it("resolves null when the builder returns null", async () => {
|
||||
builderSubmit.mockResolvedValue(null);
|
||||
const wrapper = createWrapper();
|
||||
await expect((wrapper.vm as any).submit()).resolves.toBeNull();
|
||||
});
|
||||
@@ -199,4 +221,9 @@ describe("WorkflowCondition", () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the builder as optional (incomplete rule = placeholder)", () => {
|
||||
const wrapper = createWrapper();
|
||||
expect(wrapper.findComponent({ name: "ConditionBuilder" }).props("optional")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,52 +22,84 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
carry the workflow filter wording.
|
||||
|
||||
A Condition is a filter (single output): matching records continue, the rest
|
||||
are dropped. WorkflowNodeDrawer's Save calls submit() → { version, conditions }
|
||||
or null when nothing is configured.
|
||||
are dropped. Dummy-node model (C1): the builder is `optional`, so an empty or
|
||||
incomplete rule doesn't block — the panel-close commit saves it as a placeholder
|
||||
(flagged `meta.incomplete`, Publish blocked) instead. submit() returns
|
||||
{ version, conditions } and sets the node's incomplete flag from the builder's
|
||||
`complete` result.
|
||||
-->
|
||||
<template>
|
||||
<div data-test="workflow-condition-body" class="w-full">
|
||||
<div data-test="workflow-condition-body" class="flex w-full flex-col">
|
||||
<!-- Config pane header (shared) — title + the guidelines info icon RIGHT AFTER
|
||||
it. Always visible; hovering the icon shows the tips (tooltip is the app-wide
|
||||
info pattern), reachable whether or not the first-run box below is dismissed. -->
|
||||
<WorkflowConfigHeader>
|
||||
<span
|
||||
class="text-text-secondary hover:text-text-body inline-flex cursor-help items-center"
|
||||
data-test="workflow-condition-guidelines-info"
|
||||
>
|
||||
<OIcon name="info" size="sm" />
|
||||
<OTooltip side="right" align="start" :side-offset="8" max-width="22rem">
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-1.5 p-1 text-left">
|
||||
<div class="text-xs font-semibold">{{ t("workflow.node.conditionNoteTitle") }}</div>
|
||||
<div v-for="(tip, i) in tips" :key="i" class="text-xs leading-snug">
|
||||
{{ tip.note }}
|
||||
<span
|
||||
v-if="tip.example"
|
||||
class="rounded-default bg-code-bg text-code-text px-1 py-px font-mono"
|
||||
>{{ tip.example }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</OTooltip>
|
||||
</span>
|
||||
</WorkflowConfigHeader>
|
||||
|
||||
<ConditionBuilder
|
||||
ref="builder"
|
||||
:fields="fields"
|
||||
:initial-conditions="savedConditions"
|
||||
normalize-column-names
|
||||
optional
|
||||
>
|
||||
<!-- The examples below are CODE SAMPLES held in en-US.json: only the
|
||||
illustrative column name ("severity") is meant to vary per locale —
|
||||
the operators and literals (`!=`, `""`, `null`) are expression syntax
|
||||
and must be copied verbatim, so translators should leave them alone. -->
|
||||
<template #guidelines>
|
||||
<!-- First-run hint; dismissed once, it stays gone (localStorage) and the
|
||||
recall icon above takes over. -->
|
||||
<div
|
||||
v-if="!guidelinesDismissed"
|
||||
class="bg-banner-warning-bg border-banner-warning-border text-banner-warning-text rounded-default mt-4 flex w-full flex-col gap-2 border p-3"
|
||||
data-test="workflow-condition-note"
|
||||
>
|
||||
<div class="text-sm font-bold">
|
||||
{{ t("workflow.node.conditionNoteTitle") }}
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="text-sm font-bold">{{ t("workflow.node.conditionNoteTitle") }}</div>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
data-test="workflow-condition-note-dismiss"
|
||||
:aria-label="t('common.close')"
|
||||
@click="dismissGuidelines"
|
||||
>
|
||||
<OIcon name="close" size="xs" />
|
||||
</OButton>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<div class="flex items-start gap-2">
|
||||
<div v-for="(tip, i) in tips" :key="i" class="flex items-start gap-2">
|
||||
<OIcon name="info" size="sm" class="text-status-warning-text mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{{ t("workflow.node.conditionNoteEmpty") }}
|
||||
<span class="rounded-default bg-code-bg text-code-text px-1 py-px font-mono">{{
|
||||
t("workflow.node.conditionExampleEmpty")
|
||||
}}</span>
|
||||
{{ tip.note }}
|
||||
<span
|
||||
v-if="tip.example"
|
||||
class="rounded-default bg-code-bg text-code-text px-1 py-px font-mono"
|
||||
>{{ tip.example }}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<OIcon name="info" size="sm" class="text-status-warning-text mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{{ t("workflow.node.conditionNoteNull") }}
|
||||
<span class="rounded-default bg-code-bg text-code-text px-1 py-px font-mono">{{
|
||||
t("workflow.node.conditionExampleNull")
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<OIcon name="info" size="sm" class="text-status-warning-text mt-0.5 shrink-0" />
|
||||
<span>{{ t("workflow.node.conditionNoteCustom") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -77,13 +109,42 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import { useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import ConditionBuilder from "@/components/flow/forms/ConditionBuilder.vue";
|
||||
import { workflowObj, currentTriggerKind } from "@/plugins/workflows/useWorkflowCanvas";
|
||||
import WorkflowConfigHeader from "./WorkflowConfigHeader.vue";
|
||||
import {
|
||||
workflowObj,
|
||||
currentTriggerKind,
|
||||
setNodeIncomplete,
|
||||
} from "@/plugins/workflows/useWorkflowCanvas";
|
||||
import { triggerDef } from "@/plugins/workflows/triggers";
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
|
||||
// The guidelines, as data — rendered in BOTH the inline first-run box and the
|
||||
// recall tooltip, so the copy lives in one place. `example` is a verbatim code
|
||||
// sample (see the template note); the custom-column tip has none.
|
||||
const tips: Array<{ note: I18nText; example?: I18nText }> = [
|
||||
{
|
||||
note: t("workflow.node.conditionNoteEmpty"),
|
||||
example: t("workflow.node.conditionExampleEmpty"),
|
||||
},
|
||||
{ note: t("workflow.node.conditionNoteNull"), example: t("workflow.node.conditionExampleNull") },
|
||||
{ note: t("workflow.node.conditionNoteCustom") },
|
||||
];
|
||||
|
||||
// The inline guidelines box is a first-run hint: shown until the user dismisses
|
||||
// it, then remembered so it never nags again. The recall icon (+ tooltip) is the
|
||||
// way back to the tips afterward.
|
||||
const GUIDELINES_SEEN_KEY = "workflows:conditionGuidelinesSeen";
|
||||
const guidelinesDismissed = ref(localStorage.getItem(GUIDELINES_SEEN_KEY) === "true");
|
||||
const dismissGuidelines = () => {
|
||||
guidelinesDismissed.value = true;
|
||||
localStorage.setItem(GUIDELINES_SEEN_KEY, "true");
|
||||
};
|
||||
// The pickable fields are the CURRENT trigger's payload fields, so an incident
|
||||
// workflow branches on incident fields and an alert workflow on alert fields.
|
||||
// With no trigger (it was deleted), we offer nothing rather than a wrong set —
|
||||
@@ -93,11 +154,18 @@ const fields = kind ? triggerDef(kind).conditionFields : [];
|
||||
const savedConditions = workflowObj.currentSelectedNodeData?.data?.conditions ?? null;
|
||||
|
||||
const builder = ref<any>(null);
|
||||
// The builder validates through its zod schema (async) and renders the error
|
||||
// inline, returning null when the rule is empty/incomplete. Custom columns are
|
||||
// normalized to the flattened form (dots → underscores) at creation time by
|
||||
// ConditionBuilder — see the `normalize-column-names` prop above.
|
||||
const submit = async () => (await builder.value?.submit()) ?? null;
|
||||
// The builder is `optional`, so submit() never blocks on an incomplete rule — it
|
||||
// returns { version, conditions, complete }. Flag the node incomplete when the rule
|
||||
// isn't complete (drives the "Set up later" badge + blocks Publish), then strip the
|
||||
// `complete` flag before it's persisted into node data. Custom columns are
|
||||
// normalized to the flattened form (dots → underscores) by ConditionBuilder.
|
||||
const submit = async () => {
|
||||
const payload = await builder.value?.submit();
|
||||
if (!payload) return null;
|
||||
const { complete, ...data } = payload;
|
||||
setNodeIncomplete(workflowObj.currentSelectedNodeData, !complete);
|
||||
return data;
|
||||
};
|
||||
|
||||
defineExpose({ submit });
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!--
|
||||
The "Configuration" pane header for every workflow node body — matches the
|
||||
Input / Output pane headers (same h-8 height + bold title) so all panes line up
|
||||
on one row. The default slot sits right after the title for per-node affordances
|
||||
(e.g. the Condition guidelines info icon).
|
||||
-->
|
||||
<template>
|
||||
<div class="flex h-8 shrink-0 items-center gap-1.5 pl-2">
|
||||
<span class="text-text-body text-sm font-bold">{{ t("workflow.ndv.config") }}</span>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
</script>
|
||||
@@ -0,0 +1,230 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// Read-only config readout shown in the NDV while inspecting a run. Covered here is
|
||||
// the DESTINATION branch, which resolves the node's stored destination NAME into the
|
||||
// actual request — a bare name cannot explain a failed delivery.
|
||||
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
|
||||
// Mocked at the MODULE level, not stubbed at mount: importing the real editor pulls
|
||||
// in the logs constants chain, which needs far more of the app than this spec does.
|
||||
vi.mock("@/components/CodeQueryEditor.vue", () => ({
|
||||
default: { name: "CodeQueryEditor", template: "<div />" },
|
||||
}));
|
||||
vi.mock("@/services/workflows", () => ({ default: {} }));
|
||||
vi.mock("@/services/jstransform", () => ({ default: { list: vi.fn().mockResolvedValue({}) } }));
|
||||
|
||||
const mockList = vi.fn();
|
||||
vi.mock("@/services/alert_destination", () => ({
|
||||
default: { list: (...a: any[]) => mockList(...a) },
|
||||
}));
|
||||
|
||||
import { workflowObj } from "@/plugins/workflows/useWorkflowCanvas";
|
||||
import { resetWorkflowDestinations } from "@/plugins/workflows/useWorkflowDestinations";
|
||||
import WorkflowConfigSummary from "./WorkflowConfigSummary.vue";
|
||||
|
||||
const CUSTOM = {
|
||||
name: "test",
|
||||
url: "https://api.example.com/hooks/o2",
|
||||
method: "post",
|
||||
destination_type_name: "custom",
|
||||
headers: { Authorization: "Bearer super-secret-token", "X-Env": "prod" },
|
||||
template: "alert_default",
|
||||
output_format: "json",
|
||||
skip_tls_verify: false,
|
||||
};
|
||||
|
||||
const seedDestinationNode = (destination_id = "test") => {
|
||||
workflowObj.currentSelectedNodeData = {
|
||||
id: "d1",
|
||||
data: { node_type: "destination", destination_id },
|
||||
} as any;
|
||||
};
|
||||
|
||||
function createWrapper() {
|
||||
return mount(WorkflowConfigSummary, {
|
||||
global: {
|
||||
plugins: [i18n, store],
|
||||
stubs: { WorkflowConfigHeader: { template: "<div />" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const card = (w: any) => w.find('[data-test="workflow-config-summary-destination"]');
|
||||
|
||||
describe("WorkflowConfigSummary — destination", () => {
|
||||
beforeEach(() => {
|
||||
resetWorkflowDestinations();
|
||||
workflowObj.currentSelectedNodeData = null;
|
||||
mockList.mockResolvedValue({ data: [CUSTOM] });
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
// ── Lookup ────────────────────────────────────────────────────────────────
|
||||
// There is no usable single-record endpoint: get_by_name omits `module` and 404s
|
||||
// on a pipeline destination. The list call is the only one that resolves them.
|
||||
|
||||
it("resolves the destination through the pipeline-module list endpoint", async () => {
|
||||
seedDestinationNode();
|
||||
createWrapper();
|
||||
await flushPromises();
|
||||
expect(mockList).toHaveBeenCalledWith(expect.objectContaining({ module: "pipeline" }));
|
||||
});
|
||||
|
||||
it("fetches once and reuses the cache across nodes", async () => {
|
||||
seedDestinationNode();
|
||||
createWrapper();
|
||||
await flushPromises();
|
||||
seedDestinationNode("test");
|
||||
createWrapper();
|
||||
await flushPromises();
|
||||
expect(mockList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fetch for a placeholder node with no destination", async () => {
|
||||
seedDestinationNode("");
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
expect(mockList).not.toHaveBeenCalled();
|
||||
expect(card(wrapper).exists()).toBe(false);
|
||||
expect(wrapper.text()).toContain("No Configuration");
|
||||
});
|
||||
|
||||
// ── The request ───────────────────────────────────────────────────────────
|
||||
|
||||
it("renders the method and full URL — what a 404 is actually about", async () => {
|
||||
seedDestinationNode();
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const url = wrapper.find('[data-test="workflow-config-summary-destination-url"]');
|
||||
expect(url.text()).toBe("https://api.example.com/hooks/o2");
|
||||
expect(card(wrapper).text()).toContain("POST"); // normalized to upper case
|
||||
});
|
||||
|
||||
it("defaults the method to POST when the record has none", async () => {
|
||||
mockList.mockResolvedValue({ data: [{ ...CUSTOM, method: undefined }] });
|
||||
seedDestinationNode();
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
expect(card(wrapper).text()).toContain("POST");
|
||||
});
|
||||
|
||||
it("lists header NAMES and never their values", async () => {
|
||||
seedDestinationNode();
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const text = card(wrapper).text();
|
||||
expect(text).toContain("Authorization");
|
||||
expect(text).toContain("X-Env");
|
||||
expect(text).not.toContain("super-secret-token");
|
||||
});
|
||||
|
||||
it("shows template and output format", async () => {
|
||||
seedDestinationNode();
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const text = card(wrapper).text();
|
||||
expect(text).toContain("alert_default");
|
||||
expect(text).toContain("JSON");
|
||||
});
|
||||
|
||||
it("accepts a template supplied as an object", async () => {
|
||||
mockList.mockResolvedValue({ data: [{ ...CUSTOM, template: { name: "obj_template" } }] });
|
||||
seedDestinationNode();
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
expect(card(wrapper).text()).toContain("obj_template");
|
||||
});
|
||||
|
||||
// TLS is reported only as an exception — "verification on" is the norm and would
|
||||
// just be noise in the readout.
|
||||
it("omits the TLS row when verification is on", async () => {
|
||||
seedDestinationNode();
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
expect(card(wrapper).text()).not.toContain("TLS Verification");
|
||||
});
|
||||
|
||||
it("shows the TLS row when verification is skipped", async () => {
|
||||
mockList.mockResolvedValue({ data: [{ ...CUSTOM, skip_tls_verify: true }] });
|
||||
seedDestinationNode();
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const text = card(wrapper).text();
|
||||
expect(text).toContain("TLS Verification");
|
||||
expect(text).toContain("Disabled");
|
||||
});
|
||||
|
||||
// ── Unsupported type ──────────────────────────────────────────────────────
|
||||
|
||||
it("flags a destination whose type is not custom", async () => {
|
||||
mockList.mockResolvedValue({ data: [{ ...CUSTOM, destination_type_name: "splunk" }] });
|
||||
seedDestinationNode();
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
expect(
|
||||
wrapper.find('[data-test="workflow-config-summary-destination-unsupported"]').exists(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag a custom destination", async () => {
|
||||
seedDestinationNode();
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
expect(
|
||||
wrapper.find('[data-test="workflow-config-summary-destination-unsupported"]').exists(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// ── Missing vs unknown ────────────────────────────────────────────────────
|
||||
// These two must not be conflated: claiming "no longer exists" because the lookup
|
||||
// itself failed is exactly the false report this panel is meant to prevent.
|
||||
|
||||
it("reports a destination that is genuinely absent, keeping the name visible", async () => {
|
||||
mockList.mockResolvedValue({ data: [] });
|
||||
seedDestinationNode("deleted-one");
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="workflow-config-summary-destination-missing"]').exists()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(card(wrapper).text()).toContain("deleted-one");
|
||||
});
|
||||
|
||||
it("does NOT claim the destination is gone when the lookup failed", async () => {
|
||||
mockList.mockRejectedValue({ response: { status: 500 } });
|
||||
seedDestinationNode();
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-test="workflow-config-summary-destination-missing"]').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
expect(card(wrapper).text()).toContain("test"); // name still shown
|
||||
});
|
||||
|
||||
it("does not flag an unsupported type while the record is unknown", async () => {
|
||||
mockList.mockRejectedValue(new Error("boom"));
|
||||
seedDestinationNode();
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
expect(
|
||||
wrapper.find('[data-test="workflow-config-summary-destination-unsupported"]').exists(),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<!--
|
||||
READ-ONLY config readout for the NDV, shown in place of the editable node form when
|
||||
the panel is inspecting a run (canvasReadOnly — the Runs history). A form reads as
|
||||
"what do I edit here?"; this is a display of WHAT THE NODE IS SET TO, per type:
|
||||
- Condition → the rule as a readable expression (buildConditionPreview).
|
||||
- Function → name + before/after-flatten badge, then the code read-only.
|
||||
- Destination → the destination name.
|
||||
- Trigger → the trigger kind (its payload shows in the Input/Output panes).
|
||||
Reads the current node's data reactively; the host keys it by node id so it swaps
|
||||
on prev/next navigation.
|
||||
-->
|
||||
<template>
|
||||
<div data-test="workflow-config-summary" class="flex min-h-0 w-full flex-1 flex-col gap-2">
|
||||
<WorkflowConfigHeader />
|
||||
|
||||
<!-- CONDITION — the filter rule as an expression, not a builder. Same code-block
|
||||
frame the alert history drawer uses for its condition readout. -->
|
||||
<template v-if="type === 'condition'">
|
||||
<div
|
||||
v-if="conditionText"
|
||||
data-test="workflow-config-summary-condition"
|
||||
class="border-border-default bg-surface-panel rounded-default flex flex-col overflow-hidden border"
|
||||
>
|
||||
<div
|
||||
class="border-border-default bg-surface-subtle flex shrink-0 items-center justify-between border-b px-2.5 py-1.5"
|
||||
>
|
||||
<span class="text-text-secondary text-2xs font-medium">{{
|
||||
t("alerts.alertDetails.conditions")
|
||||
}}</span>
|
||||
<OButton
|
||||
variant="ghost-muted"
|
||||
size="icon-xs-sq"
|
||||
data-test="workflow-config-summary-condition-copy"
|
||||
@click="copyConditions"
|
||||
>
|
||||
<OIcon name="content-copy" size="sm" />
|
||||
<OTooltip :content="t('common.copy')" />
|
||||
</OButton>
|
||||
</div>
|
||||
<pre
|
||||
class="text-compact m-0 overflow-x-auto p-[0.625rem_0.875rem] font-mono leading-relaxed whitespace-pre-wrap"
|
||||
>{{ conditionText }}</pre>
|
||||
</div>
|
||||
<p v-else class="text-text-secondary text-sm italic">{{ t("workflow.results.noConfig") }}</p>
|
||||
</template>
|
||||
|
||||
<!-- FUNCTION — name + flatten mode, then the code read-only. -->
|
||||
<template v-else-if="type === 'function'">
|
||||
<template v-if="functionName">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-text-secondary text-sm">{{ t("workflow.results.functionLabel") }}</span>
|
||||
<span class="text-text-body text-sm font-bold">{{ functionName }}</span>
|
||||
<OBadge variant="default-soft" size="sm">{{ flattenLabel }}</OBadge>
|
||||
</div>
|
||||
<div
|
||||
v-if="functionCode"
|
||||
data-test="workflow-config-summary-function"
|
||||
class="border-border-default bg-code-bg rounded-default flex min-h-0 flex-1 flex-col overflow-hidden border"
|
||||
>
|
||||
<CodeQueryEditor
|
||||
editor-id="workflow-config-summary-fn"
|
||||
language="javascript"
|
||||
:query="functionCode"
|
||||
:read-only="true"
|
||||
:show-auto-complete="false"
|
||||
class="min-h-0 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="text-text-secondary text-sm italic">{{ t("workflow.results.noConfig") }}</p>
|
||||
</template>
|
||||
|
||||
<!-- DESTINATION — the resolved request, not just the name. A bare name cannot
|
||||
explain a failed delivery; the URL and method are what a 404 is about. -->
|
||||
<template v-else-if="type === 'destination'">
|
||||
<div
|
||||
v-if="destinationName"
|
||||
data-test="workflow-config-summary-destination"
|
||||
class="border-border-default rounded-default flex flex-col gap-3 border px-3 py-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-text-secondary text-sm">{{
|
||||
t("workflow.results.destinationLabel")
|
||||
}}</span>
|
||||
<span class="text-text-body text-sm font-medium">{{ destinationName }}</span>
|
||||
<OBadge
|
||||
v-if="destinationUnsupported"
|
||||
variant="warning-soft"
|
||||
size="sm"
|
||||
data-test="workflow-config-summary-destination-unsupported"
|
||||
>{{ t("workflow.results.destinationUnsupported") }}</OBadge
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- The request line. Wraps rather than truncates — a clipped URL is the one
|
||||
thing that makes this panel useless again. -->
|
||||
<div v-if="destinationUrl" class="flex flex-wrap items-baseline gap-2">
|
||||
<OBadge variant="default-soft" size="sm">{{ destinationMethod }}</OBadge>
|
||||
<span
|
||||
data-test="workflow-config-summary-destination-url"
|
||||
class="text-text-body font-mono text-sm break-all"
|
||||
>{{ destinationUrl }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<dl v-if="destinationRows.length" class="flex flex-col gap-1">
|
||||
<div v-for="row in destinationRows" :key="row.key" class="flex gap-2 text-sm">
|
||||
<dt class="text-text-secondary w-32 shrink-0">{{ row.label }}</dt>
|
||||
<dd class="text-text-body break-words">{{ row.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p
|
||||
v-if="destinationMissing"
|
||||
data-test="workflow-config-summary-destination-missing"
|
||||
class="text-text-warning text-sm"
|
||||
>
|
||||
{{ t("workflow.results.destinationMissing") }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="text-text-secondary text-sm italic">{{ t("workflow.results.noConfig") }}</p>
|
||||
</template>
|
||||
|
||||
<!-- TRIGGER / OTHER — the trigger kind (payload is in the I/O panes). -->
|
||||
<template v-else>
|
||||
<div class="text-text-body text-sm" data-test="workflow-config-summary-trigger">
|
||||
{{ triggerKindTitle }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import OBadge from "@/lib/core/Badge/OBadge.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import CodeQueryEditor from "@/components/CodeQueryEditor.vue";
|
||||
import WorkflowConfigHeader from "./WorkflowConfigHeader.vue";
|
||||
import { buildConditionPreview } from "@/utils/conditionPreview";
|
||||
import functionsService from "@/services/jstransform";
|
||||
import { isCustomDestination } from "@/utils/destinationType";
|
||||
import { useWorkflowDestinations } from "@/plugins/workflows/useWorkflowDestinations";
|
||||
import type { Destination } from "@/ts/interfaces/alert";
|
||||
import { workflowObj, triggerDef } from "@/plugins/workflows/useWorkflowCanvas";
|
||||
import { DEFAULT_TRIGGER_KIND } from "@/plugins/workflows/triggers";
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
|
||||
const data = computed<any>(() => workflowObj.currentSelectedNodeData?.data || {});
|
||||
const type = computed<string>(() => data.value.node_type || "");
|
||||
|
||||
// Condition → the rule expression; "" when empty (placeholder) → noConfig.
|
||||
const conditionText = computed(() => buildConditionPreview(data.value.conditions));
|
||||
|
||||
const copyConditions = () => copyToClipboard(conditionText.value, t);
|
||||
|
||||
// Function → name + flatten mode. `after_flatten` = run AFTER flattening (RAF), else
|
||||
// BEFORE (RBF) — the same shorthand the function editor uses.
|
||||
const functionName = computed(() => data.value.name || "");
|
||||
const functionFlatten = computed(() => !!data.value.after_flatten);
|
||||
const flattenLabel = computed<I18nText>(() => raw(functionFlatten.value ? "RAF" : "RBF"));
|
||||
|
||||
// Destination → the target name (the Pipeline Destination's name). The node stores
|
||||
// only that name, so the record is fetched to show what the request actually is.
|
||||
const destinationName = computed(() => data.value.destination_id || "");
|
||||
|
||||
const { destinationsByName, destinationsLoaded, ensureWorkflowDestinations } =
|
||||
useWorkflowDestinations();
|
||||
|
||||
watch(
|
||||
[type, destinationName],
|
||||
([t2, name]) => {
|
||||
if (t2 === "destination" && name)
|
||||
ensureWorkflowDestinations(store.state.selectedOrganization.identifier);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const destinationRecord = computed<Destination | null>(
|
||||
() => destinationsByName.value[destinationName.value] || null,
|
||||
);
|
||||
|
||||
// Only claim the destination is gone once the lookup actually succeeded — a failed
|
||||
// list call means we don't know, and saying "no longer exists" there is a lie.
|
||||
const destinationMissing = computed(
|
||||
() => !!destinationName.value && destinationsLoaded.value && !destinationRecord.value,
|
||||
);
|
||||
|
||||
const destinationUrl = computed<I18nText>(() => raw(destinationRecord.value?.url) || raw(""));
|
||||
const destinationMethod = computed<I18nText>(() =>
|
||||
raw((destinationRecord.value?.method || "POST").toUpperCase()),
|
||||
);
|
||||
// Only flagged once the record is actually loaded — an in-flight fetch is not "unsupported".
|
||||
const destinationUnsupported = computed(
|
||||
() => !!destinationRecord.value && !isCustomDestination(destinationRecord.value),
|
||||
);
|
||||
|
||||
// Header VALUES are withheld — this panel gets screenshotted and pasted into
|
||||
// tickets, and an Authorization token should not travel with it.
|
||||
const destinationHeaderNames = computed(() => Object.keys(destinationRecord.value?.headers || {}));
|
||||
|
||||
const destinationRows = computed<{ key: string; label: I18nText; value: I18nText }[]>(() => {
|
||||
const d = destinationRecord.value;
|
||||
if (!d) return [];
|
||||
const rows: { key: string; label: I18nText; value: I18nText }[] = [];
|
||||
|
||||
if (destinationHeaderNames.value.length) {
|
||||
rows.push({
|
||||
key: "headers",
|
||||
label: t("workflow.results.headersLabel"),
|
||||
value: raw(destinationHeaderNames.value.join(", ")),
|
||||
});
|
||||
}
|
||||
const template = typeof d.template === "string" ? d.template : d.template?.name;
|
||||
if (template) {
|
||||
rows.push({
|
||||
key: "template",
|
||||
label: t("workflow.results.templateLabel"),
|
||||
value: raw(template),
|
||||
});
|
||||
}
|
||||
if (d.output_format) {
|
||||
rows.push({
|
||||
key: "output_format",
|
||||
label: t("workflow.results.outputFormatLabel"),
|
||||
value: raw(String(d.output_format).toUpperCase()),
|
||||
});
|
||||
}
|
||||
// Shown only when verification is OFF — the exception is the information; "on" is
|
||||
// the norm and would just be noise.
|
||||
if (d.skip_tls_verify) {
|
||||
rows.push({
|
||||
key: "tls",
|
||||
label: t("workflow.results.tlsVerifyLabel"),
|
||||
value: t("workflow.results.tlsDisabled"),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
// Trigger → its display title (e.g. "Alert Fired").
|
||||
const triggerKindTitle = computed<I18nText>(() =>
|
||||
t(triggerDef(data.value.trigger_kind || DEFAULT_TRIGGER_KIND).nodeTitleKey),
|
||||
);
|
||||
|
||||
// A function node stores only its NAME — fetch definitions once to show the code
|
||||
// read-only. On failure the name + flatten badge still render (code just omitted).
|
||||
const functionDefs = ref<Record<string, string>>({});
|
||||
const loadFunctionDefs = async () => {
|
||||
try {
|
||||
const res = await functionsService.list(
|
||||
1,
|
||||
100000,
|
||||
"name",
|
||||
false,
|
||||
"",
|
||||
store.state.selectedOrganization.identifier,
|
||||
);
|
||||
const list = res.data?.list || [];
|
||||
const map: Record<string, string> = {};
|
||||
for (const f of list) map[f.name] = f.function;
|
||||
functionDefs.value = map;
|
||||
} catch {
|
||||
/* code omitted — name + flatten still show */
|
||||
}
|
||||
};
|
||||
watch(
|
||||
type,
|
||||
(v) => {
|
||||
if (v === "function") loadFunctionDefs();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const functionCode = computed(() => functionDefs.value[functionName.value] || "");
|
||||
</script>
|
||||
@@ -106,9 +106,9 @@ describe("WorkflowDestination", () => {
|
||||
expect(picker(wrapper).props("forcedType")).toBe("custom");
|
||||
});
|
||||
|
||||
it("renders the 'Set up later' toggle", () => {
|
||||
it("renders the picker as optional (empty selection = dummy node)", () => {
|
||||
const wrapper = createWrapper();
|
||||
expect(wrapper.find('[data-test="workflow-destination-set-up-later"]').exists()).toBe(true);
|
||||
expect(picker(wrapper).props("optional")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,15 +181,31 @@ describe("WorkflowDestination", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("submit() — required destination (drawer Save)", () => {
|
||||
it("blocks Save (null) when the picker has no destination", async () => {
|
||||
// the required picker resolves null on empty — Save is blocked, no placeholder
|
||||
pickerSubmit.mockResolvedValue(null);
|
||||
// Dummy-node model (C1): no "Set up later" toggle. An empty picker result saves
|
||||
// the node as a placeholder (empty destination_id + meta.incomplete); a real
|
||||
// selection clears it. submit() always defers to the (optional) picker, and only
|
||||
// returns null while the inline create form is open (picker resolves null).
|
||||
describe("dummy-node placeholder (no toggle)", () => {
|
||||
it("does not render a 'Set up later' toggle", () => {
|
||||
const wrapper = createWrapper();
|
||||
await expect((wrapper.vm as any).submit()).resolves.toBeNull();
|
||||
expect(wrapper.find('[data-test="workflow-destination-set-up-later"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("clears meta.incomplete when set-up-later is turned off and a real destination is chosen", async () => {
|
||||
it("flags meta.incomplete when the picker returns an empty destination", async () => {
|
||||
workflowObj.currentSelectedNodeData = {
|
||||
id: "d1",
|
||||
data: { node_type: "destination" },
|
||||
} as any;
|
||||
pickerSubmit.mockResolvedValue({ org_id: "default", destination_name: "" });
|
||||
const wrapper = createWrapper();
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({
|
||||
destination_id: "",
|
||||
template_override: null,
|
||||
});
|
||||
expect(workflowObj.currentSelectedNodeData.meta?.incomplete).toBe("true");
|
||||
});
|
||||
|
||||
it("clears meta.incomplete when a real destination is chosen", async () => {
|
||||
workflowObj.currentSelectedNodeData = {
|
||||
id: "d1",
|
||||
data: { node_type: "destination" },
|
||||
@@ -197,60 +213,17 @@ describe("WorkflowDestination", () => {
|
||||
} as any;
|
||||
pickerSubmit.mockResolvedValue({ org_id: "default", destination_name: "sink-a" });
|
||||
const wrapper = createWrapper();
|
||||
// reopening a placeholder defaults the toggle ON — turn it OFF to pick a real one
|
||||
wrapper.findComponent({ name: "OSwitch" }).vm.$emit("update:modelValue", false);
|
||||
await wrapper.vm.$nextTick();
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({
|
||||
destination_id: "sink-a",
|
||||
template_override: null,
|
||||
});
|
||||
expect(workflowObj.currentSelectedNodeData.meta?.incomplete).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("'Set up later' toggle — placeholder", () => {
|
||||
// The toggle is a real OSwitch; drive it through its v-model emit.
|
||||
const toggle = (wrapper: any, on: boolean) =>
|
||||
wrapper.findComponent({ name: "OSwitch" }).vm.$emit("update:modelValue", on);
|
||||
|
||||
it("greys the picker (disabled + optional) when toggled on", async () => {
|
||||
it("blocks Save (null) while the inline create form is open (picker null)", async () => {
|
||||
pickerSubmit.mockResolvedValue(null);
|
||||
const wrapper = createWrapper();
|
||||
expect(picker(wrapper).props("disabled")).toBe(false);
|
||||
toggle(wrapper, true);
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(picker(wrapper).props("disabled")).toBe(true);
|
||||
expect(picker(wrapper).props("optional")).toBe(true);
|
||||
});
|
||||
|
||||
it("Save (submit) returns an empty placeholder and flags meta.incomplete", async () => {
|
||||
workflowObj.currentSelectedNodeData = {
|
||||
id: "d1",
|
||||
data: { node_type: "destination" },
|
||||
} as any;
|
||||
const wrapper = createWrapper();
|
||||
toggle(wrapper, true);
|
||||
await wrapper.vm.$nextTick();
|
||||
// the picker is NOT consulted while set-up-later is on
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({
|
||||
destination_id: "",
|
||||
template_override: null,
|
||||
});
|
||||
expect(pickerSubmit).not.toHaveBeenCalled();
|
||||
expect(workflowObj.currentSelectedNodeData.meta?.incomplete).toBe("true");
|
||||
});
|
||||
|
||||
it("defaults ON (picker greyed) when reopening a placeholder node", async () => {
|
||||
workflowObj.currentSelectedNodeData = {
|
||||
id: "d1",
|
||||
data: { node_type: "destination" },
|
||||
meta: { incomplete: "true" },
|
||||
} as any;
|
||||
const wrapper = createWrapper();
|
||||
expect(picker(wrapper).props("disabled")).toBe(true);
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({
|
||||
destination_id: "",
|
||||
template_override: null,
|
||||
});
|
||||
await expect((wrapper.vm as any).submit()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,44 +23,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
(node_type `destination`). `destination_id` is the Pipeline Destination's NAME
|
||||
(the shared picker returns it as `destination_name`; we remap here).
|
||||
|
||||
"Set up later" is a SECTION-LEVEL toggle at the top: turning it on greys out the
|
||||
whole destination picker below (its "Create New" switch included), so it reads as
|
||||
a parent choice rather than a peer of that switch. Only the DESTINATION is skipped
|
||||
— the node name + comment stay editable. The drawer "Save" then commits a
|
||||
PLACEHOLDER (empty destination, flagged `meta.incomplete`). Draft + test still run;
|
||||
Dummy-node model (C1): there is NO "Set up later" toggle. Leaving the picker empty
|
||||
simply saves the node as a PLACEHOLDER (empty destination, flagged
|
||||
`meta.incomplete`) — an unconfigured node IS a dummy node. Draft + test still run;
|
||||
Publish is blocked until a destination is set.
|
||||
-->
|
||||
<template>
|
||||
<div data-test="workflow-destination-body" class="flex w-full flex-col gap-4">
|
||||
<!-- Section-level "Set up later" toggle — left-aligned with the controls below
|
||||
(no inset box) and separated by a divider, so it reads as a parent choice
|
||||
governing the destination section, not another inline switch. -->
|
||||
<div class="border-border-default flex flex-col gap-1.5 border-b pb-4">
|
||||
<OSwitch
|
||||
v-model="setUpLater"
|
||||
:label="t('workflow.node.destinationSetUpLater')"
|
||||
data-test="workflow-destination-set-up-later"
|
||||
/>
|
||||
<div class="text-text-secondary text-xs leading-snug">
|
||||
{{
|
||||
setUpLater
|
||||
? t("workflow.node.destinationSetUpLaterActive")
|
||||
: t("workflow.node.destinationSetUpLaterPrompt")
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-test="workflow-destination-body" class="flex w-full flex-col gap-2">
|
||||
<!-- Config pane header (shared) — always shown, including while the inline
|
||||
"Create New Destination" form is open. -->
|
||||
<WorkflowConfigHeader />
|
||||
<!-- Workflows only support Custom HTTP destinations for now, so lock the inline
|
||||
create form to Custom. Greyed + faded while "Set up later" is on so it's
|
||||
clearly governed by the toggle above. -->
|
||||
create form to Custom. Optional: an empty selection saves a placeholder. -->
|
||||
<DestinationPicker
|
||||
ref="picker"
|
||||
class="transition-opacity"
|
||||
:class="{ 'pointer-events-none opacity-50': setUpLater }"
|
||||
:initial-name="savedData.destination_id || ''"
|
||||
forced-type="custom"
|
||||
:optional="setUpLater"
|
||||
:disabled="setUpLater"
|
||||
optional
|
||||
@expand="(v) => (creating = v)"
|
||||
/>
|
||||
</div>
|
||||
@@ -68,45 +47,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import DestinationPicker from "@/components/flow/forms/DestinationPicker.vue";
|
||||
import {
|
||||
workflowObj,
|
||||
setNodeIncomplete,
|
||||
isNodeIncomplete,
|
||||
} from "@/plugins/workflows/useWorkflowCanvas";
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
import WorkflowConfigHeader from "./WorkflowConfigHeader.vue";
|
||||
import { workflowObj, setNodeIncomplete } from "@/plugins/workflows/useWorkflowCanvas";
|
||||
|
||||
const savedData: any = workflowObj.currentSelectedNodeData?.data || {};
|
||||
const picker = ref<any>(null);
|
||||
const creating = ref(false);
|
||||
|
||||
// Selected/pressed state for the "Set up later" button. Defaults ON when reopening
|
||||
// a node already saved as a placeholder (flagged incomplete); otherwise OFF.
|
||||
const setUpLater = ref(isNodeIncomplete(workflowObj.currentSelectedNodeData));
|
||||
|
||||
// Drawer "Save":
|
||||
// • Set up later ON → commit a PLACEHOLDER (empty destination, flag incomplete).
|
||||
// • Set up later OFF → require a real destination via the picker (null blocks Save
|
||||
// with the picker's inline error, or means the inline create form is open).
|
||||
// Drawer "Save": the picker is optional, so submit() resolves a payload even with
|
||||
// no destination selected (empty name = placeholder). It only returns null while
|
||||
// the inline "Create New Destination" editor is open. Flag the node incomplete when
|
||||
// no destination was picked — that drives the warning badge and blocks Publish.
|
||||
const submit = async () => {
|
||||
const node = workflowObj.currentSelectedNodeData;
|
||||
if (setUpLater.value) {
|
||||
setNodeIncomplete(node, true);
|
||||
return { destination_id: "", template_override: savedData.template_override ?? null };
|
||||
}
|
||||
const payload = await picker.value?.submit();
|
||||
if (!payload) return null;
|
||||
setNodeIncomplete(node, false);
|
||||
const destination_id = payload.destination_name || "";
|
||||
setNodeIncomplete(node, !destination_id);
|
||||
return {
|
||||
destination_id: payload.destination_name || "",
|
||||
destination_id,
|
||||
template_override: savedData.template_override ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
// `createNewDestination` lets WorkflowNodeDrawer hide its footer while the inline
|
||||
// create form (with its own Save/Cancel) is open. `setUpLater` exposed for tests.
|
||||
defineExpose({ submit, setUpLater, createNewDestination: creating });
|
||||
// create form (with its own Save/Cancel) is open.
|
||||
defineExpose({ submit, createNewDestination: creating });
|
||||
</script>
|
||||
|
||||
@@ -36,7 +36,18 @@ const pickerSubmit = vi.fn();
|
||||
vi.mock("@/components/flow/forms/FunctionPicker.vue", () => ({
|
||||
default: {
|
||||
name: "FunctionPicker",
|
||||
props: ["initialName", "initialAfterFlatten", "sampleEvents", "language", "defaultCode"],
|
||||
// Object form (not an array): boolean props must be typed Boolean so a shorthand
|
||||
// attribute coerces to `true` instead of surfacing as `""`.
|
||||
props: {
|
||||
initialName: {},
|
||||
initialRawFn: {},
|
||||
initialAfterFlatten: {},
|
||||
sampleEvents: {},
|
||||
language: {},
|
||||
defaultCode: {},
|
||||
optional: { type: Boolean, default: false },
|
||||
createButton: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ["expand"],
|
||||
methods: {
|
||||
submit: (...args: any[]) => pickerSubmit(...args),
|
||||
@@ -84,6 +95,16 @@ describe("WorkflowFunction", () => {
|
||||
expect(picker(wrapper).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("renders the picker as optional (empty selection = dummy node)", () => {
|
||||
const wrapper = createWrapper();
|
||||
expect(picker(wrapper).props("optional")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses single-screen mode (create-button, no mode switch)", () => {
|
||||
const wrapper = createWrapper();
|
||||
expect(picker(wrapper).props("createButton")).toBe(true);
|
||||
});
|
||||
|
||||
it("passes an empty initial-name and after-flatten=false by default", () => {
|
||||
const wrapper = createWrapper();
|
||||
expect(picker(wrapper).props("initialName")).toBe("");
|
||||
@@ -101,6 +122,16 @@ describe("WorkflowFunction", () => {
|
||||
expect(picker(wrapper).props("initialAfterFlatten")).toBe(false);
|
||||
});
|
||||
|
||||
it("seeds initial-raw-fn from a saved inline (nameless) node", () => {
|
||||
workflowObj.currentSelectedNodeData = {
|
||||
id: "n1",
|
||||
data: { node_type: "function", name: "", raw_fn: "() => 1", after_flatten: true },
|
||||
} as any;
|
||||
const wrapper = createWrapper();
|
||||
expect(picker(wrapper).props("initialName")).toBe("");
|
||||
expect(picker(wrapper).props("initialRawFn")).toBe("() => 1");
|
||||
});
|
||||
|
||||
it("defaults after-flatten to false when only a name is saved", () => {
|
||||
workflowObj.currentSelectedNodeData = {
|
||||
id: "n1",
|
||||
@@ -195,64 +226,45 @@ describe("WorkflowFunction", () => {
|
||||
const wrapper = createWrapper();
|
||||
await expect((wrapper.vm as any).submit()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("proxies an inline raw_fn payload and flags incomplete (empty name)", async () => {
|
||||
workflowObj.currentSelectedNodeData = { id: "f1", data: { node_type: "function" } } as any;
|
||||
pickerSubmit.mockResolvedValue({ name: "", raw_fn: "() => 2", after_flatten: true });
|
||||
const wrapper = createWrapper();
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({
|
||||
name: "",
|
||||
raw_fn: "() => 2",
|
||||
after_flatten: true,
|
||||
});
|
||||
// raw_fn has an empty name → treated the same as a dummy (blocks Publish).
|
||||
expect(workflowObj.currentSelectedNodeData.meta?.incomplete).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("'Set up later' toggle — placeholder", () => {
|
||||
// The toggle is a real OSwitch; drive it through its v-model emit.
|
||||
const toggle = (wrapper: any, on: boolean) =>
|
||||
wrapper.findComponent({ name: "OSwitch" }).vm.$emit("update:modelValue", on);
|
||||
|
||||
it("renders the 'Set up later' toggle", () => {
|
||||
// Dummy-node model (C1): no "Set up later" toggle. An empty picker result saves
|
||||
// the node as a placeholder (empty name + meta.incomplete); a real selection
|
||||
// clears it. submit() always defers to the (optional) picker.
|
||||
describe("dummy-node placeholder (no toggle)", () => {
|
||||
it("does not render a 'Set up later' toggle", () => {
|
||||
const wrapper = createWrapper();
|
||||
expect(wrapper.find('[data-test="workflow-function-set-up-later"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test="workflow-function-set-up-later"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("Save (submit) returns an empty function name and flags meta.incomplete", async () => {
|
||||
it("flags meta.incomplete when the picker returns an empty function name", async () => {
|
||||
workflowObj.currentSelectedNodeData = {
|
||||
id: "f1",
|
||||
data: { node_type: "function" },
|
||||
} as any;
|
||||
pickerSubmit.mockResolvedValue({ name: "", after_flatten: false });
|
||||
const wrapper = createWrapper();
|
||||
toggle(wrapper, true);
|
||||
await wrapper.vm.$nextTick();
|
||||
// the picker is NOT consulted while set-up-later is on
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({
|
||||
name: "",
|
||||
after_flatten: false,
|
||||
});
|
||||
expect(pickerSubmit).not.toHaveBeenCalled();
|
||||
expect(workflowObj.currentSelectedNodeData.meta?.incomplete).toBe("true");
|
||||
});
|
||||
|
||||
it("preserves a saved after_flatten in the placeholder payload", async () => {
|
||||
workflowObj.currentSelectedNodeData = {
|
||||
id: "f1",
|
||||
data: { node_type: "function", after_flatten: true },
|
||||
} as any;
|
||||
const wrapper = createWrapper();
|
||||
toggle(wrapper, true);
|
||||
await wrapper.vm.$nextTick();
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({
|
||||
name: "",
|
||||
after_flatten: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults ON when reopening a placeholder node", async () => {
|
||||
workflowObj.currentSelectedNodeData = {
|
||||
id: "f1",
|
||||
data: { node_type: "function" },
|
||||
meta: { incomplete: "true" },
|
||||
} as any;
|
||||
const wrapper = createWrapper();
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({
|
||||
name: "",
|
||||
after_flatten: false,
|
||||
});
|
||||
expect(pickerSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears meta.incomplete when toggled off and a real function is chosen", async () => {
|
||||
it("clears meta.incomplete when a real function is chosen", async () => {
|
||||
workflowObj.currentSelectedNodeData = {
|
||||
id: "f1",
|
||||
data: { node_type: "function" },
|
||||
@@ -260,9 +272,6 @@ describe("WorkflowFunction", () => {
|
||||
} as any;
|
||||
pickerSubmit.mockResolvedValue({ name: "redact", after_flatten: false });
|
||||
const wrapper = createWrapper();
|
||||
// reopening a placeholder defaults the toggle ON — turn it OFF to pick a real one
|
||||
toggle(wrapper, false);
|
||||
await wrapper.vm.$nextTick();
|
||||
await expect((wrapper.vm as any).submit()).resolves.toEqual({
|
||||
name: "redact",
|
||||
after_flatten: false,
|
||||
|
||||
@@ -20,72 +20,79 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
alert payload, with the After-Flattening (RAF/RBF) toggle, or creates one
|
||||
inline. Data payload -> NodeData::Function { name, after_flatten }.
|
||||
|
||||
"Set up later" (same as the Destination node) is a section-level toggle: turning
|
||||
it on greys out the picker and lets the node be saved as a PLACEHOLDER (empty
|
||||
function name, flagged `meta.incomplete`). Draft + test still run; Publish is
|
||||
blocked until a function is set. WorkflowNodeDrawer's Save calls submit().
|
||||
Dummy-node model (C1): there is NO "Set up later" toggle. Leaving the picker
|
||||
empty simply saves the node as a PLACEHOLDER (empty function name, flagged
|
||||
`meta.incomplete`) — an unconfigured node IS a dummy node. Draft + test still
|
||||
run; Publish is blocked until a function is set. WorkflowNodeDrawer's Save calls
|
||||
submit(), which sets `incomplete` from whether a function was picked.
|
||||
-->
|
||||
<template>
|
||||
<div data-test="workflow-function-body" class="flex w-full flex-col gap-4">
|
||||
<!-- Section-level "Set up later" toggle — left-aligned with the picker below and
|
||||
separated by a divider, so it reads as a parent choice. Hidden while the
|
||||
inline create editor is expanded (it owns the full drawer). -->
|
||||
<div
|
||||
v-if="!workflowObj.dialog.expand"
|
||||
class="border-border-default flex flex-col gap-1.5 border-b pb-4"
|
||||
>
|
||||
<OSwitch
|
||||
v-model="setUpLater"
|
||||
:label="t('workflow.node.functionSetUpLater')"
|
||||
data-test="workflow-function-set-up-later"
|
||||
<div data-test="workflow-function-body" class="flex min-h-0 w-full flex-1 flex-col gap-2">
|
||||
<!-- Config pane header (shared) — hidden in the inline "Create New Function"
|
||||
editor, which owns the whole width. The info icon RIGHT AFTER the title
|
||||
explains before- vs after-flattening (same pattern as the Condition node). -->
|
||||
<WorkflowConfigHeader v-if="!workflowObj.dialog.expand">
|
||||
<span
|
||||
class="text-text-secondary hover:text-text-body inline-flex cursor-help items-center"
|
||||
data-test="workflow-function-flatten-info"
|
||||
>
|
||||
<OIcon name="info" size="sm" />
|
||||
<OTooltip side="right" align="start" :side-offset="8" max-width="22rem">
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-1.5 p-1 text-left">
|
||||
<div class="text-xs font-semibold">
|
||||
{{ t("workflow.node.functionFlattenTitle") }}
|
||||
</div>
|
||||
<div class="text-xs leading-snug">
|
||||
<span class="font-semibold">{{ t("flow.function.rbf") }}</span>
|
||||
{{ t("flow.function.rbfDesc") }}
|
||||
</div>
|
||||
<div class="text-xs leading-snug">
|
||||
<span class="font-semibold">{{ t("flow.function.raf") }}</span>
|
||||
{{ t("flow.function.rafDesc") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</OTooltip>
|
||||
</span>
|
||||
</WorkflowConfigHeader>
|
||||
<!-- Optional: an empty selection saves a placeholder (dummy node). Wrapped so the
|
||||
picker/editor fills the space below the header. -->
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<FunctionPicker
|
||||
ref="picker"
|
||||
:initial-name="savedData.name || ''"
|
||||
:initial-raw-fn="savedData.raw_fn || ''"
|
||||
:initial-after-flatten="savedData.after_flatten ?? false"
|
||||
:sample-events="sampleEvents"
|
||||
language="javascript"
|
||||
optional
|
||||
create-button
|
||||
:default-code="JS_DEFAULT_CODE"
|
||||
@expand="onExpand"
|
||||
/>
|
||||
<div class="text-text-secondary text-xs leading-snug">
|
||||
{{
|
||||
setUpLater
|
||||
? t("workflow.node.functionSetUpLaterActive")
|
||||
: t("workflow.node.functionSetUpLaterPrompt")
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Greyed + faded while "Set up later" is on so it's clearly governed by the
|
||||
toggle above. -->
|
||||
<FunctionPicker
|
||||
ref="picker"
|
||||
class="transition-opacity"
|
||||
:class="{ 'pointer-events-none opacity-50': setUpLater }"
|
||||
:initial-name="savedData.name || ''"
|
||||
:initial-after-flatten="savedData.after_flatten ?? false"
|
||||
:sample-events="sampleEvents"
|
||||
language="javascript"
|
||||
:default-code="JS_DEFAULT_CODE"
|
||||
@expand="onExpand"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onBeforeUnmount } from "vue";
|
||||
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import FunctionPicker from "@/components/flow/forms/FunctionPicker.vue";
|
||||
import WorkflowConfigHeader from "./WorkflowConfigHeader.vue";
|
||||
import {
|
||||
workflowObj,
|
||||
currentTriggerKind,
|
||||
setNodeIncomplete,
|
||||
isNodeIncomplete,
|
||||
} from "@/plugins/workflows/useWorkflowCanvas";
|
||||
import { triggerDef } from "@/plugins/workflows/triggers";
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
|
||||
const savedData: any = workflowObj.currentSelectedNodeData?.data || {};
|
||||
const picker = ref<any>(null);
|
||||
|
||||
// Selected state for the "Set up later" toggle. Defaults ON when reopening a node
|
||||
// already saved as a placeholder (flagged incomplete); otherwise OFF.
|
||||
const setUpLater = ref(isNodeIncomplete(workflowObj.currentSelectedNodeData));
|
||||
|
||||
// Seed code for a brand-new workflow function. Workflow functions are
|
||||
// JavaScript: the whole trigger event arrives as `row`; mutate it in place.
|
||||
// Kept trigger-agnostic (the event shape differs per kind — alert vs incident);
|
||||
@@ -110,21 +117,25 @@ onBeforeUnmount(() => {
|
||||
workflowObj.dialog.expand = false;
|
||||
});
|
||||
|
||||
// Drawer "Save":
|
||||
// • Set up later ON → commit a PLACEHOLDER (empty function name, flag incomplete).
|
||||
// • Set up later OFF → require a real function via the picker (null blocks Save
|
||||
// with the picker's inline error, or means the inline create form is open).
|
||||
// Drawer "Save": the picker is optional, so submit() resolves a payload even with
|
||||
// no function selected (empty name = placeholder). It only returns null while the
|
||||
// inline "Create New Function" editor is open. Flag the node incomplete when no saved
|
||||
// function is referenced — inline `raw_fn` has an empty name, so it's treated the same
|
||||
// as an unconfigured dummy ("Set up later"): runs in Test, but blocks Publish until
|
||||
// the function is saved (the backend rejects raw_fn / empty name on publish too).
|
||||
const submit = async () => {
|
||||
const node = workflowObj.currentSelectedNodeData;
|
||||
if (setUpLater.value) {
|
||||
setNodeIncomplete(node, true);
|
||||
return { name: "", after_flatten: savedData.after_flatten ?? false };
|
||||
}
|
||||
const payload = (await picker.value?.submit()) ?? null;
|
||||
if (!payload) return null;
|
||||
setNodeIncomplete(node, false);
|
||||
setNodeIncomplete(node, !payload.name);
|
||||
return payload;
|
||||
};
|
||||
|
||||
defineExpose({ submit, setUpLater });
|
||||
// Exposed for WorkflowNodeDrawer's save-or-discard exit prompt (unsaved inline/edited
|
||||
// function code must be resolved before leaving the node — never auto-saved).
|
||||
const isDirty = () => !!picker.value?.isDirty?.();
|
||||
const discardChanges = () => picker.value?.discardChanges?.();
|
||||
const saveChanges = () => picker.value?.saveChanges?.();
|
||||
|
||||
defineExpose({ submit, isDirty, discardChanges, saveChanges });
|
||||
</script>
|
||||
|
||||
@@ -95,9 +95,12 @@ describe("WorkflowTrigger", () => {
|
||||
expect(editor(w).props("readOnly")).toBe(true);
|
||||
});
|
||||
|
||||
// Monaco collapses without a definite height. The box now gets one by filling
|
||||
// its flex parent (min-h-0 + flex-1) rather than carrying a fixed h-* class.
|
||||
it("gives the editor a definite height (Monaco collapses without one)", () => {
|
||||
const box = createWrapper().find('[data-test="workflow-trigger-structure"]');
|
||||
expect(box.classes().some((c: string) => /^h-\d/.test(c))).toBe(true);
|
||||
expect(box.classes()).toContain("flex-1");
|
||||
expect(box.classes()).toContain("min-h-0");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
WorkflowEditor); there are no editable fields.
|
||||
-->
|
||||
<template>
|
||||
<div data-test="workflow-trigger-body" class="flex w-full flex-col">
|
||||
<div data-test="workflow-trigger-body" class="flex min-h-0 w-full flex-1 flex-col">
|
||||
<!-- Config pane header (shared) — the trigger has no editable config, so this
|
||||
pane is a read-only payload reference under the same header. -->
|
||||
<WorkflowConfigHeader class="mb-2" />
|
||||
<p class="text-text-secondary mb-3 text-xs leading-normal">
|
||||
{{ t(introKey) }}
|
||||
</p>
|
||||
@@ -100,7 +103,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<template v-else>
|
||||
<div
|
||||
data-test="workflow-trigger-structure"
|
||||
class="rounded-default border-border-default h-110 w-full overflow-hidden border"
|
||||
class="rounded-default border-border-default min-h-0 w-full flex-1 overflow-hidden border"
|
||||
>
|
||||
<QueryEditor
|
||||
editor-id="workflow-trigger-payload"
|
||||
@@ -124,6 +127,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import { computed, defineAsyncComponent, ref } from "vue";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import OSelect from "@/lib/forms/Select/OSelect.vue";
|
||||
import WorkflowConfigHeader from "./WorkflowConfigHeader.vue";
|
||||
import { workflowObj } from "@/plugins/workflows/useWorkflowCanvas";
|
||||
import {
|
||||
triggerDef,
|
||||
|
||||
@@ -24,7 +24,7 @@ import i18nInstance from "@/locales";
|
||||
const t = (i18nInstance.global as any).t;
|
||||
|
||||
vi.mock("@/services/workflows", () => ({
|
||||
default: { getWorkflowRun: vi.fn(), testWorkflow: vi.fn() },
|
||||
default: { getWorkflowRun: vi.fn(), testWorkflow: vi.fn(), getWorkflowHistory: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/zincutils", () => ({
|
||||
@@ -38,7 +38,7 @@ vi.mock("@vue-flow/core", () => ({
|
||||
onNodesInitialized: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
}),
|
||||
// makeEdge (via commitNode) reads MarkerType.ArrowClosed for the edge marker.
|
||||
// makeEdge (via commitStagedNode) reads MarkerType.ArrowClosed for the edge marker.
|
||||
MarkerType: { ArrowClosed: "arrowclosed" },
|
||||
}));
|
||||
|
||||
@@ -52,10 +52,11 @@ import useWorkflowCanvas, {
|
||||
workflowObj,
|
||||
hydrateWorkflow,
|
||||
loadWorkflowRun,
|
||||
loadRunsHistory,
|
||||
executeTestRun,
|
||||
serializeWorkflow,
|
||||
nodeTestInput,
|
||||
nodeTestOutputBranches,
|
||||
nodeTestOutput,
|
||||
currentTriggerKind,
|
||||
buildStepTree,
|
||||
toggleNodeDisabled,
|
||||
@@ -76,6 +77,41 @@ const triggerNode = () => ({
|
||||
|
||||
const mockRun = workflowService.getWorkflowRun as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockTest = workflowService.testWorkflow as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockHistory = workflowService.getWorkflowHistory as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("loadRunsHistory — shared runs list for the Runs page + NDV switcher", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
workflowObj.runsHistory = {
|
||||
list: [],
|
||||
fetchedAt: 0,
|
||||
params: { start: 0, end: 0 },
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
|
||||
it("stores the fetched list, the window, and a fetchedAt stamp", async () => {
|
||||
const runs = [{ run_id: "r1", start_time: 10 }];
|
||||
mockHistory.mockResolvedValue({ data: runs });
|
||||
const r = await loadRunsHistory({ orgId: "o", workflowId: "wf1", start: 100, end: 200 });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(workflowObj.runsHistory.list).toEqual(runs);
|
||||
expect(workflowObj.runsHistory.params).toEqual({ start: 100, end: 200 });
|
||||
expect(workflowObj.runsHistory.fetchedAt).toBeGreaterThan(0);
|
||||
expect(workflowObj.runsHistory.loading).toBe(false);
|
||||
// called with the passed window
|
||||
expect(mockHistory.mock.calls[0][0]).toMatchObject({ start_time: 100, end_time: 200 });
|
||||
});
|
||||
|
||||
it("keeps the previous list on failure (a failed refresh must not blank it)", async () => {
|
||||
workflowObj.runsHistory.list = [{ run_id: "old" }] as any;
|
||||
mockHistory.mockRejectedValue({ response: { status: 500 } });
|
||||
const r = await loadRunsHistory({ orgId: "o", workflowId: "wf1", start: 1, end: 2 });
|
||||
expect(r).toEqual({ ok: false, status: 500 });
|
||||
expect(workflowObj.runsHistory.list).toEqual([{ run_id: "old" }]); // untouched
|
||||
expect(workflowObj.runsHistory.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadWorkflowRun — history run response mapping", () => {
|
||||
beforeEach(() => {
|
||||
@@ -288,14 +324,9 @@ describe("executeTestRun — ran-node scope + badge state", () => {
|
||||
// the disconnected node never ran → no ✓ badge
|
||||
expect(res.ranNodeIds).not.toContain("x");
|
||||
expect(res.blockedNodeIds).toEqual([]);
|
||||
// ranSteps snapshots the executed-steps tree (structure + frozen data) so the
|
||||
// dock can persist across later edits.
|
||||
expect(res.ranSteps.map((s: any) => s.id)).toEqual(["t", "f", "d"]);
|
||||
expect(res.ranSteps.find((s: any) => s.id === "t")).toMatchObject({
|
||||
depth: 0,
|
||||
childCount: 1,
|
||||
});
|
||||
expect(res.ranSteps.find((s: any) => s.id === "f").data.node_type).toBe("function");
|
||||
// The frozen `ranSteps` tree went with the results dock — the NDV reads the
|
||||
// live graph plus this result, so there is no snapshot to keep in sync.
|
||||
expect(res.ranSteps).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends the whole in-memory graph (test-without-saving), not just an id", async () => {
|
||||
@@ -319,6 +350,100 @@ describe("executeTestRun — ran-node scope + badge state", () => {
|
||||
expect(res.ranNodeIds).not.toContain("t");
|
||||
});
|
||||
|
||||
it("Run Step (singleNode) sends ONLY that node as the workflow and marks only it", async () => {
|
||||
mockTest.mockResolvedValue({ data: { errors: {} } });
|
||||
await executeTestRun({ orgId: "o", inputs: [{ a: 1 }], fromNode: "f", singleNode: true });
|
||||
const arg = mockTest.mock.calls[0][0];
|
||||
// Just the one node, no edges — from_node is that node so it runs in isolation.
|
||||
expect(arg.from_node).toBe("f");
|
||||
expect(arg.workflow.nodes.map((n: any) => n.id)).toEqual(["f"]);
|
||||
expect(arg.workflow.edges).toEqual([]);
|
||||
// Only the run node counts as ran — nothing upstream/downstream executed.
|
||||
const res: any = workflowObj.testRun.result;
|
||||
expect(res.ranNodeIds).toEqual(["f"]);
|
||||
});
|
||||
|
||||
it("Run Step accumulates: a second node's run keeps the first node's result", async () => {
|
||||
// Run Step on "f", then on "d" — d's run must NOT wipe f's input/output.
|
||||
mockTest.mockResolvedValue({ data: { inputs: { f: [{ a: 1 }] }, outputs: { f: [{ o: 1 }] } } });
|
||||
await executeTestRun({ orgId: "o", inputs: [{ a: 1 }], fromNode: "f", singleNode: true });
|
||||
mockTest.mockResolvedValue({ data: { inputs: { d: [{ b: 2 }] }, outputs: { d: [{ o: 2 }] } } });
|
||||
await executeTestRun({ orgId: "o", inputs: [{ b: 2 }], fromNode: "d", singleNode: true });
|
||||
|
||||
const res: any = workflowObj.testRun.result;
|
||||
// both nodes' data is retained, and both are marked ran
|
||||
expect(res.inputs).toEqual({ f: [{ a: 1 }], d: [{ b: 2 }] });
|
||||
expect(res.outputs).toEqual({ f: [{ o: 1 }], d: [{ o: 2 }] });
|
||||
expect(res.ranNodeIds.sort()).toEqual(["d", "f"]);
|
||||
});
|
||||
|
||||
it("Run Step refreshes only its own node (re-running clears that node's stale error)", async () => {
|
||||
mockTest.mockResolvedValue({
|
||||
data: { errors: { f: { error_count: 1, errors: [["boom"]] } }, outputs: {} },
|
||||
});
|
||||
await executeTestRun({ orgId: "o", inputs: [{ a: 1 }], fromNode: "f", singleNode: true });
|
||||
expect((workflowObj.testRun.result as any).errors.f).toBeTruthy();
|
||||
// re-run f successfully → its error clears, and it stays the only ran node
|
||||
mockTest.mockResolvedValue({ data: { inputs: { f: [{ a: 1 }] }, outputs: { f: [{ o: 9 }] } } });
|
||||
await executeTestRun({ orgId: "o", inputs: [{ a: 1 }], fromNode: "f", singleNode: true });
|
||||
const res: any = workflowObj.testRun.result;
|
||||
expect(res.errors.f).toBeUndefined();
|
||||
expect(res.outputs.f).toEqual([{ o: 9 }]);
|
||||
});
|
||||
|
||||
it("Run Step into a loaded run replaces only that node's output, keeping the rest", async () => {
|
||||
// A history run is loaded (mode set) with data for every node.
|
||||
workflowObj.testRun.result = {
|
||||
mode: "history",
|
||||
runId: "r9",
|
||||
errors: {},
|
||||
inputs: { t: [{ x: 0 }], f: [{ a: 1 }], d: [{ b: 2 }] },
|
||||
outputs: { t: [{ x: 0 }], f: [{ old: 1 }], d: [{ dd: 2 }] },
|
||||
ranNodeIds: ["t", "f", "d"],
|
||||
blockedNodeIds: [],
|
||||
} as any;
|
||||
mockTest.mockResolvedValue({
|
||||
data: { inputs: { f: [{ a: 1 }] }, outputs: { f: [{ fresh: 9 }] } },
|
||||
});
|
||||
await executeTestRun({ orgId: "o", inputs: [{ a: 1 }], fromNode: "f", singleNode: true });
|
||||
const res: any = workflowObj.testRun.result;
|
||||
expect(res.outputs.f).toEqual([{ fresh: 9 }]); // this node's output REPLACED
|
||||
expect(res.outputs.d).toEqual([{ dd: 2 }]); // other nodes untouched
|
||||
expect(res.outputs.t).toEqual([{ x: 0 }]);
|
||||
expect(res.mode).toBe("history"); // surrounding context preserved
|
||||
expect(res.runId).toBe("r9");
|
||||
expect(res.ranNodeIds.sort()).toEqual(["d", "f", "t"]);
|
||||
});
|
||||
|
||||
it("Run Step with an EMPTY output clears this node's stale output (not falls through)", async () => {
|
||||
workflowObj.testRun.result = {
|
||||
errors: {},
|
||||
inputs: { f: [{ a: 1 }] },
|
||||
outputs: { f: [{ stale: 1 }] }, // a previous output for f
|
||||
ranNodeIds: ["f"],
|
||||
blockedNodeIds: [],
|
||||
} as any;
|
||||
// the run emits nothing for f (e.g. it errored) — response has no `outputs.f`
|
||||
mockTest.mockResolvedValue({
|
||||
data: { errors: { f: { error_count: 1, errors: [["boom"]] } }, inputs: {}, outputs: {} },
|
||||
});
|
||||
await executeTestRun({ orgId: "o", inputs: [{ a: 1 }], fromNode: "f", singleNode: true });
|
||||
const res: any = workflowObj.testRun.result;
|
||||
expect(res.outputs.f).toBeUndefined(); // stale output CLEARED, not left behind
|
||||
expect(res.errors.f).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a FULL run replaces accumulated single-node results", async () => {
|
||||
mockTest.mockResolvedValue({ data: { inputs: { f: [{ a: 1 }] }, outputs: { f: [{ o: 1 }] } } });
|
||||
await executeTestRun({ orgId: "o", inputs: [{ a: 1 }], fromNode: "f", singleNode: true });
|
||||
// full run (no singleNode) → fresh result, stale single-node data gone
|
||||
mockTest.mockResolvedValue({ data: { inputs: { t: [{ x: 0 }] }, outputs: {} } });
|
||||
await executeTestRun({ orgId: "o", inputs: [{ x: 0 }] });
|
||||
const res: any = workflowObj.testRun.result;
|
||||
expect(res.inputs).toEqual({ t: [{ x: 0 }] }); // f's accumulated input is gone
|
||||
expect(res.ranNodeIds.sort()).toEqual(["d", "f", "t"]); // reachable-from-trigger
|
||||
});
|
||||
|
||||
it("marks nodes downstream of an errored node as blocked (not passed)", async () => {
|
||||
mockTest.mockResolvedValue({
|
||||
data: { errors: { f: { error_count: 1, errors: [["boom"]] } } },
|
||||
@@ -484,10 +609,10 @@ describe("tidyWorkflowLayout — orphan subtree ordering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// nodeTestInput / nodeTestOutputBranches derive per-node Input and Output from the
|
||||
// backend `inputs` map. Output on an edge == the child's input (single-incoming
|
||||
// tree), so these two helpers power the whole step-drawer.
|
||||
describe("nodeTestInput + nodeTestOutputBranches — per-node I/O derivation", () => {
|
||||
// nodeTestInput / nodeTestOutput read per-node Input and Output straight from the
|
||||
// backend `inputs` / `outputs` maps (the backend reports each node's output
|
||||
// directly). These two helpers power the whole step-drawer.
|
||||
describe("nodeTestInput + nodeTestOutput — per-node I/O maps", () => {
|
||||
beforeEach(() => {
|
||||
// trigger(t) -> function(f) -> destination(d); t also -> condition(c) (fan-out)
|
||||
workflowObj.currentSelectedWorkflow = {
|
||||
@@ -508,6 +633,7 @@ describe("nodeTestInput + nodeTestOutputBranches — per-node I/O derivation", (
|
||||
workflowObj.testRun.result = {
|
||||
errors: {},
|
||||
inputs: { t: [{ x: 0 }], f: [{ x: 1 }], d: [{ x: 2 }] }, // c got nothing
|
||||
outputs: { t: [{ x: 0 }], f: [{ x: 2 }] }, // f emitted { x: 2 }; c/d emitted nothing
|
||||
ranNodeIds: ["t", "f", "c", "d"],
|
||||
blockedNodeIds: [],
|
||||
} as any;
|
||||
@@ -524,24 +650,15 @@ describe("nodeTestInput + nodeTestOutputBranches — per-node I/O derivation", (
|
||||
expect(nodeTestInput("f")).toBeNull();
|
||||
});
|
||||
|
||||
it("nodeTestOutputBranches: a node's output == each child's input", () => {
|
||||
const branches = nodeTestOutputBranches("f");
|
||||
expect(branches).toHaveLength(1);
|
||||
expect(branches[0]).toMatchObject({ targetId: "d", nodeType: "destination" });
|
||||
expect(branches[0].records).toEqual([{ x: 2 }]); // == inputs[d]
|
||||
it("nodeTestOutput returns the records a node emitted, null when absent", () => {
|
||||
expect(nodeTestOutput("f")).toEqual([{ x: 2 }]);
|
||||
expect(nodeTestOutput("c")).toBeNull(); // emitted nothing — not in outputs
|
||||
expect(nodeTestOutput("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("fan-out yields one branch per outgoing edge; a filtered branch has null records", () => {
|
||||
const branches = nodeTestOutputBranches("t");
|
||||
expect(branches.map((b) => b.targetId).sort()).toEqual(["c", "f"]);
|
||||
const toC = branches.find((b) => b.targetId === "c")!;
|
||||
const toF = branches.find((b) => b.targetId === "f")!;
|
||||
expect(toC.records).toBeNull(); // c received nothing
|
||||
expect(toF.records).toEqual([{ x: 1 }]);
|
||||
});
|
||||
|
||||
it("a terminal (destination) has no outgoing edges → no branches", () => {
|
||||
expect(nodeTestOutputBranches("d")).toEqual([]);
|
||||
it("nodeTestOutput is null when there is no run", () => {
|
||||
workflowObj.testRun.result = null as any;
|
||||
expect(nodeTestOutput("f")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -692,6 +809,69 @@ describe("placeholder / incomplete node (Configure Later)", () => {
|
||||
expect(wf.nodes[0].meta.incomplete).toBe("true");
|
||||
expect(wf.nodes[0].data.destination_id).toBe("");
|
||||
});
|
||||
|
||||
// A never-configured (dummy) node carries only { label, node_type }. serializeNode
|
||||
// must fill a valid NodeData for it or the backend fails deserialization.
|
||||
it("dummy DESTINATION node serializes with an empty destination name", () => {
|
||||
workflowObj.currentSelectedWorkflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: "d1",
|
||||
type: "output",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label: "d1", node_type: "destination" }, // no destination_id
|
||||
meta: { incomplete: "true" },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
} as any;
|
||||
const { data } = serializeWorkflow().nodes[0];
|
||||
expect(data.destination_id).toBe("");
|
||||
expect(data.template_override).toBeNull();
|
||||
});
|
||||
|
||||
it("dummy CONDITION node serializes as a pass-through v2 rule (empty column, empty value)", () => {
|
||||
workflowObj.currentSelectedWorkflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "default",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label: "c1", node_type: "condition" }, // no conditions
|
||||
meta: { incomplete: "true" },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
} as any;
|
||||
const { data } = serializeWorkflow().nodes[0];
|
||||
expect(data.version).toBe(2);
|
||||
expect(data.conditions.filterType).toBe("group");
|
||||
expect(data.conditions.conditions).toHaveLength(1);
|
||||
// Empty column → backend short-circuits to always-true; value is irrelevant, so
|
||||
// it's empty (not a misleading "true" literal).
|
||||
expect(data.conditions.conditions[0]).toMatchObject({
|
||||
column: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("a CONFIGURED condition node keeps its own rule (no pass-through override)", () => {
|
||||
const conditions = { filterType: "group", logicalOperator: "AND", conditions: [] };
|
||||
workflowObj.currentSelectedWorkflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "default",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { node_type: "condition", version: 2, conditions },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
} as any;
|
||||
const { data } = serializeWorkflow().nodes[0];
|
||||
expect(data.conditions).toEqual(conditions); // untouched
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrateWorkflow — draft flag normalization", () => {
|
||||
@@ -743,21 +923,31 @@ describe("trigger-first guard — palette adds are blocked until a trigger exist
|
||||
expect(workflowObj.currentSelectedNodeData).toBeNull();
|
||||
});
|
||||
|
||||
it("addNodeToEnd stages a node once a trigger is present", () => {
|
||||
it("addNodeToEnd inserts a node on the canvas once a trigger is present", () => {
|
||||
workflowObj.currentSelectedWorkflow.nodes = [triggerNode()];
|
||||
addNodeToEnd("condition");
|
||||
expect(mockToast).not.toHaveBeenCalled();
|
||||
expect(workflowObj.dialog.show).toBe(true);
|
||||
expect(workflowObj.currentSelectedNodeData?.data.node_type).toBe("condition");
|
||||
// Insert-immediately, panel left SHUT — so the staged selection is cleared and
|
||||
// the node must be inspected on the canvas, where it sits flagged incomplete.
|
||||
expect(workflowObj.dialog.show).toBe(false);
|
||||
const added = workflowObj.currentSelectedWorkflow.nodes.find(
|
||||
(n: any) => n.data.node_type === "condition",
|
||||
);
|
||||
expect(workflowObj.currentSelectedWorkflow.nodes).toHaveLength(2);
|
||||
expect(added?.meta?.incomplete).toBe("true");
|
||||
});
|
||||
|
||||
it("onDrop stages a node once a trigger is present", () => {
|
||||
it("onDrop inserts a node on the canvas once a trigger is present", () => {
|
||||
workflowObj.currentSelectedWorkflow.nodes = [triggerNode()];
|
||||
workflowObj.draggedNodeType = "function";
|
||||
onDrop({ clientX: 10, clientY: 10 } as any);
|
||||
expect(mockToast).not.toHaveBeenCalled();
|
||||
expect(workflowObj.dialog.show).toBe(true);
|
||||
expect(workflowObj.currentSelectedNodeData?.data.node_type).toBe("function");
|
||||
expect(workflowObj.dialog.show).toBe(false);
|
||||
const dropped = workflowObj.currentSelectedWorkflow.nodes.find(
|
||||
(n: any) => n.data.node_type === "function",
|
||||
);
|
||||
expect(workflowObj.currentSelectedWorkflow.nodes).toHaveLength(2);
|
||||
expect(dropped?.meta?.incomplete).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -811,7 +1001,7 @@ describe("addNodeOnEdge — insert-between spacing (T7)", () => {
|
||||
};
|
||||
|
||||
it("places the spliced node a row below the source and nudges the target down", () => {
|
||||
const { addNodeOnEdge, commitNode } = useWorkflowCanvas(t);
|
||||
const { addNodeOnEdge } = useWorkflowCanvas(t);
|
||||
resetStaged();
|
||||
workflowObj.currentSelectedWorkflow = {
|
||||
nodes: [
|
||||
@@ -831,27 +1021,26 @@ describe("addNodeOnEdge — insert-between spacing (T7)", () => {
|
||||
edges: [{ id: "e1", source: "t", target: "d" }],
|
||||
} as any;
|
||||
|
||||
// Insert-immediately: addNodeOnEdge splices the node onto the canvas now (no
|
||||
// separate commit step) and leaves the panel shut.
|
||||
addNodeOnEdge("e1", "condition");
|
||||
const staged = workflowObj.currentSelectedNodeData;
|
||||
// staged a row below the source, aligned with the target column
|
||||
expect(staged.position.y).toBe(160);
|
||||
expect(staged.position.x).toBe(100);
|
||||
|
||||
commitNode({});
|
||||
const wf = workflowObj.currentSelectedWorkflow;
|
||||
const inserted = wf.nodes.find((n: any) => n.id === staged.id);
|
||||
const inserted = wf.nodes.find((n: any) => n.data.node_type === "condition");
|
||||
// placed a row below the source, aligned with the target column
|
||||
expect(inserted.position.y).toBe(160);
|
||||
expect(inserted.position.x).toBe(100);
|
||||
const target = wf.nodes.find((n: any) => n.id === "d");
|
||||
// new node stays a row below the source; target pushed down a full row below it
|
||||
expect(inserted.position.y).toBe(160);
|
||||
expect(target.position.y).toBe(320);
|
||||
// rewired A→new→B, old edge dropped
|
||||
expect(wf.edges.some((e: any) => e.source === "t" && e.target === staged.id)).toBe(true);
|
||||
expect(wf.edges.some((e: any) => e.source === staged.id && e.target === "d")).toBe(true);
|
||||
expect(wf.edges.some((e: any) => e.source === "t" && e.target === inserted.id)).toBe(true);
|
||||
expect(wf.edges.some((e: any) => e.source === inserted.id && e.target === "d")).toBe(true);
|
||||
expect(wf.edges.some((e: any) => e.id === "e1")).toBe(false);
|
||||
});
|
||||
|
||||
it("also shifts nodes downstream of the target", () => {
|
||||
const { addNodeOnEdge, commitNode } = useWorkflowCanvas(t);
|
||||
const { addNodeOnEdge } = useWorkflowCanvas(t);
|
||||
resetStaged();
|
||||
workflowObj.currentSelectedWorkflow = {
|
||||
nodes: [
|
||||
@@ -882,16 +1071,15 @@ describe("addNodeOnEdge — insert-between spacing (T7)", () => {
|
||||
|
||||
// insert on the trigger→condition edge; condition AND its downstream destination shift
|
||||
addNodeOnEdge("e1", "function");
|
||||
const staged = workflowObj.currentSelectedNodeData;
|
||||
commitNode({});
|
||||
const wf = workflowObj.currentSelectedWorkflow;
|
||||
expect(wf.nodes.find((n: any) => n.id === staged.id).position.y).toBe(160);
|
||||
const inserted = wf.nodes.find((n: any) => n.data.node_type === "function");
|
||||
expect(inserted.position.y).toBe(160);
|
||||
expect(wf.nodes.find((n: any) => n.id === "c").position.y).toBe(320);
|
||||
expect(wf.nodes.find((n: any) => n.id === "d").position.y).toBe(480);
|
||||
});
|
||||
|
||||
it("does not shift a target that already has enough room", () => {
|
||||
const { addNodeOnEdge, commitNode } = useWorkflowCanvas(t);
|
||||
const { addNodeOnEdge } = useWorkflowCanvas(t);
|
||||
resetStaged();
|
||||
workflowObj.currentSelectedWorkflow = {
|
||||
nodes: [
|
||||
@@ -907,7 +1095,6 @@ describe("addNodeOnEdge — insert-between spacing (T7)", () => {
|
||||
} as any;
|
||||
|
||||
addNodeOnEdge("e1", "condition");
|
||||
commitNode({});
|
||||
const wf = workflowObj.currentSelectedWorkflow;
|
||||
// target already 400 below (> new node at 160 + 160 gap), so it stays put
|
||||
expect(wf.nodes.find((n: any) => n.id === "d").position.y).toBe(400);
|
||||
|
||||
@@ -30,6 +30,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
// the editor, canvas, nodes and node-forms all share one object.
|
||||
|
||||
import { reactive } from "vue";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { useVueFlow } from "@vue-flow/core";
|
||||
import { getUUID, getImageURL } from "@/utils/zincutils";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
@@ -203,7 +204,7 @@ const defaultObject = {
|
||||
show: false,
|
||||
source: "",
|
||||
handle: "out",
|
||||
mode: "next" as "next" | "trigger" | "insert",
|
||||
mode: "next" as "next" | "trigger" | "action" | "insert",
|
||||
// "insert" mode splices onto this edge (A→B becomes A→new→B on commit).
|
||||
edgeId: "",
|
||||
position: null as { x: number; y: number } | null,
|
||||
@@ -232,13 +233,25 @@ const defaultObject = {
|
||||
// sentinel never lands here or on the API payload.
|
||||
fromNode: "",
|
||||
result: <any>null,
|
||||
// Per-node Input/Output result drawer (opened by clicking a node's badge).
|
||||
resultDrawer: { show: false, nodeId: "" },
|
||||
},
|
||||
currentSelectedWorkflow: <any>JSON.parse(JSON.stringify(defaultWorkflow)),
|
||||
workflowWithoutChange: <any>JSON.parse(JSON.stringify(defaultWorkflow)),
|
||||
nameError: false,
|
||||
nameErrorMessage: "",
|
||||
// Node ids to FLASH a "needs setup" warning ring on — set by Publish validation
|
||||
// when incomplete (dummy) nodes block publishing, so the user sees exactly which
|
||||
// steps. Transient: the canvas frames them and clears this after a few seconds.
|
||||
incompleteHighlight: <string[]>[],
|
||||
// Runs list, fetched ONCE by the Runs page (WorkflowRunsPanel) and SHARED so the
|
||||
// NDV's run switcher reuses it instead of re-hitting /history on every open. The
|
||||
// user reaches the NDV from that list, so it's already loaded; a manual Refresh
|
||||
// (with `fetchedAt` shown) re-pulls the same window when they want the latest.
|
||||
runsHistory: {
|
||||
list: <any[]>[],
|
||||
fetchedAt: 0, // ms epoch of the last successful fetch; 0 = never
|
||||
params: { start: 0, end: 0 }, // the time window the list was fetched for (µs)
|
||||
loading: false,
|
||||
},
|
||||
};
|
||||
|
||||
const workflowObj = reactive(Object.assign({}, defaultObject));
|
||||
@@ -323,9 +336,19 @@ export const isNodeDisabled = (node: any): boolean =>
|
||||
|
||||
// Write meta on a node reference (staged or already on the canvas). Empty values
|
||||
// drop the key so an unnamed/uncommented node serializes clean.
|
||||
//
|
||||
// IDEMPOTENT: if the resolved value is unchanged, do nothing — no reassignment,
|
||||
// no dirty flag. This matters now that the node panel commits its config on CLOSE
|
||||
// (no Save button): opening a node and closing it re-runs the body's submit(),
|
||||
// which re-asserts the same `incomplete` value; without this guard that alone
|
||||
// would mark an untouched workflow dirty and trip the unsaved-changes guard.
|
||||
const setNodeMeta = (node: any, key: string, value: string) => {
|
||||
if (!node) return;
|
||||
const meta = { ...(node.meta || {}) };
|
||||
const current = node.meta || {};
|
||||
const nextVal = value || undefined;
|
||||
const curVal = current[key] || undefined;
|
||||
if (curVal === nextVal) return;
|
||||
const meta = { ...current };
|
||||
if (value) meta[key] = value;
|
||||
else delete meta[key];
|
||||
node.meta = meta;
|
||||
@@ -486,9 +509,9 @@ export const flowOrderedNodeIds = (nodes: any[], edges: any[], startId?: string)
|
||||
return order;
|
||||
};
|
||||
|
||||
// A step in the executed-steps TREE (results dock). Carries the render structure —
|
||||
// depth, direct-child count, and per-column "does this rail continue" flags — so
|
||||
// the panel draws the traces-waterfall connector lines without re-deriving them.
|
||||
// A step in the workflow TREE (the NDV's Steps rail). Carries the render structure —
|
||||
// depth, direct-child count, and per-column "does this rail continue" flags — so the
|
||||
// rail draws the traces-waterfall connector lines without re-deriving them.
|
||||
export interface StepTreeNode {
|
||||
id: string;
|
||||
depth: number;
|
||||
@@ -569,6 +592,31 @@ const downstreamOfErrorNodes = (errorIds: string[]): string[] => {
|
||||
return [...set];
|
||||
};
|
||||
|
||||
// A v2 condition group the backend reads as "pass everything through": one rule
|
||||
// with an EMPTY column. The backend short-circuits an empty column to always-true
|
||||
// (Condition::evaluate: `if self.column.is_empty() { return true }`) BEFORE it ever
|
||||
// reads `operator`/`value`, so both are irrelevant — we send an empty value (not a
|
||||
// misleading literal like "true") so it matches an unconfigured rule and round-trips
|
||||
// cleanly if the draft is reopened. Used to give a never-configured (dummy) Condition
|
||||
// node a valid, non-filtering `NodeData` at send time; mirrors ConditionBuilder's
|
||||
// emptyGroup() shape.
|
||||
const passthroughConditionGroup = () => ({
|
||||
filterType: "group",
|
||||
logicalOperator: "AND",
|
||||
groupId: getUUID(),
|
||||
conditions: [
|
||||
{
|
||||
filterType: "condition",
|
||||
column: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
values: [],
|
||||
logicalOperator: "AND",
|
||||
id: getUUID(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Serialize one in-memory VueFlow node down to the fields the backend `Node`
|
||||
// struct persists: id, io_type, position, data, and (when present) meta / style.
|
||||
// Everything else on the node is VueFlow runtime state (`type`, `dimensions`,
|
||||
@@ -588,6 +636,21 @@ const serializeNode = (node: any) => {
|
||||
meta.trigger_kind = data.trigger_kind || DEFAULT_TRIGGER_KIND;
|
||||
}
|
||||
|
||||
// Dummy / never-configured nodes carry only { label, node_type }, but each
|
||||
// backend `NodeData` variant needs its own fields or deserialization fails.
|
||||
// Fill a valid placeholder at SEND time only (the editor node stays flagged
|
||||
// `incomplete`, so the "Set up later" badge and Publish block are unaffected):
|
||||
// - condition → a pass-through v2 rule (see above); records flow untouched.
|
||||
// - destination → an empty destination name, the same shape a configured
|
||||
// node sends ({ destination_id, template_override }).
|
||||
if (nodeType === "condition" && data.conditions == null) {
|
||||
data.version = 2;
|
||||
data.conditions = passthroughConditionGroup();
|
||||
} else if (nodeType === "destination" && typeof data.destination_id !== "string") {
|
||||
data.destination_id = "";
|
||||
if (data.template_override === undefined) data.template_override = null;
|
||||
}
|
||||
|
||||
const out: any = {
|
||||
id: node.id,
|
||||
io_type: node.type || "default",
|
||||
@@ -610,8 +673,14 @@ const serializeNode = (node: any) => {
|
||||
// graph so it can run WITHOUT saving). The `Workflow` struct has no serde
|
||||
// defaults, so every field must be present; org_id/id/created_by are
|
||||
// overridden/generated by the backend (the test endpoint assigns a throwaway id).
|
||||
export const serializeWorkflow = () => {
|
||||
//
|
||||
// `onlyNodeId` builds a SINGLE-NODE workflow (just that node, no edges) — used by
|
||||
// "Run Step" to execute one node in isolation (from_node = that node's id), so
|
||||
// nothing downstream runs and its output comes back on its own.
|
||||
export const serializeWorkflow = (opts?: { onlyNodeId?: string }) => {
|
||||
const wf = workflowObj.currentSelectedWorkflow;
|
||||
const allNodes = wf.nodes || [];
|
||||
const nodes = opts?.onlyNodeId ? allNodes.filter((n: any) => n.id === opts.onlyNodeId) : allNodes;
|
||||
return {
|
||||
id: wf.id || "",
|
||||
org_id: "",
|
||||
@@ -621,88 +690,112 @@ export const serializeWorkflow = () => {
|
||||
created_at: wf.created_at || 0,
|
||||
updated_at: wf.updated_at || 0,
|
||||
created_by: "",
|
||||
nodes: (wf.nodes || []).map(serializeNode),
|
||||
edges: wf.edges || [],
|
||||
nodes: nodes.map(serializeNode),
|
||||
edges: opts?.onlyNodeId ? [] : wf.edges || [],
|
||||
};
|
||||
};
|
||||
|
||||
// Run the workflow Test (from the Test dialog or a node's Replay button) and
|
||||
// Run the workflow Test (from the Test dialog or a node's Run Step button) and
|
||||
// store the result so each WorkflowNode paints its ✓ / ✗ / ⊘ badge. Shared so
|
||||
// both entry points behave identically. The whole in-memory graph is sent, so a
|
||||
// workflow can be tested whether or not it's been saved. The backend returns
|
||||
// errors only — the step drawer (error nodes only) derives its input/output from
|
||||
// `errors`, so there's no per-node node_io to carry.
|
||||
// workflow can be tested whether or not it's been saved. The backend returns a
|
||||
// per-node `inputs` map, a per-node `outputs` map, and `errors` — the step drawer
|
||||
// reads Input/Output straight from those maps.
|
||||
//
|
||||
// `singleNode` runs ONE node in isolation ("Run Step"): only that node is sent as
|
||||
// the workflow, with from_node = its id, so nothing upstream/downstream executes —
|
||||
// just this step, against the given input, and its output comes back on its own.
|
||||
export const executeTestRun = async (opts: {
|
||||
orgId: string;
|
||||
inputs: any[];
|
||||
fromNode?: string;
|
||||
singleNode?: boolean;
|
||||
}): Promise<{ ok: boolean; error?: string }> => {
|
||||
const wf = workflowObj.currentSelectedWorkflow;
|
||||
try {
|
||||
const single = opts.singleNode && opts.fromNode ? opts.fromNode : "";
|
||||
// Dry-run a DRAFT when the graph is a saved draft, has unsaved edits, or was
|
||||
// never persisted — the backend then skips the strict published validation.
|
||||
const draft = !!(wf.isDraft || workflowObj.dirtyFlag || !wf.id);
|
||||
// never persisted — the backend then skips the strict published validation. A
|
||||
// single-node run is inherently a partial graph (one node, no trigger/edges),
|
||||
// so it's ALWAYS a draft, even when the parent workflow is published.
|
||||
const draft = !!(single || wf.isDraft || workflowObj.dirtyFlag || !wf.id);
|
||||
const res = await workflowService.testWorkflow({
|
||||
org_identifier: opts.orgId,
|
||||
workflow: serializeWorkflow(),
|
||||
workflow: serializeWorkflow(single ? { onlyNodeId: single } : undefined),
|
||||
inputs: opts.inputs,
|
||||
from_node: opts.fromNode || undefined,
|
||||
draft,
|
||||
});
|
||||
const errors = res.data?.errors || {};
|
||||
// Per-node INPUT map: node_id -> the records that node received. A node's
|
||||
// OUTPUT is derived from this (see nodeTestOutputBranches): since the graph is
|
||||
// a single-incoming tree, a child's input IS its parent's output on that edge.
|
||||
// Per-node INPUT map: node_id -> the records that node received.
|
||||
const inputs = res.data?.inputs || {};
|
||||
// Which nodes ran: from a replay, `fromNode` + everything downstream;
|
||||
// otherwise everything reachable from the trigger. Nodes NOT reachable
|
||||
// (unwired / disconnected) never executed, so they must not paint a ✓.
|
||||
// Per-node OUTPUT map: node_id -> the records that node emitted. The backend
|
||||
// now sends this directly (alongside `inputs`), so a node's Output is read
|
||||
// straight from here (see nodeTestOutput) rather than derived from downstream
|
||||
// inputs. Errors are still delivered the old way, via `errors`.
|
||||
const outputs = res.data?.outputs || {};
|
||||
|
||||
if (single) {
|
||||
// Single-node Run Step: REPLACE just this node's input/output/error in the
|
||||
// CURRENT result, leaving every other node untouched — its fresh output
|
||||
// overwrites whatever was shown for it (a loaded history run, a prior Test, or
|
||||
// an earlier Run Step) while the rest of the view stays put. The spread keeps
|
||||
// the surrounding context (mode/runId/ghostNodeIds, other nodes' data); only
|
||||
// this node's slot is refreshed (its stale error cleared, then re-applied if
|
||||
// it failed again) and it's marked as ran.
|
||||
const base: any = workflowObj.testRun.result || {};
|
||||
const mergedErrors = { ...(base.errors || {}) };
|
||||
const mergedInputs = { ...(base.inputs || {}) };
|
||||
const mergedOutputs = { ...(base.outputs || {}) };
|
||||
// This node's slots are FULLY REPLACED by the fresh run: drop the old value
|
||||
// first, then apply the response. Critical when the step emitted nothing — an
|
||||
// empty/absent result must CLEAR the old output, not fall through to it
|
||||
// (a plain `{ ...base, ...resp }` spread leaves the stale value when `resp`
|
||||
// has no key for this node).
|
||||
delete mergedErrors[single];
|
||||
delete mergedInputs[single];
|
||||
delete mergedOutputs[single];
|
||||
Object.assign(mergedErrors, errors);
|
||||
Object.assign(mergedInputs, inputs);
|
||||
Object.assign(mergedOutputs, outputs);
|
||||
const ranNodeIds = [...new Set([...(base.ranNodeIds || []), single])];
|
||||
workflowObj.testRun.result = {
|
||||
...base,
|
||||
errors: mergedErrors,
|
||||
inputs: mergedInputs,
|
||||
outputs: mergedOutputs,
|
||||
ranNodeIds,
|
||||
blockedNodeIds: downstreamOfErrorNodes(Object.keys(mergedErrors)),
|
||||
};
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Full Test run — replaces the result entirely (a fresh, coherent run of the
|
||||
// whole graph; stale single-node accumulations must not linger). Which nodes
|
||||
// ran: a replay is `fromNode` + everything downstream; otherwise everything
|
||||
// reachable from the trigger. Unreachable (unwired) nodes never ran → no ✓.
|
||||
const triggerId = (wf.nodes || []).find(
|
||||
(n: any) => n.data?.node_type === "workflow_trigger",
|
||||
)?.id;
|
||||
const startId = opts.fromNode || triggerId;
|
||||
const ranNodeIds = startId ? [...reachableFrom(wf.edges || [], [startId])] : [];
|
||||
// Snapshot the executed-steps TREE at run time. The results dock now persists
|
||||
// across graph edits, so a step later deleted or disabled must still
|
||||
// render — with its frozen label/icon, struck-through. We freeze the tree shape
|
||||
// (depth + connector guides) plus each step's display data/meta here; the panel
|
||||
// resolves live label/status on top (reflecting a later rename) and falls back
|
||||
// to this frozen data for a removed node. History runs don't set this — the
|
||||
// panel rebuilds the tree from the live graph there. See WorkflowResultsPanel.
|
||||
const stepById = new Map<string, any>((wf.nodes || []).map((n: any) => [n.id, n]));
|
||||
const ranSteps = buildStepTree(wf.nodes || [], wf.edges || [], ranNodeIds).map((s) => {
|
||||
const n = stepById.get(s.id);
|
||||
return {
|
||||
...s,
|
||||
data: n?.data ? { ...n.data } : undefined,
|
||||
meta: n?.meta ? { ...n.meta } : undefined,
|
||||
};
|
||||
});
|
||||
workflowObj.testRun.result = {
|
||||
errors,
|
||||
inputs,
|
||||
outputs,
|
||||
ranNodeIds,
|
||||
ranSteps,
|
||||
blockedNodeIds: downstreamOfErrorNodes(Object.keys(errors)),
|
||||
};
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
// A failed run must not leave the previous run's ✓/✗ badges on screen.
|
||||
workflowObj.testRun.result = null;
|
||||
// A failed FULL run must not leave the previous run's ✓/✗ badges on screen. A
|
||||
// single-node run that throws (network/validation) keeps the accumulated
|
||||
// results so other nodes' Run Step outputs aren't wiped by an unrelated failure.
|
||||
if (!(opts.singleNode && opts.fromNode)) workflowObj.testRun.result = null;
|
||||
return { ok: false, error: e?.response?.data?.message };
|
||||
}
|
||||
};
|
||||
|
||||
// A single downstream branch of a node's OUTPUT: the target it feeds and the
|
||||
// records that target received (== what this node emitted on that edge). `records`
|
||||
// is null when the target got nothing (filtered out / never reached).
|
||||
export interface NodeOutputBranch {
|
||||
targetId: string;
|
||||
nodeType: string;
|
||||
detail: string;
|
||||
records: any[] | null;
|
||||
}
|
||||
|
||||
// The INPUT a node received on the last Test run — the raw records from the
|
||||
// backend `inputs` map (shape varies by node type; rendered as-is). Null when the
|
||||
// node isn't in the map (0 records reached it) or there's no run.
|
||||
@@ -712,24 +805,45 @@ export const nodeTestInput = (nodeId: string): any[] | null => {
|
||||
return Array.isArray(v) ? v : null;
|
||||
};
|
||||
|
||||
// A node's OUTPUT, per outgoing edge. The graph is a single-incoming tree, so
|
||||
// each child's input came ONLY from this node — child input == this node's output
|
||||
// on that branch. One entry per outgoing edge (so fan-out reads per-target); an
|
||||
// empty array means a terminal node (a destination/sink) with no derivable output.
|
||||
export const nodeTestOutputBranches = (nodeId: string): NodeOutputBranch[] => {
|
||||
const wf = workflowObj.currentSelectedWorkflow;
|
||||
const byId = new Map<string, any>((wf.nodes || []).map((n: any) => [n.id, n]));
|
||||
return (wf.edges || [])
|
||||
.filter((e: any) => e.source === nodeId)
|
||||
.map((e: any) => {
|
||||
const target = byId.get(e.target);
|
||||
return {
|
||||
targetId: e.target,
|
||||
nodeType: target?.data?.node_type || "",
|
||||
detail: nodeConfigDetail(target?.data, 40),
|
||||
records: nodeTestInput(e.target),
|
||||
};
|
||||
// The OUTPUT a node emitted on the last run — read straight from the backend
|
||||
// `outputs` map (node_id -> records). Null when the node isn't in the map (never
|
||||
// ran / emitted nothing) or there's no run. Replaces deriving output from
|
||||
// downstream inputs: the backend now reports each node's output directly.
|
||||
export const nodeTestOutput = (nodeId: string): any[] | null => {
|
||||
const outputs = workflowObj.testRun.result?.outputs;
|
||||
const v = outputs?.[nodeId];
|
||||
return Array.isArray(v) ? v : null;
|
||||
};
|
||||
|
||||
// Fetch the runs list into SHARED state (workflowObj.runsHistory), so the Runs page
|
||||
// and the NDV's run switcher read one source. Stores the window it fetched for
|
||||
// (`params`) and a `fetchedAt` stamp, so a later Refresh can re-pull the same
|
||||
// window. On error the previous list is kept (a failed refresh shouldn't blank the
|
||||
// switcher); the caller gets {ok,status} to surface load-error / permission UI.
|
||||
export const loadRunsHistory = async (opts: {
|
||||
orgId: string;
|
||||
workflowId: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}): Promise<{ ok: boolean; status?: number }> => {
|
||||
const rh = workflowObj.runsHistory;
|
||||
rh.loading = true;
|
||||
try {
|
||||
const res = await workflowService.getWorkflowHistory({
|
||||
org_identifier: opts.orgId,
|
||||
id: opts.workflowId,
|
||||
start_time: opts.start,
|
||||
end_time: opts.end,
|
||||
});
|
||||
rh.list = Array.isArray(res.data) ? res.data : (res.data?.list ?? []);
|
||||
rh.params = { start: opts.start, end: opts.end };
|
||||
rh.fetchedAt = Date.now();
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return { ok: false, status: e?.response?.status };
|
||||
} finally {
|
||||
rh.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Load a PAST run (from the Executions history) into the same testRun.result the
|
||||
@@ -771,24 +885,29 @@ export const loadWorkflowRun = async (opts: {
|
||||
}
|
||||
|
||||
// Per-node INPUT for the whole run (all nodes) — the same shape/semantics as a
|
||||
// Test run's `inputs`, so the drawer derives Output the same way. Older runs
|
||||
// only carried error_node_map (errored node's input) — fall back to that.
|
||||
// Test run's `inputs`. Older runs only carried error_node_map (errored node's
|
||||
// input) — fall back to that.
|
||||
const inputs = payload.data?.input_map || payload.data?.error_node_map || {};
|
||||
// Per-node OUTPUT for the whole run — the stored counterpart of a Test run's
|
||||
// `outputs`, so the drawer reads a node's Output directly. Older runs predate
|
||||
// the output map → empty, and Output shows "run to view".
|
||||
const outputs = payload.data?.output_map || {};
|
||||
|
||||
// GHOST NODES — the run references a node the workflow no longer has (it was
|
||||
// edited/deleted after the run). Its badge has nowhere to render, so an error
|
||||
// would silently vanish and the run would look cleaner than it was. Surface
|
||||
// them so the Runs view can say the graph no longer matches this run.
|
||||
const currentNodeIds = new Set((wf.nodes || []).map((n: any) => n.id));
|
||||
const ghostNodeIds = [...new Set([...Object.keys(errors), ...Object.keys(inputs)])].filter(
|
||||
(id) => !currentNodeIds.has(id),
|
||||
);
|
||||
const ghostNodeIds = [
|
||||
...new Set([...Object.keys(errors), ...Object.keys(inputs), ...Object.keys(outputs)]),
|
||||
].filter((id) => !currentNodeIds.has(id));
|
||||
|
||||
workflowObj.testRun.result = {
|
||||
errors,
|
||||
// Same per-node inputs map as a Test run — drives the ✓/grey/✗ badges and the
|
||||
// drawer's Input + derived Output for every node.
|
||||
// Same per-node inputs/outputs maps as a Test run — drives the ✓/grey/✗
|
||||
// badges and the drawer's Input + Output for every node.
|
||||
inputs,
|
||||
outputs,
|
||||
ranNodeIds: (wf.nodes || []).map((n: any) => n.id),
|
||||
blockedNodeIds: downstreamOfErrorNodes(Object.keys(errors)),
|
||||
mode: "history",
|
||||
@@ -950,9 +1069,7 @@ export default function useWorkflowCanvas(t: TranslateFn) {
|
||||
pushWorkflowHistory();
|
||||
wf.nodes = wf.nodes.filter((n: any) => n.id !== nodeId);
|
||||
wf.edges = wf.edges.filter((e: any) => e.source !== nodeId && e.target !== nodeId);
|
||||
// The Test log PERSISTS: the deleted step stays listed in the dock,
|
||||
// struck-through, from the run-time snapshot (result.ranSteps). Its canvas badge
|
||||
// is gone with the node. Cleared only by a new run or the explicit Clear button.
|
||||
// If the deleted node's NDV is open, close it.
|
||||
if (workflowObj.currentSelectedNodeData?.id === nodeId) {
|
||||
workflowObj.currentSelectedNodeData = null;
|
||||
workflowObj.dialog.show = false;
|
||||
@@ -1005,6 +1122,23 @@ export default function useWorkflowCanvas(t: TranslateFn) {
|
||||
};
|
||||
}
|
||||
|
||||
// The empty-canvas start scaffold's second slot: the same picker restricted to
|
||||
// the addable (non-trigger) node types. Mirrors openTriggerPicker — the click
|
||||
// point becomes the placed node's position (see addActionFromStart). `mode`
|
||||
// "action" means "the workflow's FIRST step" (no parent yet), as distinct from
|
||||
// "next" (extend a specific node).
|
||||
function openActionPicker(event: MouseEvent) {
|
||||
workflowObj.stepPicker = {
|
||||
show: true,
|
||||
source: "",
|
||||
handle: "out",
|
||||
mode: "action",
|
||||
edgeId: "",
|
||||
position: screenToFlowCoordinate({ x: event.clientX, y: event.clientY }),
|
||||
anchor: { x: event.clientX, y: event.clientY },
|
||||
};
|
||||
}
|
||||
|
||||
function closeStepPicker() {
|
||||
workflowObj.stepPicker = {
|
||||
show: false,
|
||||
@@ -1115,30 +1249,23 @@ export default function useWorkflowCanvas(t: TranslateFn) {
|
||||
type: meta.ioType,
|
||||
position,
|
||||
data: { label: id, node_type: nodeType },
|
||||
meta: { incomplete: "true" },
|
||||
};
|
||||
// No auto-wire on drag-drop — the node is placed where dropped and stays
|
||||
// unconnected until the user draws an edge.
|
||||
workflowObj.pendingEdge = null;
|
||||
workflowObj.pendingInsert = null;
|
||||
workflowObj.currentSelectedNodeID = id;
|
||||
workflowObj.isEditNode = false;
|
||||
workflowObj.dialog.name = nodeType;
|
||||
workflowObj.dialog.expand = false;
|
||||
workflowObj.dialog.show = true;
|
||||
commitStagedNode();
|
||||
closeNodeDrawer();
|
||||
}
|
||||
|
||||
// Hover-`+` add: STAGE a node below `sourceId` and open its config drawer. The
|
||||
// node is NOT added to the canvas here — it's committed (added + auto-wired)
|
||||
// only when the drawer is saved (commitNode), or discarded on cancel
|
||||
// (cancelNodeDrawer). Pipeline pattern. `handle` is always "out" (the single
|
||||
// output; the Condition is a filter, not a true/false branch).
|
||||
// Start node picked: ADD the workflow's first (trigger) node to the canvas and
|
||||
// open its panel.
|
||||
//
|
||||
// Committed immediately rather than staged (the addNodeAfter / onDrop
|
||||
// pattern): a staged node only lands via commitNode, which runs from the
|
||||
// drawer's Save — and the trigger panel is a READ-ONLY payload reference with
|
||||
// no footer and no Save (WorkflowNodeDrawer's `readonlyBody`). Staged, it
|
||||
// could never be committed, so the node never appeared on the canvas.
|
||||
// open its panel. Like every other add now, it lands on the canvas immediately
|
||||
// (commitStagedNode is the shared path for the config nodes); the trigger is the
|
||||
// one node with a READ-ONLY panel, so there's simply nothing to commit on close.
|
||||
//
|
||||
// `triggerKind` maps to the backend WorkflowTriggerKind; the node type stays
|
||||
// "workflow_trigger" for all kinds.
|
||||
@@ -1153,9 +1280,10 @@ export default function useWorkflowCanvas(t: TranslateFn) {
|
||||
// Placing the trigger is a real add — snapshot before, mark dirty after, so
|
||||
// it's undoable and the unsaved guard catches a just-started workflow.
|
||||
pushWorkflowHistory();
|
||||
const wf = workflowObj.currentSelectedWorkflow;
|
||||
const id = getUUID();
|
||||
workflowObj.currentSelectedWorkflow.nodes = [
|
||||
...workflowObj.currentSelectedWorkflow.nodes,
|
||||
wf.nodes = [
|
||||
...wf.nodes,
|
||||
{
|
||||
id,
|
||||
type: meta.ioType,
|
||||
@@ -1168,11 +1296,58 @@ export default function useWorkflowCanvas(t: TranslateFn) {
|
||||
},
|
||||
},
|
||||
];
|
||||
// Empty-canvas scaffold "Action first" path: if the user placed the action
|
||||
// step BEFORE the trigger, the canvas holds exactly that one orphan step —
|
||||
// auto-wire the new trigger into it so the two-node chain the scaffold implied
|
||||
// is connected, instead of stranding an unlinked step. Guarded to the single
|
||||
// orphan case so re-adding a trigger to a larger graph never wires blindly.
|
||||
const others = wf.nodes.filter((n: any) => n.id !== id);
|
||||
if (others.length === 1) {
|
||||
const only = others[0];
|
||||
const hasIncoming = (wf.edges || []).some((e: any) => e.target === only.id);
|
||||
if (!hasIncoming && only.data?.node_type !== "workflow_trigger") {
|
||||
wf.edges = [...(wf.edges || []), newEdge(id, only.id)];
|
||||
}
|
||||
}
|
||||
markWorkflowDirty();
|
||||
// Don't auto-open the trigger's (read-only) detail panel — placing the trigger
|
||||
// shouldn't interrupt the build flow. The user can click the node to open it.
|
||||
}
|
||||
|
||||
// Empty-canvas scaffold "Action" slot picked: place the workflow's FIRST step.
|
||||
// When a trigger already exists (the common flow — trigger picked first), append
|
||||
// + auto-wire after the chain end, exactly like the palette add. When there's no
|
||||
// trigger yet (the user clicked Action before Trigger), drop the step UNCONNECTED
|
||||
// at the scaffold's action-slot position; adding the trigger afterwards auto-wires
|
||||
// into it (see addTriggerNode). `position` is the click point the scaffold card
|
||||
// sat at. Like every add, the node lands as an unconfigured DUMMY — its config
|
||||
// panel opens only when the user clicks it, not on add.
|
||||
function addActionFromStart(nodeType: string, position: { x: number; y: number } | null) {
|
||||
if (hasTrigger()) {
|
||||
const src = endNodeId();
|
||||
if (src) addNodeAfter(src, "out", nodeType);
|
||||
return;
|
||||
}
|
||||
const meta = nodeMeta(nodeType);
|
||||
if (!meta) return;
|
||||
const id = getUUID();
|
||||
workflowObj.currentSelectedNodeData = {
|
||||
id,
|
||||
type: meta.ioType,
|
||||
position: position ?? { x: 320, y: 240 },
|
||||
data: { label: id, node_type: nodeType },
|
||||
meta: { incomplete: "true" },
|
||||
};
|
||||
// Unconnected — no trigger to wire from yet; the trigger add links it later.
|
||||
workflowObj.pendingEdge = null;
|
||||
workflowObj.pendingInsert = null;
|
||||
workflowObj.currentSelectedNodeID = id;
|
||||
workflowObj.dialog.name = nodeType;
|
||||
workflowObj.dialog.expand = false;
|
||||
commitStagedNode();
|
||||
closeNodeDrawer();
|
||||
}
|
||||
|
||||
const NODE_W = 240;
|
||||
// Vertical gap between a node and the next row (matches addNodeAfter / tidy).
|
||||
const INSERT_ROW_GAP = 160;
|
||||
@@ -1199,19 +1374,28 @@ export default function useWorkflowCanvas(t: TranslateFn) {
|
||||
type: meta.ioType,
|
||||
position,
|
||||
data: { label: id, node_type: nodeType },
|
||||
// A freshly-added config node is an unconfigured placeholder until the panel
|
||||
// is closed with its payload; flag it so it shows the "Set up later" badge and
|
||||
// Publish stays blocked.
|
||||
meta: { incomplete: "true" },
|
||||
};
|
||||
workflowObj.pendingEdge = { source: sourceId, sourceHandle };
|
||||
workflowObj.pendingInsert = null;
|
||||
workflowObj.currentSelectedNodeID = id;
|
||||
workflowObj.isEditNode = false;
|
||||
workflowObj.dialog.name = nodeType;
|
||||
workflowObj.dialog.expand = false;
|
||||
workflowObj.dialog.show = true;
|
||||
// Insert-immediately (no Save button): the node lands on the canvas + auto-wires
|
||||
// now, and the panel does NOT open — adding several steps in a row shouldn't mean
|
||||
// dismissing a dialog each time. It arrives flagged incomplete ("Set up later")
|
||||
// and is configured by clicking it, which is what blocks Publish until it's done.
|
||||
commitStagedNode();
|
||||
closeNodeDrawer();
|
||||
}
|
||||
|
||||
// Insert-on-edge (T7): STAGE a node to be spliced onto edge A→B. On commit the
|
||||
// old edge is removed and A→new + new→B are created (see commitNode). The node is
|
||||
// positioned at the midpoint of A and B; B is nudged down so it doesn't overlap.
|
||||
// Insert-on-edge (T7): splice a node onto edge A→B. commitStagedNode removes the
|
||||
// old edge and creates A→new + new→B immediately. The node is positioned a row
|
||||
// below A, aligned with B's column; B (and its subtree) is nudged down so it
|
||||
// doesn't overlap.
|
||||
function addNodeOnEdge(edgeId: string, nodeType: string) {
|
||||
const wf = workflowObj.currentSelectedWorkflow;
|
||||
const edge = (wf.edges || []).find((e: any) => e.id === edgeId);
|
||||
@@ -1223,8 +1407,8 @@ export default function useWorkflowCanvas(t: TranslateFn) {
|
||||
|
||||
const id = getUUID();
|
||||
// Place the spliced node a full row below the source and aligned with the
|
||||
// target's column (so new→target reads as a straight edge). commitNode then
|
||||
// nudges the target + downstream down to keep a full row of breathing room
|
||||
// target's column (so new→target reads as a straight edge). commitStagedNode
|
||||
// then nudges the target + downstream down to keep a full row of breathing room
|
||||
// below the new node.
|
||||
const position = {
|
||||
x: tgt.position?.x ?? src.position?.x ?? 0,
|
||||
@@ -1235,6 +1419,7 @@ export default function useWorkflowCanvas(t: TranslateFn) {
|
||||
type: meta.ioType,
|
||||
position,
|
||||
data: { label: id, node_type: nodeType },
|
||||
meta: { incomplete: "true" },
|
||||
};
|
||||
workflowObj.pendingEdge = null;
|
||||
workflowObj.pendingInsert = {
|
||||
@@ -1244,26 +1429,24 @@ export default function useWorkflowCanvas(t: TranslateFn) {
|
||||
sourceHandle: edge.sourceHandle,
|
||||
};
|
||||
workflowObj.currentSelectedNodeID = id;
|
||||
workflowObj.isEditNode = false;
|
||||
workflowObj.dialog.name = nodeType;
|
||||
workflowObj.dialog.expand = false;
|
||||
workflowObj.dialog.show = true;
|
||||
commitStagedNode();
|
||||
closeNodeDrawer();
|
||||
}
|
||||
|
||||
// Drawer Save: merge the form payload, then either update the existing node
|
||||
// (edit) or commit the staged node + its auto-wired edge (add / insert).
|
||||
function commitNode(payload: any = {}) {
|
||||
// Insert the staged node (currentSelectedNodeData) onto the canvas immediately —
|
||||
// the "insert-immediately" half of the Save-less node panel. Adds the node and
|
||||
// its auto-wired edge (append via pendingEdge, or splice A→new→B via
|
||||
// pendingInsert), leaves it in EDIT mode, and does NOT touch the dialog. The panel
|
||||
// then opens on this now-real node; closing it commits the node's config
|
||||
// (applyNodeConfig). One undo reverts the whole insert (edges included).
|
||||
function commitStagedNode() {
|
||||
const wf = workflowObj.currentSelectedWorkflow;
|
||||
const node = workflowObj.currentSelectedNodeData;
|
||||
if (!node) return;
|
||||
// Snapshot BEFORE the add/edit so one undo reverts the whole commit.
|
||||
pushWorkflowHistory();
|
||||
node.data = { ...node.data, ...payload };
|
||||
|
||||
if (workflowObj.isEditNode) {
|
||||
const idx = wf.nodes.findIndex((n: any) => n.id === node.id);
|
||||
if (idx !== -1) wf.nodes[idx] = node;
|
||||
} else if (workflowObj.pendingInsert) {
|
||||
if (workflowObj.pendingInsert) {
|
||||
// Splice onto the edge: drop A→B, add A→new and new→B (T7).
|
||||
const ins = workflowObj.pendingInsert;
|
||||
wf.nodes = [...wf.nodes, node];
|
||||
@@ -1297,20 +1480,39 @@ export default function useWorkflowCanvas(t: TranslateFn) {
|
||||
}
|
||||
workflowObj.pendingEdge = null;
|
||||
workflowObj.pendingInsert = null;
|
||||
workflowObj.isEditNode = false;
|
||||
workflowObj.dialog.expand = false;
|
||||
workflowObj.dialog.show = false;
|
||||
// The Test log PERSISTS across edits — adding / inserting / editing
|
||||
// a node keeps the dock and existing badges in place instead of a jarring
|
||||
// close+reopen. A newly-added node simply has no badge (it wasn't in the run);
|
||||
// an edited node keeps its last-run badge until re-tested. Cleared only by a new
|
||||
// run or the explicit "Clear" button. See executeTestRun for the run snapshot.
|
||||
// The node is now a real, committed node on the canvas — edit mode from here.
|
||||
workflowObj.isEditNode = true;
|
||||
markWorkflowDirty();
|
||||
}
|
||||
|
||||
// Drawer Cancel: discard a staged (not-yet-added) node; leave existing nodes
|
||||
// untouched.
|
||||
function cancelNodeDrawer() {
|
||||
// Merge the body's config payload into the already-committed node, in place,
|
||||
// WITHOUT closing the panel. Change-gated — identical data writes nothing and does
|
||||
// NOT mark dirty (e.g. open + close with no edit). Used both by the panel-close
|
||||
// commit and by the NDV Replay (which commits, then re-runs, staying open).
|
||||
function mergeNodeConfig(payload: any = {}) {
|
||||
const wf = workflowObj.currentSelectedWorkflow;
|
||||
const node = workflowObj.currentSelectedNodeData;
|
||||
if (!node) return;
|
||||
const merged = { ...node.data, ...payload };
|
||||
if (isEqual(merged, node.data)) return;
|
||||
pushWorkflowHistory();
|
||||
node.data = merged;
|
||||
const idx = wf.nodes.findIndex((n: any) => n.id === node.id);
|
||||
if (idx !== -1) wf.nodes[idx] = node;
|
||||
// The Test log PERSISTS across edits — editing a node keeps the dock and existing
|
||||
// badges in place; the node keeps its last-run badge until re-tested.
|
||||
markWorkflowDirty();
|
||||
}
|
||||
|
||||
// Node panel CLOSE (there is no Save button): commit the config, then close.
|
||||
function applyNodeConfig(payload: any = {}) {
|
||||
mergeNodeConfig(payload);
|
||||
closeNodeDrawer();
|
||||
}
|
||||
|
||||
// Close the node panel without deleting anything (the node is already on the
|
||||
// canvas — insert-immediately). Just clears the selection + dialog state.
|
||||
function closeNodeDrawer() {
|
||||
workflowObj.pendingEdge = null;
|
||||
workflowObj.pendingInsert = null;
|
||||
workflowObj.currentSelectedNodeData = null;
|
||||
@@ -1365,12 +1567,18 @@ export default function useWorkflowCanvas(t: TranslateFn) {
|
||||
workflowObj.nameError = false;
|
||||
workflowObj.nameErrorMessage = "";
|
||||
workflowObj.deleteConfirm = { show: false, nodeId: "" };
|
||||
workflowObj.incompleteHighlight = [];
|
||||
workflowObj.runsHistory = {
|
||||
list: [],
|
||||
fetchedAt: 0,
|
||||
params: { start: 0, end: 0 },
|
||||
loading: false,
|
||||
};
|
||||
workflowObj.testRun = {
|
||||
show: false,
|
||||
input: "",
|
||||
fromNode: "",
|
||||
result: null,
|
||||
resultDrawer: { show: false, nodeId: "" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1387,9 +1595,11 @@ export default function useWorkflowCanvas(t: TranslateFn) {
|
||||
// node ops
|
||||
openStepPicker,
|
||||
openTriggerPicker,
|
||||
openActionPicker,
|
||||
openInsertPicker,
|
||||
closeStepPicker,
|
||||
addTriggerNode,
|
||||
addActionFromStart,
|
||||
addNodeAfter,
|
||||
addNodeOnEdge,
|
||||
addNodeToEnd,
|
||||
@@ -1397,8 +1607,9 @@ export default function useWorkflowCanvas(t: TranslateFn) {
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
commitNode,
|
||||
cancelNodeDrawer,
|
||||
applyNodeConfig,
|
||||
mergeNodeConfig,
|
||||
closeNodeDrawer,
|
||||
editNode,
|
||||
requestDeleteNode,
|
||||
cancelDeleteNode,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { computed, ref } from "vue";
|
||||
import destinationService from "@/services/alert_destination";
|
||||
import type { Destination } from "@/ts/interfaces/alert";
|
||||
|
||||
/**
|
||||
* Shared, load-once lookup of the org's PIPELINE destinations, keyed by name.
|
||||
*
|
||||
* A workflow node stores only the destination's name, so the read-only run view has
|
||||
* to resolve it. There is no single-record endpoint that can do that: `get_by_name`
|
||||
* hits `/alerts/destinations/{name}` with no `module` parameter and 404s on a
|
||||
* pipeline destination. The list endpoint is the only one that accepts `module`.
|
||||
*
|
||||
* The result is cached per org and the in-flight request is shared, so stepping
|
||||
* through a run's nodes issues one call rather than one per destination node.
|
||||
*/
|
||||
const byName = ref<Record<string, Destination>>({});
|
||||
const loadedOrg = ref<string | null>(null);
|
||||
const loadFailed = ref(false);
|
||||
let inflight: Promise<void> | null = null;
|
||||
|
||||
const fetchDestinations = async (org: string) => {
|
||||
try {
|
||||
const res = await destinationService.list({
|
||||
org_identifier: org,
|
||||
page_num: 1,
|
||||
page_size: 100000,
|
||||
sort_by: "name",
|
||||
desc: false,
|
||||
module: "pipeline",
|
||||
});
|
||||
const map: Record<string, Destination> = {};
|
||||
for (const d of res.data || []) map[d.name] = d;
|
||||
byName.value = map;
|
||||
loadedOrg.value = org;
|
||||
loadFailed.value = false;
|
||||
} catch {
|
||||
// Leave the cache empty and record the failure — callers must not report
|
||||
// "no longer exists" when the truth is that the lookup never completed.
|
||||
byName.value = {};
|
||||
loadedOrg.value = org;
|
||||
loadFailed.value = true;
|
||||
} finally {
|
||||
inflight = null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Loads the destination map once per org; concurrent callers share one request. */
|
||||
export const ensureWorkflowDestinations = (org: string): Promise<void> => {
|
||||
if (loadedOrg.value === org && !loadFailed.value) return Promise.resolve();
|
||||
if (!inflight) inflight = fetchDestinations(org);
|
||||
return inflight;
|
||||
};
|
||||
|
||||
/** Drops the cache so the next ensure() refetches (org switch, or a fresh run view). */
|
||||
export const resetWorkflowDestinations = () => {
|
||||
byName.value = {};
|
||||
loadedOrg.value = null;
|
||||
loadFailed.value = false;
|
||||
inflight = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* True once a lookup has COMPLETED successfully — distinct from "the map is empty",
|
||||
* which is also what a failed call and an org with no destinations both look like.
|
||||
* Only a caller that sees this can tell "not found" apart from "not known yet".
|
||||
*/
|
||||
const loaded = computed(() => loadedOrg.value !== null && !loadFailed.value);
|
||||
|
||||
export const useWorkflowDestinations = () => ({
|
||||
destinationsByName: byName,
|
||||
destinationsLoaded: loaded,
|
||||
ensureWorkflowDestinations,
|
||||
resetWorkflowDestinations,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* A destination's type is stored as `destination_type_name` (backend) and mirrored
|
||||
* to `destination_type` in the frontend form. "custom" is a plain HTTP webhook;
|
||||
* every other value — and an unset one, which the edit form defaults to
|
||||
* "openobserve" — is a prebuilt provider (Splunk, Datadog, Elasticsearch, …).
|
||||
*
|
||||
* Workflow destination nodes can only execute custom webhooks, so both the picker
|
||||
* (which offers destinations) and the read-only node summary (which flags an
|
||||
* already-saved one) decide from this single rule.
|
||||
*/
|
||||
export const isCustomDestination = (destination: {
|
||||
destination_type_name?: string;
|
||||
destination_type?: string;
|
||||
}): boolean =>
|
||||
(destination?.destination_type_name || destination?.destination_type || "")
|
||||
.trim()
|
||||
.toLowerCase() === "custom";
|
||||
Reference in New Issue
Block a user