feat: Dashboard configs can now self-contain their entities, and actions all in one neat file
This commit is contained in:
parent
e58677e12c
commit
32fea4ec30
|
|
@ -3,14 +3,10 @@ import fs from 'fs'
|
||||||
import { expect } from 'chai'
|
import { expect } from 'chai'
|
||||||
import { Condition } from 'selenium-webdriver'
|
import { Condition } from 'selenium-webdriver'
|
||||||
|
|
||||||
export async function getActionButtons (dashboardTitle = null) {
|
export async function getActionButtons () {
|
||||||
// New Vue UI renders action buttons using ActionButton.vue structure
|
// Currently, only the active dashboard's contents are rendered,
|
||||||
// Each button lives under a container with class .action-button
|
// so we don't need to scope the selector by dashboard title.
|
||||||
if (dashboardTitle == null) {
|
|
||||||
return await webdriver.findElements(By.css('.action-button button'))
|
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 getExecutionDialogOutput() {
|
export async function getExecutionDialogOutput() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
|
||||||
|
include: dashboards.d
|
||||||
|
|
||||||
|
actions:
|
||||||
|
- title: Base Action
|
||||||
|
shell: echo "base"
|
||||||
|
icon: ping
|
||||||
|
|
@ -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!"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
- name: Alice
|
||||||
|
- name: Bob
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -84,7 +84,16 @@ func isLinkType(itemType string) bool {
|
||||||
func cloneLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, clone *apiv1.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
|
func cloneLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, clone *apiv1.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
|
||||||
clone.Type = "link"
|
clone.Type = "link"
|
||||||
clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
|
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
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
|
||||||
if srcActions, ok := src["actions"]; ok {
|
if srcActions, ok := src["actions"]; ok {
|
||||||
mergeActionsFromSource(srcActions, dest)
|
mergeActionsFromSource(srcActions, dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if srcDashboards, ok := src["dashboards"]; ok {
|
||||||
|
mergeDashboardsFromSource(srcDashboards, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if srcEntities, ok := src["entities"]; ok {
|
||||||
|
mergeEntitiesFromSource(srcEntities, dest)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,17 +42,33 @@ func (cfg *Config) sanitizeInlineAction(component *DashboardComponent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if component.InlineAction.Title == "" {
|
sanitizeInlineActionTitles(component)
|
||||||
component.InlineAction.Title = component.Title
|
|
||||||
|
if component.Entity != "" && component.InlineAction.Entity == "" {
|
||||||
|
component.InlineAction.Entity = component.Entity
|
||||||
}
|
}
|
||||||
|
|
||||||
component.InlineAction.sanitize(cfg)
|
component.InlineAction.sanitize(cfg)
|
||||||
|
|
||||||
if cfg.inlineActionExists(component.InlineAction) {
|
cfg.addInlineActionIfNotExists(component.InlineAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) addInlineActionIfNotExists(action *Action) {
|
||||||
|
if cfg.inlineActionExists(action) {
|
||||||
return
|
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 {
|
func (cfg *Config) inlineActionExists(action *Action) bool {
|
||||||
|
|
@ -93,6 +109,10 @@ func (cfg *Config) inlineActionIDExists(action *Action) bool {
|
||||||
|
|
||||||
func (cfg *Config) sanitizeChildDashboardComponents(component *DashboardComponent) {
|
func (cfg *Config) sanitizeChildDashboardComponents(component *DashboardComponent) {
|
||||||
for _, child := range component.Contents {
|
for _, child := range component.Contents {
|
||||||
|
if child.Entity == "" {
|
||||||
|
child.Entity = component.Entity
|
||||||
|
}
|
||||||
|
|
||||||
cfg.sanitizeDashboardComponentForInlineActions(child)
|
cfg.sanitizeDashboardComponentForInlineActions(child)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ func migrateLegacyEntityProperties(rawShellCommand string) string {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"old": entityName,
|
"old": entityName,
|
||||||
"new": ".CurrentEntity",
|
"new": ".CurrentEntity",
|
||||||
}).Warnf("Legacy entity variable name found, changing to CurrentEntity")
|
}).Debugf("Legacy entity variable name found, changing to CurrentEntity")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +42,7 @@ func migrateLegacyEntityProperties(rawShellCommand string) string {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"old": argName,
|
"old": argName,
|
||||||
"new": ".CurrentEntity." + 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{
|
log.WithFields(log.Fields{
|
||||||
"old": argName,
|
"old": argName,
|
||||||
"new": ".Arguments." + 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)
|
rawShellCommand = strings.ReplaceAll(rawShellCommand, argName, ".Arguments."+argName)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,13 @@ func findDashboardActionTitles(req *RebuildActionMapRequest) {
|
||||||
//gocyclo:ignore
|
//gocyclo:ignore
|
||||||
func recurseDashboardForActionTitles(component *config.DashboardComponent, req *RebuildActionMapRequest) {
|
func recurseDashboardForActionTitles(component *config.DashboardComponent, req *RebuildActionMapRequest) {
|
||||||
for _, sub := range component.Contents {
|
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)
|
req.DashboardActionTitles = append(req.DashboardActionTitles, sub.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue