fix: add support for entities in checklists

This commit is contained in:
jamesread 2026-07-06 12:17:03 +01:00
parent 6077c63cfd
commit b18518ebea
8 changed files with 189 additions and 4 deletions

View File

@ -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.

View File

@ -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')
})
})

View File

@ -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 }}'

View File

@ -0,0 +1,2 @@
- hostname: attic
- hostname: basement

View File

@ -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)
}

View File

@ -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{

View File

@ -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{

View File

@ -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{