Repository: argoproj/argo (go)

Issue:
Node search tool in UI Workflow viewer
# Summary

Add a search bar for nodes and node contents in the UI workflow viewer.

# Motivation

Large workflows have a lot of nodes and it could be difficult to find the ones that are relevant, either by title or by contents.

# Proposal

I'll implement this as a UI only change for now

---
<!-- Issue Author: Don't delete this message to encourage other users to support your issue! -->
**Message from the maintainers**:

If you wish to see this enhancement implemented please add a 👍 reaction to this issue! We often sort issues this way to know what to prioritize.


Pull request description:
feat(ui): Node search tool in UI Workflow viewer
Closes #3485.

This is a basic search tool that checks the label of a node. This change also affects the event flow page. Filtering is done in the same way as other filters. Note that the search results are also filtered by other filters.

#### Screenshots
No search keyword
![image](https://user-images.githubusercontent.com/4591982/106690808-b9d1bd00-65d2-11eb-91c0-7b254c67e5c4.png)

With keyword "gen"
![image](https://user-images.githubusercontent.com/4591982/106690858-cbb36000-65d2-11eb-87c4-b9ac7e08604e.png)

Display in mobile mode
![image](https://user-images.githubusercontent.com/4591982/106690911-e4237a80-65d2-11eb-8360-41db99decf81.png)

Event flow page
![image](https://user-images.githubusercontent.com/4591982/106691093-3369ab00-65d3-11eb-8a37-e57c04418b4a.png)


Checklist:

* [ ] My organization is added to [USERS.md](https://github.com/argoproj/argo/blob/master/USERS.md).

<!--

Please leave your PR in draft if you don't need a review yet. 

To fix failing `CI / Codegen`, run `make codegen`. 

-->


Patch:
diff --git a/ui/src/app/shared/components/graph/graph-panel.scss b/ui/src/app/shared/components/graph/graph-panel.scss
index 07af039dd..57dcb576d 100644
--- a/ui/src/app/shared/components/graph/graph-panel.scss
+++ b/ui/src/app/shared/components/graph/graph-panel.scss
@@ -24,6 +24,11 @@
   }
 }
 
+.node-search-bar {
+  display: inline-block;
+  margin-left: 10px;
+}
+
 .graph-options-panel {
   margin: 10px;
   padding: 10px;
diff --git a/ui/src/app/shared/components/graph/graph-panel.tsx b/ui/src/app/shared/components/graph/graph-panel.tsx
index 0a6a069e4..15e9ee22a 100644
--- a/ui/src/app/shared/components/graph/graph-panel.tsx
+++ b/ui/src/app/shared/components/graph/graph-panel.tsx
@@ -1,5 +1,6 @@
 import * as React from 'react';
 import {useEffect} from 'react';
+import {TextInput} from '../../../shared/components/text-input';
 import {ScopedLocalStorage} from '../../scoped-local-storage';
 import {FilterDropDown} from '../filter-drop-down';
 import {Icon} from '../icon';
@@ -55,6 +56,7 @@ export const GraphPanel = (props: Props) => {
     const [nodeGenres, setNodeGenres] = React.useState<NodeGenres>(storage.getItem('nodeGenres', props.nodeGenres));
     const [nodeClassNames, setNodeClassNames] = React.useState<NodeClassNames>(storage.getItem('nodeClassNames', props.nodeClassNames));
     const [nodeTags, setNodeTags] = React.useState<NodeTags>(props.nodeTags);
+    const [nodeSearchKeyword, setNodeSearchKeyword] = React.useState<string>('');
 
     useEffect(() => storage.setItem('nodeSize', nodeSize, props.nodeSize), [nodeSize]);
     useEffect(() => storage.setItem('horizontal', horizontal, props.horizontal), [horizontal]);
@@ -73,7 +75,8 @@ export const GraphPanel = (props: Props) => {
         return (
             nodeGenres[label.genre] &&
             (!nodeClassNames || Object.entries(nodeClassNames).find(([className, checked]) => checked && (label.classNames || '').split(' ').includes(className))) &&
-            (!nodeTags || Object.entries(nodeTags).find(([tag, checked]) => !label.tags || (checked && label.tags.has(tag))))
+            (!nodeTags || Object.entries(nodeTags).find(([tag, checked]) => !label.tags || (checked && label.tags.has(tag)))) &&
+            props.graph.nodes.get(id).label.includes(nodeSearchKeyword)
         );
     };
 
@@ -132,6 +135,9 @@ export const GraphPanel = (props: Props) => {
                         <i className='fa fa-bolt' />
                     </a>
                     {props.options}
+                    <div className='node-search-bar'>
+                        <TextInput value={nodeSearchKeyword} onChange={v => setNodeSearchKeyword(v)} placeholder={'Search'} />
+                    </div>
                 </div>
             )}
             <div className={'graph ' + props.classNames}>
diff --git a/workflow/validate/validate.go b/workflow/validate/validate.go
index 2fc86422e..0876b4248 100644
--- a/workflow/validate/validate.go
+++ b/workflow/validate/validate.go
@@ -1304,6 +1304,10 @@ func (ctx *templateValidationCtx) validateDAG(scope map[string]interface{}, tmpl
 		if err != nil {
 			return errors.Errorf(errors.CodeBadRequest, "templates.%s.tasks.%s %s", tmpl.Name, task.Name, err.Error())
 		}
+		err = validateDAGTaskArgumentDependency(task.Arguments, ancestry)
+		if err != nil {
+			return errors.Errorf(errors.CodeBadRequest, "templates.%s.tasks.%s %s", tmpl.Name, task.Name, err.Error())
+		}
 		err = validateArguments(fmt.Sprintf("templates.%s.tasks.%s.arguments.", tmpl.Name, task.Name), task.Arguments)
 		if err != nil {
 			return err
@@ -1318,6 +1322,38 @@ func (ctx *templateValidationCtx) validateDAG(scope map[string]interface{}, tmpl
 	return nil
 }
 
+func validateDAGTaskArgumentDependency(arguments wfv1.Arguments, ancestry []string) error {
+	ancestryMap := make(map[string]struct{}, len(ancestry))
+	for _, a := range ancestry {
+		ancestryMap[a] = struct{}{}
+	}
+
+	for _, param := range arguments.Parameters {
+		if strings.HasPrefix(param.Value.String(), "{{tasks.") {
+			// All parameter values should have been validated, so
+			// index 1 should exist.
+			refTaskName := strings.Split(param.Value.String(), ".")[1]
+
+			if _, dependencyExists := ancestryMap[refTaskName]; !dependencyExists {
+				return errors.Errorf(errors.CodeBadRequest, "missing dependency '%s' for parameter '%s'", refTaskName, param.Name)
+			}
+		}
+	}
+
+	for _, artifact := range arguments.Artifacts {
+		if strings.HasPrefix(artifact.From, "{{tasks.") {
+			// All parameter values should have been validated, so
+			// index 1 should exist.
+			refTaskName := strings.Split(artifact.From, ".")[1]
+
+			if _, dependencyExists := ancestryMap[refTaskName]; !dependencyExists {
+				return errors.Errorf(errors.CodeBadRequest, "missing dependency '%s' for artifact '%s'", refTaskName, artifact.Name)
+			}
+		}
+	}
+	return nil
+}
+
 func validateDAGTargets(tmpl *wfv1.Template, nameToTask map[string]wfv1.DAGTask) error {
 	if tmpl.DAG.Target == "" {
 		return nil


Test patch:
diff --git a/workflow/validate/validate_dag_test.go b/workflow/validate/validate_dag_test.go
index 059095029..4150d72c4 100644
--- a/workflow/validate/validate_dag_test.go
+++ b/workflow/validate/validate_dag_test.go
@@ -215,7 +215,7 @@ spec:
         arguments:
           parameters:
           - name: message
-            value: "{{tasks.B.outputs.parameters.unresolvable}}"
+            value: "{{tasks.B.outputs.parameters.hosts}}"
 `
 
 var dagResolvedGlobalVar = `
@@ -290,7 +290,7 @@ func TestDAGVariableResolution(t *testing.T) {
 
 	_, err = validate(dagResolvedVarNotAncestor)
 	if assert.NotNil(t, err) {
-		assert.Contains(t, err.Error(), "failed to resolve {{tasks.B.outputs.parameters.unresolvable}}")
+		assert.Contains(t, err.Error(), "templates.unresolved.tasks.C missing dependency 'B' for parameter 'message'")
 	}
 
 	_, err = validate(dagResolvedGlobalVar)