fix: add support for entities in checklists
This commit is contained in:
parent
6077c63cfd
commit
b18518ebea
|
|
@ -53,3 +53,28 @@ arguments:
|
||||||
Choice `value` fields must not contain commas, because commas are used to join multiple selections together.
|
Choice `value` fields must not contain commas, because commas are used to join multiple selections together.
|
||||||
|
|
||||||
Each `title` is shown in the web interface. If a submitted segment matches a choice `title`, OliveTin maps it to the corresponding `value` before validation, matching the behaviour of xref:args/input_checkbox.adoc[checkbox] arguments with choices.
|
Each `title` is shown in the web interface. If a submitted segment matches a choice `title`, OliveTin maps it to the corresponding `value` before validation, matching the behaviour of xref:args/input_checkbox.adoc[checkbox] arguments with choices.
|
||||||
|
|
||||||
|
== Using Entities
|
||||||
|
|
||||||
|
Checklist options can be generated from entities, using the same pattern as xref:args/input_dropdown.adoc#args-dropdown-entities[entity-backed dropdowns]. Define one choice template and set `entity` to the entity type name:
|
||||||
|
|
||||||
|
[source,yaml]
|
||||||
|
----
|
||||||
|
actions:
|
||||||
|
- title: Restart selected containers
|
||||||
|
shell: 'docker restart {{ containers }}'
|
||||||
|
arguments:
|
||||||
|
- name: containers
|
||||||
|
title: Containers to restart
|
||||||
|
type: checklist
|
||||||
|
entity: container
|
||||||
|
choices:
|
||||||
|
- value: '{{ container.Names }}'
|
||||||
|
title: '{{ container.Names }}'
|
||||||
|
|
||||||
|
entities:
|
||||||
|
- file: entities/containers.json
|
||||||
|
name: container
|
||||||
|
----
|
||||||
|
|
||||||
|
OliveTin expands the template once per entity instance and renders each result as a checkbox. Selected values are still passed as a comma-separated string.
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ import {
|
||||||
waitForExecutionComplete,
|
waitForExecutionComplete,
|
||||||
} from '../../lib/elements.js'
|
} from '../../lib/elements.js'
|
||||||
|
|
||||||
async function openChecklistArgumentForm() {
|
async function openChecklistArgumentForm(actionTitle = 'Test checklist argument') {
|
||||||
await getRootAndWait()
|
await getRootAndWait()
|
||||||
const btn = await getActionButton(webdriver, 'Test checklist argument')
|
const btn = await getActionButton(webdriver, actionTitle)
|
||||||
await btn.click()
|
await btn.click()
|
||||||
|
|
||||||
await waitForArgumentFormPage()
|
await waitForArgumentFormPage()
|
||||||
|
|
@ -52,9 +52,9 @@ async function pollTerminal(matcher, timeoutMs = DEFAULT_UI_WAIT_MS) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForTerminalOutput(expectedValue) {
|
async function waitForTerminalOutput(expectedValue, label = 'Selected segments') {
|
||||||
await pollTerminal(
|
await pollTerminal(
|
||||||
(output) => output.includes(`Selected segments: ${expectedValue}`),
|
(output) => output.includes(`${label}: ${expectedValue}`),
|
||||||
DEFAULT_UI_WAIT_MS
|
DEFAULT_UI_WAIT_MS
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -145,4 +145,24 @@ describe('config: checklist', function () {
|
||||||
await waitForExecutionComplete()
|
await waitForExecutionComplete()
|
||||||
await waitForTerminalOutput('kitchen,bedroom,hallway')
|
await waitForTerminalOutput('kitchen,bedroom,hallway')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Checklist entity argument renders choices from entities', async function () {
|
||||||
|
await openChecklistArgumentForm('Test checklist entity argument')
|
||||||
|
|
||||||
|
const checkboxes = await webdriver.findElements(
|
||||||
|
By.css('.choice-checklist-item input[type="checkbox"]')
|
||||||
|
)
|
||||||
|
expect(checkboxes).to.have.length(2)
|
||||||
|
|
||||||
|
const labels = await webdriver.findElements(By.css('.choice-checklist-item span'))
|
||||||
|
expect(await labels[0].getText()).to.equal('attic')
|
||||||
|
expect(await labels[1].getText()).to.equal('basement')
|
||||||
|
|
||||||
|
await checkboxes[0].click()
|
||||||
|
|
||||||
|
await submitChecklistForm()
|
||||||
|
await waitForLogsPage()
|
||||||
|
await waitForExecutionComplete()
|
||||||
|
await waitForTerminalOutput('attic', 'Selected rooms')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ logLevel: "DEBUG"
|
||||||
checkForUpdates: false
|
checkForUpdates: false
|
||||||
defaultPopupOnStart: execution-dialog
|
defaultPopupOnStart: execution-dialog
|
||||||
|
|
||||||
|
entities:
|
||||||
|
- file: entities/rooms.yaml
|
||||||
|
name: room
|
||||||
|
|
||||||
actions:
|
actions:
|
||||||
- title: Test checklist argument
|
- title: Test checklist argument
|
||||||
shell: "echo 'Selected segments: {{ segments }}'"
|
shell: "echo 'Selected segments: {{ segments }}'"
|
||||||
|
|
@ -22,3 +26,15 @@ actions:
|
||||||
- title: Hallway
|
- title: Hallway
|
||||||
value: hallway
|
value: hallway
|
||||||
default: kitchen,bedroom
|
default: kitchen,bedroom
|
||||||
|
|
||||||
|
- title: Test checklist entity argument
|
||||||
|
shell: "echo 'Selected rooms: {{ rooms }}'"
|
||||||
|
icon: ping
|
||||||
|
arguments:
|
||||||
|
- name: rooms
|
||||||
|
title: Rooms to include
|
||||||
|
type: checklist
|
||||||
|
entity: room
|
||||||
|
choices:
|
||||||
|
- title: '{{ room.hostname }}'
|
||||||
|
value: '{{ room.hostname }}'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
- hostname: attic
|
||||||
|
- hostname: basement
|
||||||
|
|
@ -919,3 +919,23 @@ func TestBuildActionIncludesGroups(t *testing.T) {
|
||||||
assert.Equal(t, "missing", actionResult.Groups[1].Name)
|
assert.Equal(t, "missing", actionResult.Groups[1].Name)
|
||||||
assert.Equal(t, int32(0), actionResult.Groups[1].MaxConcurrent)
|
assert.Equal(t, int32(0), actionResult.Groups[1].MaxConcurrent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildChoicesExpandsChecklistEntityChoices(t *testing.T) {
|
||||||
|
entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
|
||||||
|
entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
|
||||||
|
|
||||||
|
arg := config.ActionArgument{
|
||||||
|
Type: "checklist",
|
||||||
|
Entity: "room",
|
||||||
|
Choices: []config.ActionArgumentChoice{
|
||||||
|
{Title: "{{ room.hostname }}", Value: "{{ room.hostname }}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
choices := buildChoices(arg)
|
||||||
|
require.Len(t, choices, 2)
|
||||||
|
assert.Equal(t, "attic", choices[0].Value)
|
||||||
|
assert.Equal(t, "attic", choices[0].Title)
|
||||||
|
assert.Equal(t, "basement", choices[1].Value)
|
||||||
|
assert.Equal(t, "basement", choices[1].Title)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -498,6 +498,11 @@ func (arg *ActionArgument) sanitizeChecklist() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
arg.warnMissingChecklistChoices()
|
||||||
|
arg.warnInvalidChecklistEntityTemplate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (arg *ActionArgument) warnMissingChecklistChoices() {
|
||||||
if len(arg.Choices) == 0 {
|
if len(arg.Choices) == 0 {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"arg": arg.Name,
|
"arg": arg.Name,
|
||||||
|
|
@ -505,6 +510,17 @@ func (arg *ActionArgument) sanitizeChecklist() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (arg *ActionArgument) warnInvalidChecklistEntityTemplate() {
|
||||||
|
if arg.Entity == "" || len(arg.Choices) == 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"arg": arg.Name,
|
||||||
|
"entity": arg.Entity,
|
||||||
|
}).Warn("Checklist argument with entity should define exactly one choice template")
|
||||||
|
}
|
||||||
|
|
||||||
func (arg *ActionArgument) sanitizeNoType() {
|
func (arg *ActionArgument) sanitizeNoType() {
|
||||||
if len(arg.Choices) == 0 && arg.Type == "" {
|
if len(arg.Choices) == 0 && arg.Type == "" {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
|
|
|
||||||
|
|
@ -505,6 +505,43 @@ func mangleChecklistSegment(arg *config.ActionArgument, segment string, actionTi
|
||||||
}
|
}
|
||||||
|
|
||||||
func mangleChoiceSegment(arg *config.ActionArgument, value string, actionTitle string) string {
|
func mangleChoiceSegment(arg *config.ActionArgument, value string, actionTitle string) string {
|
||||||
|
if mapped, ok := mangleChoiceSegmentEntity(arg, value, actionTitle); ok {
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
return mangleChoiceSegmentStatic(arg, value, actionTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mangleChoiceSegmentEntity(arg *config.ActionArgument, value string, actionTitle string) (string, bool) {
|
||||||
|
if arg.Entity == "" || len(arg.Choices) == 0 {
|
||||||
|
return value, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return mangleEntityTemplateChoiceSegment(arg.Choices[0], arg.Entity, arg.Name, value, actionTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mangleEntityTemplateChoiceSegment(templateChoice config.ActionArgumentChoice, entityName string, argName string, value string, actionTitle string) (string, bool) {
|
||||||
|
for _, ent := range entities.GetEntityInstancesOrdered(entityName) {
|
||||||
|
expandedTitle := tpl.ParseTemplateOfActionBeforeExec(templateChoice.Title, ent)
|
||||||
|
if value != expandedTitle {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedValue := tpl.ParseTemplateOfActionBeforeExec(templateChoice.Value, ent)
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"arg": argName,
|
||||||
|
"oldValue": value,
|
||||||
|
"newValue": expandedValue,
|
||||||
|
"actionTitle": actionTitle,
|
||||||
|
}).Infof("Mangled entity choice segment")
|
||||||
|
|
||||||
|
return expandedValue, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mangleChoiceSegmentStatic(arg *config.ActionArgument, value string, actionTitle string) string {
|
||||||
for _, choice := range arg.Choices {
|
for _, choice := range arg.Choices {
|
||||||
if value == choice.Title {
|
if value == choice.Title {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,55 @@ func TestMangleArgumentValueChecklist(t *testing.T) {
|
||||||
assert.Equal(t, "documents,photos", out)
|
assert.Equal(t, "documents,photos", out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checklistEntityTestArg() config.ActionArgument {
|
||||||
|
return config.ActionArgument{
|
||||||
|
Name: "rooms",
|
||||||
|
Type: "checklist",
|
||||||
|
Entity: "room",
|
||||||
|
Choices: []config.ActionArgumentChoice{
|
||||||
|
{Title: "{{ room.hostname }}", Value: "{{ room.hostname }}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateArgumentChecklistEntitySelections(t *testing.T) {
|
||||||
|
log.SetLevel(log.PanicLevel)
|
||||||
|
|
||||||
|
entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
|
||||||
|
entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
|
||||||
|
|
||||||
|
arg := checklistEntityTestArg()
|
||||||
|
action := config.Action{Title: "Test checklist entity"}
|
||||||
|
|
||||||
|
err := ValidateArgument(&arg, "attic", &action)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
err = ValidateArgument(&arg, "attic,basement", &action)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
err = ValidateArgument(&arg, "attic,unknown", &action)
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMangleArgumentValueChecklistEntityTitles(t *testing.T) {
|
||||||
|
log.SetLevel(log.PanicLevel)
|
||||||
|
|
||||||
|
entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
|
||||||
|
entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
|
||||||
|
|
||||||
|
arg := config.ActionArgument{
|
||||||
|
Name: "rooms",
|
||||||
|
Type: "checklist",
|
||||||
|
Entity: "room",
|
||||||
|
Choices: []config.ActionArgumentChoice{
|
||||||
|
{Title: "{{ room.hostname }} room", Value: "{{ room.hostname }}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out := MangleArgumentValue(&arg, "attic room,basement room", "Test checklist entity titles")
|
||||||
|
assert.Equal(t, "attic,basement", out)
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseActionArgumentsChecklistEmptySelection(t *testing.T) {
|
func TestParseActionArgumentsChecklistEmptySelection(t *testing.T) {
|
||||||
req := newExecRequest()
|
req := newExecRequest()
|
||||||
req.Binding.Action = &config.Action{
|
req.Binding.Action = &config.Action{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue