diff --git a/integration-tests/lib/elements.js b/integration-tests/lib/elements.js index c4fb370..7d64661 100644 --- a/integration-tests/lib/elements.js +++ b/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() { diff --git a/integration-tests/tests/multi-dashboard-includes/config.yaml b/integration-tests/tests/multi-dashboard-includes/config.yaml new file mode 100644 index 0000000..7deb49e --- /dev/null +++ b/integration-tests/tests/multi-dashboard-includes/config.yaml @@ -0,0 +1,7 @@ + +include: dashboards.d + +actions: + - title: Base Action + shell: echo "base" + icon: ping \ No newline at end of file diff --git a/integration-tests/tests/multi-dashboard-includes/dashboards.d/one.yaml b/integration-tests/tests/multi-dashboard-includes/dashboards.d/one.yaml new file mode 100644 index 0000000..ae8109b --- /dev/null +++ b/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!" diff --git a/integration-tests/tests/multi-dashboard-includes/dashboards.d/people.yaml b/integration-tests/tests/multi-dashboard-includes/dashboards.d/people.yaml new file mode 100644 index 0000000..827ab73 --- /dev/null +++ b/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 diff --git a/integration-tests/tests/multi-dashboard-includes/entities/person.yaml b/integration-tests/tests/multi-dashboard-includes/entities/person.yaml new file mode 100644 index 0000000..48f501b --- /dev/null +++ b/integration-tests/tests/multi-dashboard-includes/entities/person.yaml @@ -0,0 +1,4 @@ +- name: Alice +- name: Bob + + diff --git a/integration-tests/tests/multi-dashboard-includes/multi-dashboard-includes.mjs b/integration-tests/tests/multi-dashboard-includes/multi-dashboard-includes.mjs new file mode 100644 index 0000000..9b6283a --- /dev/null +++ b/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') + }) + +}) + + diff --git a/service/internal/api/dashboard_entities.go b/service/internal/api/dashboard_entities.go index 03949cf..68a053b 100644 --- a/service/internal/api/dashboard_entities.go +++ b/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 } diff --git a/service/internal/config/config_reloader.go b/service/internal/config/config_reloader.go index ddbe9e4..3bb8ee0 100644 --- a/service/internal/config/config_reloader.go +++ b/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 } diff --git a/service/internal/config/sanitize.go b/service/internal/config/sanitize.go index d8d3d89..9cc8c49 100644 --- a/service/internal/config/sanitize.go +++ b/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) } } diff --git a/service/internal/entities/templates.go b/service/internal/entities/templates.go index ccb67f0..53092de 100644 --- a/service/internal/entities/templates.go +++ b/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) } diff --git a/service/internal/executor/executor_actions.go b/service/internal/executor/executor_actions.go index 053af80..92d3396 100644 --- a/service/internal/executor/executor_actions.go +++ b/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) }