Prechádzať zdrojové kódy

feat: Dashboard configs can now self-contain their entities, and actions all in one neat file

jamesread 7 mesiacov pred
rodič
commit
32fea4ec30

+ 4 - 8
integration-tests/lib/elements.js

@@ -3,14 +3,10 @@ import fs from 'fs'
 import { expect } from 'chai'
 import { Condition } from 'selenium-webdriver'
 
-export async function getActionButtons (dashboardTitle = null) {
-  // New Vue UI renders action buttons using ActionButton.vue structure
-  // Each button lives under a container with class .action-button
-  if (dashboardTitle == null) {
-    return await webdriver.findElements(By.css('.action-button button'))
-  } else {
-    return await webdriver.findElements(By.css('section[title="' + dashboardTitle + '"] .action-button button'))
-  }
+export async function getActionButtons () {
+  // Currently, only the active dashboard's contents are rendered,
+  // so we don't need to scope the selector by dashboard title.
+  return await webdriver.findElements(By.css('.action-button button'))
 }
 
 export async function getExecutionDialogOutput() {

+ 7 - 0
integration-tests/tests/multi-dashboard-includes/config.yaml

@@ -0,0 +1,7 @@
+
+include: dashboards.d
+
+actions:
+  - title: Base Action
+    shell: echo "base"
+    icon: ping

+ 12 - 0
integration-tests/tests/multi-dashboard-includes/dashboards.d/one.yaml

@@ -0,0 +1,12 @@
+dashboards:
+  - title: First Dashboard
+    contents:
+      - type: action
+        inlineAction:
+          title: First Action
+          shell: echo "First Dashboard, First Action!"
+
+      - type: action
+        inlineAction:
+          title: Second Action
+          shell: echo "First Dashboard, Second Action!"

+ 30 - 0
integration-tests/tests/multi-dashboard-includes/dashboards.d/people.yaml

@@ -0,0 +1,30 @@
+entities:
+  - file: entities/person.yaml
+    name: person
+
+dashboards:
+  - title: People
+    contents:
+      - type: action
+        inlineAction:
+          title: Third Action
+          shell: echo "First Dashboard, Third Action!"
+
+      - type: action
+        inlineAction:
+          title: Forth Action
+          shell: echo "First Dashboard, Forth Action!"
+
+      - title: "Person: {{ person.name }}"
+        type: fieldset
+        entity: person
+        contents:
+          - type: display
+            title: " {{ person.name }} is a person"
+            cssStyle:
+              background-color: red;
+
+          - title: "Greet {{ person.name }}"
+            inlineAction:
+              shell: echo "Hello, {{ person.name }}!"
+              icon: ping

+ 4 - 0
integration-tests/tests/multi-dashboard-includes/entities/person.yaml

@@ -0,0 +1,4 @@
+- name: Alice
+- name: Bob
+
+

+ 108 - 0
integration-tests/tests/multi-dashboard-includes/multi-dashboard-includes.mjs

@@ -0,0 +1,108 @@
+import { describe, it, before, after } from 'mocha'
+import { expect, assert } from 'chai'
+import { By } from 'selenium-webdriver'
+import {
+  getRootAndWait,
+  getActionButtons,
+  getNavigationLinks,
+  openSidebar,
+  takeScreenshotOnFailure,
+} from '../../lib/elements.js'
+
+describe('config: multi-dashboard-includes', function () {
+  this.timeout(30000)
+
+  before(async function () {
+    await runner.start('multi-dashboard-includes')
+  })
+
+  after(async () => {
+    await runner.stop()
+  })
+
+  afterEach(function () {
+    takeScreenshotOnFailure(this.currentTest, webdriver);
+  });
+
+  async function clickNavigationLinkByTitle (title) {
+    await openSidebar()
+
+    const navigationLinks = await getNavigationLinks()
+    assert.isAbove(navigationLinks.length, 0, 'Expected at least one navigation link')
+
+    const matching = []
+    for (const li of navigationLinks) {
+      const liTitle = await li.getAttribute('title')
+      if (liTitle === title) {
+        matching.push(li)
+      }
+    }
+
+    assert.strictEqual(matching.length, 1, `Expected exactly one navigation link with title "${title}"`)
+
+    await matching[0].click()
+  }
+
+  async function getActionTitlesOnDashboard (dashboardTitle = null) {
+    const buttons = await getActionButtons(dashboardTitle)
+    const titles = []
+
+    for (const button of buttons) {
+      titles.push(await button.getAttribute('title'))
+    }
+
+    return titles
+  }
+
+  it('Should expose both dashboards from included files in navigation', async function () {
+    await getRootAndWait()
+
+    await openSidebar()
+    const navigationLinks = await getNavigationLinks()
+    assert.isAbove(navigationLinks.length, 0, 'Expected navigation to have at least one link')
+
+    const titles = []
+    for (const li of navigationLinks) {
+      titles.push(await li.getAttribute('title'))
+    }
+
+    expect(titles).to.include('First Dashboard')
+    expect(titles).to.include('Second Dashboard')
+  })
+
+  it('First Dashboard shows First and Second inline actions from include', async function () {
+    await getRootAndWait()
+
+    // Navigate to "First Dashboard"
+    await clickNavigationLinkByTitle('First Dashboard')
+
+    // Buttons on this dashboard only
+    const titles = await getActionTitlesOnDashboard('First Dashboard')
+
+    expect(titles).to.include('First Action')
+    expect(titles).to.include('Second Action')
+
+    // Ensure actions from the second dashboard are not rendered here
+    expect(titles).to.not.include('Third Action')
+    expect(titles).to.not.include('Forth Action')
+  })
+
+  it('Second Dashboard shows Third and Forth inline actions from include', async function () {
+    await getRootAndWait()
+
+    // Navigate to "Second Dashboard"
+    await clickNavigationLinkByTitle('Second Dashboard')
+
+    const titles = await getActionTitlesOnDashboard('Second Dashboard')
+
+    expect(titles).to.include('Third Action')
+    expect(titles).to.include('Forth Action')
+
+    // Ensure actions from the first dashboard are not rendered here
+    expect(titles).to.not.include('First Action')
+    expect(titles).to.not.include('Second Action')
+  })
+
+})
+
+

+ 10 - 1
service/internal/api/dashboard_entities.go

@@ -84,7 +84,16 @@ func isLinkType(itemType string) bool {
 func cloneLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, clone *apiv1.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
 	clone.Type = "link"
 	clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
-	clone.Action = rr.findActionForEntity(subitem.Title, ent)
+	// Prefer an entity-specific action when available, but fall back to a
+	// non-entity-scoped action with the same title. This allows inline actions
+	// defined inside entity dashboards to work without requiring an explicit
+	// entity binding.
+	action := rr.findActionForEntity(subitem.Title, ent)
+	if action == nil {
+		action = rr.findAction(subitem.Title)
+	}
+
+	clone.Action = action
 	return clone
 }
 

+ 49 - 0
service/internal/config/config_reloader.go

@@ -192,10 +192,59 @@ func mergeActionsFromSource(srcActions interface{}, dest map[string]interface{})
 	}
 }
 
