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.
|
||||
|
||||
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,
|
||||
} from '../../lib/elements.js'
|
||||
|
||||
async function openChecklistArgumentForm() {
|
||||
async function openChecklistArgumentForm(actionTitle = 'Test checklist argument') {
|
||||
await getRootAndWait()
|
||||
const btn = await getActionButton(webdriver, 'Test checklist argument')
|
||||
const btn = await getActionButton(webdriver, actionTitle)
|
||||
await btn.click()
|
||||
|
||||
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(
|
||||
(output) => output.includes(`Selected segments: ${expectedValue}`),
|
||||
(output) => output.includes(`${label}: ${expectedValue}`),
|
||||
DEFAULT_UI_WAIT_MS
|
||||
)
|
||||
}
|
||||
|
|
@ -145,4 +145,24 @@ describe('config: checklist', function () {
|
|||
await waitForExecutionComplete()
|
||||
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
|
||||
defaultPopupOnStart: execution-dialog
|
||||
|
||||
entities:
|
||||
- file: entities/rooms.yaml
|
||||
name: room
|
||||
|
||||
actions:
|
||||
- title: Test checklist argument
|
||||
shell: "echo 'Selected segments: {{ segments }}'"
|
||||
|
|
@ -22,3 +26,15 @@ actions:
|
|||
- title: Hallway
|
||||
value: hallway
|
||||
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, 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
|
||||
}
|
||||
|
||||
arg.warnMissingChecklistChoices()
|
||||
arg.warnInvalidChecklistEntityTemplate()
|
||||
}
|
||||
|
||||
func (arg *ActionArgument) warnMissingChecklistChoices() {
|
||||
if len(arg.Choices) == 0 {
|
||||
log.WithFields(log.Fields{
|
||||
"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() {
|
||||
if len(arg.Choices) == 0 && arg.Type == "" {
|
||||
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 {
|
||||
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 {
|
||||
if value == choice.Title {
|
||||
log.WithFields(log.Fields{
|
||||
|
|
|
|||
|
|
@ -202,6 +202,55 @@ func TestMangleArgumentValueChecklist(t *testing.T) {
|
|||
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) {
|
||||
req := newExecRequest()
|
||||
req.Binding.Action = &config.Action{
|
||||
|
|
|
|||
Loading…
Reference in New Issue