From b18518ebea48a4bcce861046331375bfff6d115b Mon Sep 17 00:00:00 2001 From: jamesread Date: Mon, 6 Jul 2026 12:17:03 +0100 Subject: [PATCH] fix: add support for entities in checklists --- .../ROOT/pages/args/input_checklist.adoc | 25 ++++++++++ .../tests/checklist/checklist.mjs | 28 +++++++++-- integration-tests/tests/checklist/config.yaml | 16 ++++++ .../tests/checklist/entities/rooms.yaml | 2 + service/internal/api/api_test.go | 20 ++++++++ service/internal/config/sanitize.go | 16 ++++++ service/internal/executor/arguments.go | 37 ++++++++++++++ service/internal/executor/arguments_test.go | 49 +++++++++++++++++++ 8 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 integration-tests/tests/checklist/entities/rooms.yaml diff --git a/docs/modules/ROOT/pages/args/input_checklist.adoc b/docs/modules/ROOT/pages/args/input_checklist.adoc index b432e01..229814e 100644 --- a/docs/modules/ROOT/pages/args/input_checklist.adoc +++ b/docs/modules/ROOT/pages/args/input_checklist.adoc @@ -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. diff --git a/integration-tests/tests/checklist/checklist.mjs b/integration-tests/tests/checklist/checklist.mjs index f888466..bbe77dc 100644 --- a/integration-tests/tests/checklist/checklist.mjs +++ b/integration-tests/tests/checklist/checklist.mjs @@ -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') + }) }) diff --git a/integration-tests/tests/checklist/config.yaml b/integration-tests/tests/checklist/config.yaml index b990056..9042b99 100644 --- a/integration-tests/tests/checklist/config.yaml +++ b/integration-tests/tests/checklist/config.yaml @@ -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 }}' diff --git a/integration-tests/tests/checklist/entities/rooms.yaml b/integration-tests/tests/checklist/entities/rooms.yaml new file mode 100644 index 0000000..3e197a1 --- /dev/null +++ b/integration-tests/tests/checklist/entities/rooms.yaml @@ -0,0 +1,2 @@ +- hostname: attic +- hostname: basement diff --git a/service/internal/api/api_test.go b/service/internal/api/api_test.go index 90f8ed1..1291f80 100644 --- a/service/internal/api/api_test.go +++ b/service/internal/api/api_test.go @@ -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) +} diff --git a/service/internal/config/sanitize.go b/service/internal/config/sanitize.go index ecca619..2ee2313 100644 --- a/service/internal/config/sanitize.go +++ b/service/internal/config/sanitize.go @@ -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{ diff --git a/service/internal/executor/arguments.go b/service/internal/executor/arguments.go index 180dc47..51342df 100644 --- a/service/internal/executor/arguments.go +++ b/service/internal/executor/arguments.go @@ -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{ diff --git a/service/internal/executor/arguments_test.go b/service/internal/executor/arguments_test.go index 09ebc7c..c04cfd7 100644 --- a/service/internal/executor/arguments_test.go +++ b/service/internal/executor/arguments_test.go @@ -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{