+// mergeDashboardsWhenBothExist merges dashboards when both src and dest have dashboards.
+func mergeDashboardsWhenBothExist(srcDashboards interface{}, destDashboards interface{}, dest map[string]interface{}) {
+	srcSlice, ok1 := srcDashboards.([]interface{})
+	destSlice, ok2 := destDashboards.([]interface{})
+	if ok1 && ok2 {
+		dest["dashboards"] = append(destSlice, srcSlice...)
+	} else {
+		dest["dashboards"] = srcDashboards
+	}
+}
+
+// mergeDashboardsFromSource merges dashboards from source into destination.
+func mergeDashboardsFromSource(srcDashboards interface{}, dest map[string]interface{}) {
+	if destDashboards, ok := dest["dashboards"]; ok {
+		mergeDashboardsWhenBothExist(srcDashboards, destDashboards, dest)
+	} else {
+		dest["dashboards"] = srcDashboards
+	}
+}
+
+// mergeEntitiesWhenBothExist merges entities when both src and dest have entities.
+func mergeEntitiesWhenBothExist(srcEntities interface{}, destEntities interface{}, dest map[string]interface{}) {
+	srcSlice, ok1 := srcEntities.([]interface{})
+	destSlice, ok2 := destEntities.([]interface{})
+	if ok1 && ok2 {
+		dest["entities"] = append(destSlice, srcSlice...)
+	} else {
+		dest["entities"] = srcEntities
+	}
+}
+
+// mergeEntitiesFromSource merges entities from source into destination.
+func mergeEntitiesFromSource(srcEntities interface{}, dest map[string]interface{}) {
+	if destEntities, ok := dest["entities"]; ok {
+		mergeEntitiesWhenBothExist(srcEntities, destEntities, dest)
+	} else {
+		dest["entities"] = srcEntities
+	}
+}
+
 func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
 	if srcActions, ok := src["actions"]; ok {
 		mergeActionsFromSource(srcActions, dest)
 	}
+
+	if srcDashboards, ok := src["dashboards"]; ok {
+		mergeDashboardsFromSource(srcDashboards, dest)
+	}
+
+	if srcEntities, ok := src["entities"]; ok {
+		mergeEntitiesFromSource(srcEntities, dest)
+	}
+
 	return nil
 }
 

+ 24 - 4
service/internal/config/sanitize.go

@@ -42,17 +42,33 @@ func (cfg *Config) sanitizeInlineAction(component *DashboardComponent) {
 		return
 	}
 
-	if component.InlineAction.Title == "" {
-		component.InlineAction.Title = component.Title
+	sanitizeInlineActionTitles(component)
+
+	if component.Entity != "" && component.InlineAction.Entity == "" {
+		component.InlineAction.Entity = component.Entity
 	}
 
 	component.InlineAction.sanitize(cfg)
 
-	if cfg.inlineActionExists(component.InlineAction) {
+	cfg.addInlineActionIfNotExists(component.InlineAction)
+}
+
+func (cfg *Config) addInlineActionIfNotExists(action *Action) {
+	if cfg.inlineActionExists(action) {
 		return
 	}
 
-	cfg.Actions = append(cfg.Actions, component.InlineAction)
+	cfg.Actions = append(cfg.Actions, action)
+}
+
+func sanitizeInlineActionTitles(component *DashboardComponent) {
+	if component.InlineAction.Title == "" {
+		component.InlineAction.Title = component.Title
+	}
+
+	if component.Title == "" {
+		component.Title = component.InlineAction.Title
+	}
 }
 
 func (cfg *Config) inlineActionExists(action *Action) bool {
@@ -93,6 +109,10 @@ func (cfg *Config) inlineActionIDExists(action *Action) bool {
 
 func (cfg *Config) sanitizeChildDashboardComponents(component *DashboardComponent) {
 	for _, child := range component.Contents {
+		if child.Entity == "" {
+			child.Entity = component.Entity
+		}
+
 		cfg.sanitizeDashboardComponentForInlineActions(child)
 	}
 }

+ 3 - 3
service/internal/entities/templates.go

@@ -30,7 +30,7 @@ func migrateLegacyEntityProperties(rawShellCommand string) string {
 			log.WithFields(log.Fields{
 				"old": entityName,
 				"new": ".CurrentEntity",
-			}).Warnf("Legacy entity variable name found, changing to CurrentEntity")
+			}).Debugf("Legacy entity variable name found, changing to CurrentEntity")
 			continue
 		}
 
@@ -42,7 +42,7 @@ func migrateLegacyEntityProperties(rawShellCommand string) string {
 			log.WithFields(log.Fields{
 				"old": argName,
 				"new": ".CurrentEntity." + argName,
-			}).Warnf("Legacy variable name found, changing to CurrentEntity")
+			}).Debugf("Legacy variable name found, changing to CurrentEntity")
 		}
 	}
 
@@ -59,7 +59,7 @@ func migrateLegacyArgumentNames(rawShellCommand string) string {
 			log.WithFields(log.Fields{
 				"old": argName,
 				"new": ".Arguments." + argName,
-			}).Warnf("Legacy variable name found, changing to Argument")
+			}).Debugf("Legacy variable name found, changing to Argument")
 
 			rawShellCommand = strings.ReplaceAll(rawShellCommand, argName, ".Arguments."+argName)
 		}

+ 7 - 1
service/internal/executor/executor_actions.go

@@ -81,7 +81,13 @@ func findDashboardActionTitles(req *RebuildActionMapRequest) {
 //gocyclo:ignore
 func recurseDashboardForActionTitles(component *config.DashboardComponent, req *RebuildActionMapRequest) {
 	for _, sub := range component.Contents {
-		if sub.Type == "link" || sub.Type == "" {
+		if sub.InlineAction != nil {
+			title := sub.Title
+			if title == "" {
+				title = sub.InlineAction.Title
+			}
+			req.DashboardActionTitles = append(req.DashboardActionTitles, title)
+		} else if sub.Type == "link" || sub.Type == "" {
 			req.DashboardActionTitles = append(req.DashboardActionTitles, sub.Title)
 		}