Parcourir la source

feat: Checklist support (#922) (#1066)

James Read il y a 6 heures
Parent
commit
a856d46324

+ 23 - 0
config.yaml

@@ -143,6 +143,29 @@ actions:
       - type: confirmation
         title: Are you sure?!
 
+  # Checklist arguments let users pick multiple predefined options. Selected
+  # values are passed to the action as a comma-separated string.
+  #
+  # Docs: https://docs.olivetin.app/args/input_checklist.html
+  - title: Backup selected directories
+    icon: backup
+    shell: 'echo "Backing up: {{ directories }}"'
+    arguments:
+      - name: directories
+        title: Directories to back up
+        type: checklist
+        description: Select one or more directories to include in the backup.
+        choices:
+          - title: Documents
+            value: documents
+          - title: Photos
+            value: photos
+          - title: Music
+            value: music
+          - title: Videos
+            value: videos
+        default: documents,photos
+
   # This is an action that runs a script included with OliveTin, that will
   # download themes. You will still need to set theme "themeName" in your config.
   #

+ 1 - 0
docs/modules/ROOT/nav.adoc

@@ -73,6 +73,7 @@
 ** xref:args/regex.adoc[Input: Regex]
 ** xref:args/password.adoc[Input: Password]
 ** xref:args/input_checkbox.adoc[Input: Checkbox/Boolean]
+** xref:args/input_checklist.adoc[Input: Checklist]
 ** xref:args/input_dropdown.adoc[Input: Dropdown]
 ** xref:args/input_datetime.adoc[Input: Date & Time]
 ** xref:args/input_confirmation.adoc[Input: Confirmation]

+ 80 - 0
docs/modules/ROOT/pages/args/input_checklist.adoc

@@ -0,0 +1,80 @@
+[#checklist]
+= Input: Checklist
+
+The `checklist` type argument renders multiple checkboxes from predefined `choices`. Users can select one or more options, and the selected values are passed to your action as a comma-separated string.
+
+[source,yaml]
+----
+actions:
+  - title: Backup selected directories
+    shell: echo "Backing up: {{ directories }}"
+    arguments:
+      - name: directories
+        title: Directories to back up
+        type: checklist
+        choices:
+          - title: Documents
+            value: documents
+          - title: Photos
+            value: photos
+          - title: Music
+            value: music
+        default: documents,photos
+----
+
+When the example above runs with Documents and Photos selected, the shell command becomes:
+
+[source,shell]
+----
+echo "Backing up: documents,photos"
+----
+
+== Select all / Select none
+
+The web interface includes **Select all** and **Select none** controls above the checkbox list.
+
+== Empty selections
+
+If no options are selected, the argument value is an empty string. Use `rejectNull: true` when at least one selection is required.
+
+[source,yaml]
+----
+arguments:
+  - name: directories
+    type: checklist
+    rejectNull: true
+    choices:
+      - value: documents
+      - value: photos
+----
+
+== Choice values
+
+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.

+ 3 - 3
docs/modules/ROOT/pages/args/types.adoc

@@ -9,9 +9,9 @@ A full list of argument types are below;
 | Type                        | Rendered as                       | Allowed values
 | (default)                   | xref:args/input.adoc[Textbox]           | If a `type:` is not set, and `choices:` is empty, then ascii will be used, and a warning will be logged. It is recommended that you set the type explicitly, rather than relying on defaults.
 | ascii                       | xref:args/input.adoc[Textbox]           | a-z (case insensitive), 0-9, but no spaces or punctuation
-| ascii_identifier            | xref:args/input.adoc[Textbox]           | Like a DNS name, a-Z (case insensitive), 0-9, `-`, `.`, and `_`. 
+| ascii_identifier            | xref:args/input.adoc[Textbox]           | Like a DNS name, a-Z (case insensitive), 0-9, `-`, `.`, and `_`.
 | shell_safe_identifier       | xref:args/input.adoc[Textbox]           | Like an ascii identifier, but also allows `@` and `+`. Useful for shell-safe usernames and email-style identifiers.
-| ascii_sentence              | xref:args/input.adoc[Textbox]           | a-z (case insensitive), 0-9, with spaces, `.` and `,`. 
+| ascii_sentence              | xref:args/input.adoc[Textbox]           | a-z (case insensitive), 0-9, with spaces, `.` and `,`.
 | unicode_identifier          | xref:args/input.adoc[Textbox]           | Like an ascii identifier, but allows unicode characters. This is useful for languages that use non-ascii characters, such as Chinese, Japanese, etc.
 | email                       | xref:args/input.adoc[Textbox]           | An email address.
 | password                    | xref:args/password.adoc[Password]       | A password, which is hidden when typed.
@@ -20,6 +20,7 @@ A full list of argument types are below;
 | int                         | xref:args/input.adoc[Textbox]           | Any number, made up of the characters 0 to 9. Negative numbers are not supported.
 | url                         | xref:args/input.adoc[Textbox]           | A URL (e.g. https://example.com). Accepts any scheme, including `file://` and `ftp://`. See warning below.
 | confirmation                | xref:args/input_confirmation.adoc[Confirmation] | A "hidden" argument that makes the action require a confirmation before launching.
+| checklist                   | xref:args/input_checklist.adoc[Checklist]     | Multiple checkboxes from predefined choices. Selected values are passed as a comma-separated string.
 | n/a, but `choices` used     | xref:args/input_dropdown.adoc[Dropdown]         | A "hidden" argument that makes the action require a confirmation before launching.
 | raw_string_multiline        | xref:args/input_textarea.adoc[Textarea]         | Anything. This is **dangerous**, as effectively people can type anything they like
 |===
@@ -31,4 +32,3 @@ The `url` argument type does not restrict the URL scheme. Users can enter `file:
 
 If your action might be used by untrusted users, validate or filter the URL in your script (e.g. allow only `https://`) before using the value.
 ====
-

+ 156 - 0
frontend/resources/vue/components/ChoiceChecklist.vue

@@ -0,0 +1,156 @@
+<template>
+  <div class="choice-checklist" :id="`${id}-wrapper`">
+    <div class="choice-checklist-controls">
+      <button type="button" class="choice-checklist-control" @click="selectAll">
+        Select all
+      </button>
+      <button type="button" class="choice-checklist-control" @click="selectNone">
+        Select none
+      </button>
+    </div>
+    <fieldset class="choice-checklist-fieldset">
+      <legend class="visually-hidden">{{ label || name }}</legend>
+      <label
+        v-for="(choice, index) in choices"
+        :key="choice.value"
+        class="choice-checklist-item"
+        :for="`${id}-${index}`"
+      >
+        <input
+          :id="`${id}-${index}`"
+          type="checkbox"
+          :checked="isSelected(choice.value)"
+          @change="handleToggle(choice.value)"
+        />
+        <span>{{ choiceLabel(choice) }}</span>
+      </label>
+    </fieldset>
+    <input
+      :id="`${id}-value`"
+      :name="name"
+      type="text"
+      class="visually-hidden choice-checklist-value"
+      :value="modelValue"
+      :required="required"
+      tabindex="-1"
+      aria-hidden="true"
+    />
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import {
+  allChoiceValues,
+  choiceLabel,
+  formatChecklistValue,
+  parseChecklistValue,
+  toggleChoice
+} from '../utils/choiceChecklistHelpers.js'
+
+const props = defineProps({
+  id: {
+    type: String,
+    required: true
+  },
+  name: {
+    type: String,
+    required: true
+  },
+  label: {
+    type: String,
+    default: ''
+  },
+  choices: {
+    type: Array,
+    required: true
+  },
+  modelValue: {
+    type: String,
+    default: ''
+  },
+  required: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const emit = defineEmits(['update:modelValue'])
+
+const selectedValues = computed(() => parseChecklistValue(props.modelValue))
+
+function isSelected(value) {
+  return selectedValues.value.includes(value)
+}
+
+function emitSelection(selected) {
+  emit('update:modelValue', formatChecklistValue(selected))
+}
+
+function handleToggle(value) {
+  emitSelection(toggleChoice(selectedValues.value, value))
+}
+
+function selectAll() {
+  emitSelection(allChoiceValues(props.choices))
+}
+
+function selectNone() {
+  emitSelection([])
+}
+</script>
+
+<style scoped>
+.choice-checklist {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5em;
+}
+
+.choice-checklist-controls {
+  display: flex;
+  gap: 0.75em;
+}
+
+.choice-checklist-control {
+  background: none;
+  border: none;
+  color: inherit;
+  cursor: pointer;
+  font: inherit;
+  padding: 0;
+  text-decoration: underline;
+}
+
+.choice-checklist-fieldset {
+  border: none;
+  display: grid;
+  gap: 0.5em 1em;
+  grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
+  margin: 0;
+  padding: 0;
+}
+
+.choice-checklist-item {
+  align-items: center;
+  display: flex;
+  gap: 0.4em;
+  margin: 0;
+}
+
+.choice-checklist-item input[type="checkbox"] {
+  margin: 0;
+}
+
+.visually-hidden {
+  border: 0;
+  clip: rect(0 0 0 0);
+  height: 1px;
+  margin: -1px;
+  overflow: hidden;
+  padding: 0;
+  position: absolute;
+  white-space: nowrap;
+  width: 1px;
+}
+</style>

+ 36 - 0
frontend/resources/vue/utils/choiceChecklistHelpers.js

@@ -0,0 +1,36 @@
+export function parseChecklistValue(value) {
+  if (!value || value === '') {
+    return []
+  }
+
+  return value.split(',').map((segment) => segment.trim()).filter((segment) => segment !== '')
+}
+
+export function formatChecklistValue(selected) {
+  if (!Array.isArray(selected) || selected.length === 0) {
+    return ''
+  }
+
+  return selected.join(',')
+}
+
+export function toggleChoice(selected, value) {
+  const current = Array.isArray(selected) ? [...selected] : []
+  const index = current.indexOf(value)
+
+  if (index === -1) {
+    current.push(value)
+    return current
+  }
+
+  current.splice(index, 1)
+  return current
+}
+
+export function choiceLabel(choice) {
+  return choice.title || choice.value || ''
+}
+
+export function allChoiceValues(choices) {
+  return choices.map((choice) => choice.value)
+}

+ 40 - 0
frontend/resources/vue/utils/choiceChecklistHelpers.test.mjs

@@ -0,0 +1,40 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import {
+  allChoiceValues,
+  choiceLabel,
+  formatChecklistValue,
+  parseChecklistValue,
+  toggleChoice
+} from './choiceChecklistHelpers.js'
+
+const choices = [
+  { title: 'Documents', value: 'documents' },
+  { title: 'Photos', value: 'photos' }
+]
+
+test('parseChecklistValue splits comma-delimited values', () => {
+  assert.deepEqual(parseChecklistValue('documents,photos'), ['documents', 'photos'])
+  assert.deepEqual(parseChecklistValue('documents, photos'), ['documents', 'photos'])
+  assert.deepEqual(parseChecklistValue(''), [])
+})
+
+test('formatChecklistValue joins selected values', () => {
+  assert.equal(formatChecklistValue(['documents', 'photos']), 'documents,photos')
+  assert.equal(formatChecklistValue([]), '')
+})
+
+test('toggleChoice adds and removes values', () => {
+  assert.deepEqual(toggleChoice([], 'documents'), ['documents'])
+  assert.deepEqual(toggleChoice(['documents'], 'photos'), ['documents', 'photos'])
+  assert.deepEqual(toggleChoice(['documents', 'photos'], 'documents'), ['photos'])
+})
+
+test('choiceLabel prefers title over value', () => {
+  assert.equal(choiceLabel(choices[0]), 'Documents')
+  assert.equal(choiceLabel({ value: 'music' }), 'music')
+})
+
+test('allChoiceValues returns every choice value', () => {
+  assert.deepEqual(allChoiceValues(choices), ['documents', 'photos'])
+})

+ 25 - 9
frontend/resources/vue/views/ArgumentForm.vue

@@ -8,7 +8,7 @@
         <template v-if="actionArguments.length > 0">
 
           <template v-for="arg in actionArguments" :key="arg.name">
-              <label :for="arg.name">
+              <label :for="arg.type === 'checklist' ? undefined : arg.name">
                 {{ formatLabel(arg.title) }}
               </label>
 
@@ -25,6 +25,10 @@
                 :choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
                 @update:model-value="handleChoiceUpdate(arg, $event)" />
 
+              <ChoiceChecklist v-else-if="arg.type === 'checklist'" :id="arg.name" :name="arg.name"
+                :label="arg.title" :choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
+                @update:model-value="handleChoiceUpdate(arg, $event)" />
+
               <component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name"
                 :value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
                 :checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
@@ -65,6 +69,7 @@ import { ref, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue'
 import { useRouter } from 'vue-router'
 import { requestReconnectNow } from '../../../js/websocket.js'
 import ChoiceCombobox from '../components/ChoiceCombobox.vue'
+import ChoiceChecklist from '../components/ChoiceChecklist.vue'
 
 const router = useRouter()
 
@@ -238,6 +243,14 @@ function handleChange(arg, event) {
   validateArgument(arg, event.target.value)
 }
 
+function getValidationElement(arg) {
+  if (arg.type === 'checklist') {
+    return document.getElementById(`${arg.name}-value`)
+  }
+
+  return document.getElementById(arg.name)
+}
+
 function handleChoiceUpdate(arg, value) {
   argValues.value[arg.name] = value
   updateUrlWithArg(arg.name, value)
@@ -251,7 +264,7 @@ async function validateArgument(arg, value) {
 
   // Skip validation for datetime - backend will handle mangling values without seconds
   if (arg.type === 'datetime') {
-    const inputElement = document.getElementById(arg.name)
+    const inputElement = getValidationElement(arg)
     if (inputElement) {
       inputElement.setCustomValidity('')
     }
@@ -261,7 +274,7 @@ async function validateArgument(arg, value) {
 
   // Skip validation for checkbox and confirmation - they're always valid
   if (arg.type === 'checkbox' || arg.type === 'confirmation') {
-    const inputElement = document.getElementById(arg.name)
+    const inputElement = getValidationElement(arg)
     if (inputElement) {
       inputElement.setCustomValidity('')
     }
@@ -279,8 +292,7 @@ async function validateArgument(arg, value) {
 
     const validation = await window.client.validateArgumentType(validateArgumentTypeArgs)
 
-    // Get the input element to set custom validity
-    const inputElement = document.getElementById(arg.name)
+    const inputElement = getValidationElement(arg)
 
     if (validation.valid) {
       delete formErrors.value[arg.name]
@@ -297,8 +309,7 @@ async function validateArgument(arg, value) {
     }
   } catch (err) {
     console.warn('Validation failed:', err)
-    // On error, clear any custom validity
-    const inputElement = document.getElementById(arg.name)
+    const inputElement = getValidationElement(arg)
     if (inputElement) {
       inputElement.setCustomValidity('')
     }
@@ -393,7 +404,7 @@ function saveBrowserSuggestions() {
       const value = argValues.value[arg.name]
 
       // Only save non-empty values for non-checkbox/confirmation/password types
-      if (value && value !== '' && arg.type !== 'checkbox' && arg.type !== 'confirmation' && arg.type !== 'password') {
+      if (value && value !== '' && arg.type !== 'checkbox' && arg.type !== 'confirmation' && arg.type !== 'checklist' && arg.type !== 'password') {
         try {
           const key = `olivetin-suggestions-${arg.suggestionsBrowserKey}`
           const stored = localStorage.getItem(key)
@@ -467,7 +478,7 @@ async function handleSubmit(event) {
 
   for (const arg of actionArguments.value) {
     const value = argValues.value[arg.name]
-    const inputElement = document.getElementById(arg.name)
+    const inputElement = getValidationElement(arg)
 
     if (arg.required && (!value || value === '')) {
       formErrors.value[arg.name] = 'This field is required'
@@ -484,6 +495,11 @@ async function handleSubmit(event) {
     return
   }
 
+  if (Object.keys(formErrors.value).length > 0) {
+    console.log('argument form has validation errors')
+    return
+  }
+
   const argvs = getArgumentValues()
   console.log('argument form has elements that passed validation')
 

+ 168 - 0
integration-tests/tests/checklist/checklist.mjs

@@ -0,0 +1,168 @@
+import { describe, it, before, after } from 'mocha'
+import { expect } from 'chai'
+import { By, Condition } from 'selenium-webdriver'
+import {
+  DEFAULT_UI_WAIT_MS,
+  getRootAndWait,
+  getActionButton,
+  takeScreenshotOnFailure,
+  getTerminalBuffer,
+  waitForArgumentFormPage,
+  waitForArgumentFormReady,
+  waitForLogsPage,
+  waitForExecutionComplete,
+} from '../../lib/elements.js'
+
+async function openChecklistArgumentForm(actionTitle = 'Test checklist argument') {
+  await getRootAndWait()
+  const btn = await getActionButton(webdriver, actionTitle)
+  await btn.click()
+
+  await waitForArgumentFormPage()
+  await waitForArgumentFormReady()
+}
+
+async function submitChecklistForm() {
+  const submitButton = await webdriver.findElement(By.css('button[name="start"]'))
+  await submitButton.click()
+}
+
+async function pollTerminal(matcher, timeoutMs = DEFAULT_UI_WAIT_MS) {
+  await webdriver.wait(
+    new Condition('wait for terminal output', async () => {
+      try {
+        const terminalReady = await webdriver.executeScript(`
+          return !!(window.terminal && window.terminal.getBufferAsString);
+        `)
+        if (!terminalReady) {
+          return false
+        }
+
+        const output = await getTerminalBuffer()
+        if (!output) {
+          return false
+        }
+
+        return matcher(output.trim())
+      } catch (e) {
+        return false
+      }
+    }),
+    timeoutMs
+  )
+}
+
+async function waitForTerminalOutput(expectedValue, label = 'Selected segments') {
+  await pollTerminal(
+    (output) => output.includes(`${label}: ${expectedValue}`),
+    DEFAULT_UI_WAIT_MS
+  )
+}
+
+async function waitForTerminalOutputPattern(pattern) {
+  await pollTerminal(
+    (output) => pattern.test(output),
+    10000
+  )
+}
+
+async function getCheckboxByValueIndex(index) {
+  const checkboxes = await webdriver.findElements(
+    By.css('.choice-checklist-item input[type="checkbox"]')
+  )
+  return checkboxes[index]
+}
+
+describe('config: checklist', function () {
+  this.timeout(10000)
+
+  before(async function () {
+    await runner.start('checklist')
+  })
+
+  after(async () => {
+    await runner.stop()
+  })
+
+  afterEach(function () {
+    takeScreenshotOnFailure(this.currentTest, webdriver)
+  })
+
+  it('Checklist argument renders multiple checkbox inputs', async function () {
+    await openChecklistArgumentForm()
+
+    const kitchen = await getCheckboxByValueIndex(0)
+    const bedroom = await getCheckboxByValueIndex(1)
+    const hallway = await getCheckboxByValueIndex(2)
+
+    expect(await kitchen.getAttribute('type')).to.equal('checkbox')
+    expect(await bedroom.getAttribute('type')).to.equal('checkbox')
+    expect(await hallway.getAttribute('type')).to.equal('checkbox')
+    expect(await kitchen.isSelected()).to.be.true
+    expect(await bedroom.isSelected()).to.be.true
+    expect(await hallway.isSelected()).to.be.false
+  })
+
+  it('Checklist select none submits an empty value', async function () {
+    await openChecklistArgumentForm()
+
+    const selectNone = await webdriver.findElement(By.xpath("//button[normalize-space()='Select none']"))
+    await selectNone.click()
+    await webdriver.sleep(300)
+
+    const valueInput = await webdriver.findElement(By.css('.choice-checklist > input'))
+    expect(await valueInput.getAttribute('value')).to.equal('')
+
+    await submitChecklistForm()
+    await waitForLogsPage()
+    await waitForExecutionComplete()
+    await waitForTerminalOutputPattern(/Selected segments:\s*(\r?\n|$)/)
+  })
+
+  it('Checklist select all submits every choice value', async function () {
+    await openChecklistArgumentForm()
+
+    const selectNone = await webdriver.findElement(By.xpath("//button[normalize-space()='Select none']"))
+    await selectNone.click()
+
+    const selectAll = await webdriver.findElement(By.xpath("//button[normalize-space()='Select all']"))
+    await selectAll.click()
+
+    await submitChecklistForm()
+    await waitForLogsPage()
+    await waitForExecutionComplete()
+    await waitForTerminalOutput('kitchen,bedroom,hallway')
+  })
+
+  it('Checklist toggles individual choices before submit', async function () {
+    await openChecklistArgumentForm()
+
+    const hallway = await getCheckboxByValueIndex(2)
+    await hallway.click()
+
+    await submitChecklistForm()
+    await waitForLogsPage()
+    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')
+  })
+})

+ 40 - 0
integration-tests/tests/checklist/config.yaml

@@ -0,0 +1,40 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:1337
+
+logLevel: "DEBUG"
+checkForUpdates: false
+defaultPopupOnStart: execution-dialog
+
+entities:
+  - file: entities/rooms.yaml
+    name: room
+
+actions:
+  - title: Test checklist argument
+    shell: "echo 'Selected segments: {{ segments }}'"
+    icon: ping
+    arguments:
+      - name: segments
+        title: Rooms to clean
+        type: checklist
+        description: Select the rooms to include in the vacuum run.
+        choices:
+          - title: Kitchen
+            value: kitchen
+          - title: Bedroom
+            value: bedroom
+          - 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 }}'

+ 2 - 0
integration-tests/tests/checklist/entities/rooms.yaml

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

+ 20 - 0
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)
+}

+ 76 - 0
service/internal/config/sanitize.go

@@ -34,6 +34,10 @@ func (cfg *Config) Sanitize() {
 	if err := cfg.validateReservedActionArgumentNames(); err != nil {
 		log.Fatalf("%v", err)
 	}
+
+	if err := cfg.validateChecklistChoiceValues(); err != nil {
+		log.Fatalf("%v", err)
+	}
 }
 
 func (cfg *Config) validateReservedActionArgumentNames() error {
@@ -60,6 +64,49 @@ func (action *Action) validateReservedArgumentNames() error {
 	return nil
 }
 
+func (cfg *Config) validateChecklistChoiceValues() error {
+	for _, action := range cfg.Actions {
+		if err := action.validateChecklistChoiceValues(); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (action *Action) validateChecklistChoiceValues() error {
+	if action == nil {
+		return nil
+	}
+
+	for _, arg := range action.Arguments {
+		if err := validateChecklistChoicesForArgument(action.Title, arg); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func validateChecklistChoicesForArgument(actionTitle string, arg ActionArgument) error {
+	if arg.Type != "checklist" {
+		return nil
+	}
+
+	for _, choice := range arg.Choices {
+		if strings.Contains(choice.Value, ",") {
+			return fmt.Errorf(
+				`action %q argument %q choice value %q must not contain commas`,
+				actionTitle,
+				arg.Name,
+				choice.Value,
+			)
+		}
+	}
+
+	return nil
+}
+
 func (cfg *Config) sanitizeDashboardsForInlineActions() {
 	for _, dashboard := range cfg.Dashboards {
 		cfg.sanitizeDashboardComponentForInlineActions(dashboard)
@@ -441,10 +488,39 @@ func (arg *ActionArgument) sanitize() {
 	}
 
 	arg.sanitizeNoType()
+	arg.sanitizeChecklist()
 
 	// Default value validation runs in executor at config load (validateArgumentDefaults).
 }
 
+func (arg *ActionArgument) sanitizeChecklist() {
+	if arg.Type != "checklist" {
+		return
+	}
+
+	arg.warnMissingChecklistChoices()
+	arg.warnInvalidChecklistEntityTemplate()
+}
+
+func (arg *ActionArgument) warnMissingChecklistChoices() {
+	if len(arg.Choices) == 0 {
+		log.WithFields(log.Fields{
+			"arg": arg.Name,
+		}).Warn("Checklist argument has no choices defined")
+	}
+}
+
+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{

+ 23 - 0
service/internal/config/sanitize_test.go

@@ -271,3 +271,26 @@ func TestValidateUniqueLocalUserAPIKeys(t *testing.T) {
 	})
 	require.NoError(t, err)
 }
+
+func TestValidateChecklistChoiceValuesRejectsCommas(t *testing.T) {
+	t.Parallel()
+
+	c := DefaultConfig()
+	c.Actions = append(c.Actions, &Action{
+		Title: "Checklist commas",
+		Shell: "true",
+		Arguments: []ActionArgument{
+			{
+				Name: "segments",
+				Type: "checklist",
+				Choices: []ActionArgumentChoice{
+					{Value: "kitchen,bedroom"},
+				},
+			},
+		},
+	})
+
+	err := c.validateChecklistChoiceValues()
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), `choice value "kitchen,bedroom" must not contain commas`)
+}

+ 119 - 7
service/internal/executor/arguments.go

@@ -191,6 +191,10 @@ func typecheckActionArgumentFound(value string, arg *config.ActionArgument) erro
 		return typecheckNull(arg)
 	}
 
+	if arg.Type == "checklist" {
+		return typecheckChecklist(value, arg)
+	}
+
 	if len(arg.Choices) > 0 {
 		return typecheckChoice(value, arg)
 	}
@@ -211,6 +215,8 @@ func TypeSafetyCheck(name string, value string, argumentType string) error {
 		return nil
 	case "checkbox":
 		return nil
+	case "checklist":
+		return nil
 	case "email":
 		return typeSafetyCheckEmail(value)
 	case "url":
@@ -230,6 +236,28 @@ func typecheckNull(arg *config.ActionArgument) error {
 	return nil
 }
 
+func typecheckChecklist(value string, arg *config.ActionArgument) error {
+	if len(arg.Choices) == 0 {
+		return fmt.Errorf("checklist argument %q requires choices", arg.Name)
+	}
+
+	for _, segment := range strings.Split(value, ",") {
+		if err := typecheckChecklistSegment(strings.TrimSpace(segment), arg); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func typecheckChecklistSegment(segment string, arg *config.ActionArgument) error {
+	if segment == "" {
+		return fmt.Errorf("checklist argument %q contains an empty segment", arg.Name)
+	}
+
+	return typecheckChoice(segment, arg)
+}
+
 func typecheckChoice(value string, arg *config.ActionArgument) error {
 	if arg.Entity != "" {
 		return typecheckChoiceEntity(value, arg)
@@ -333,6 +361,7 @@ func mangleInvalidArgumentValues(req *ExecutionRequest) {
 		}
 
 		mangleCheckboxValues(req, &arg)
+		mangleChecklistValues(req, &arg)
 	}
 }
 
@@ -389,15 +418,20 @@ func MangleArgumentValue(arg *config.ActionArgument, value string, actionTitle s
 		return value
 	}
 
-	if arg.Type == "datetime" {
-		return mangleDatetimeValue(arg, value, actionTitle)
-	}
+	return mangleArgumentValueByType(arg, value, actionTitle)
+}
 
-	if arg.Type == "checkbox" {
+func mangleArgumentValueByType(arg *config.ActionArgument, value string, actionTitle string) string {
+	switch arg.Type {
+	case "datetime":
+		return mangleDatetimeValue(arg, value, actionTitle)
+	case "checkbox":
 		return mangleCheckboxValue(arg, value, actionTitle)
+	case "checklist":
+		return mangleChecklistValue(arg, value, actionTitle)
+	default:
+		return value
 	}
-
-	return value
 }
 
 func mangleDatetimeValue(arg *config.ActionArgument, value string, actionTitle string) string {
@@ -430,6 +464,84 @@ func mangleCheckboxValue(arg *config.ActionArgument, value string, actionTitle s
 		return value
 	}
 
+	return mangleChoiceSegment(arg, value, actionTitle)
+}
+
+func mangleChecklistValues(req *ExecutionRequest, arg *config.ActionArgument) {
+	if arg.Type != "checklist" {
+		return
+	}
+
+	value, exists := req.Arguments[arg.Name]
+	if !exists || value == "" {
+		return
+	}
+
+	req.Arguments[arg.Name] = mangleChecklistValue(arg, value, req.Binding.Action.Title)
+}
+
+func mangleChecklistValue(arg *config.ActionArgument, value string, actionTitle string) string {
+	if arg == nil || value == "" {
+		return value
+	}
+
+	segments := strings.Split(value, ",")
+	mangled := make([]string, len(segments))
+
+	for i, segment := range segments {
+		mangled[i] = mangleChecklistSegment(arg, segment, actionTitle)
+	}
+
+	return strings.Join(mangled, ",")
+}
+
+func mangleChecklistSegment(arg *config.ActionArgument, segment string, actionTitle string) string {
+	trimmed := strings.TrimSpace(segment)
+	if trimmed == "" {
+		return ""
+	}
+
+	return mangleChoiceSegment(arg, trimmed, actionTitle)
+}
+
+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{
@@ -437,7 +549,7 @@ func mangleCheckboxValue(arg *config.ActionArgument, value string, actionTitle s
 				"oldValue":    value,
 				"newValue":    choice.Value,
 				"actionTitle": actionTitle,
-			}).Infof("Mangled checkbox value")
+			}).Infof("Mangled choice segment")
 
 			return choice.Value
 		}

+ 163 - 0
service/internal/executor/arguments_test.go

@@ -115,6 +115,169 @@ func TestValidateArgumentCheckboxWithChoices(t *testing.T) {
 	assert.NotNil(t, err, "Expected unknown checkbox title to be rejected against choices")
 }
 
+func checklistTestArg() config.ActionArgument {
+	return config.ActionArgument{
+		Name: "directories",
+		Type: "checklist",
+		Choices: []config.ActionArgumentChoice{
+			{Title: "Documents", Value: "documents"},
+			{Title: "Photos", Value: "photos"},
+			{Title: "Music", Value: "music"},
+		},
+	}
+}
+
+func TestValidateArgumentChecklistSelections(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	arg := checklistTestArg()
+	action := config.Action{Title: "Test checklist"}
+
+	err := ValidateArgument(&arg, "documents", &action)
+	assert.Nil(t, err)
+
+	err = ValidateArgument(&arg, "documents,photos", &action)
+	assert.Nil(t, err)
+
+	err = ValidateArgument(&arg, "documents,unknown", &action)
+	assert.NotNil(t, err)
+}
+
+func TestValidateArgumentChecklistTitleMangling(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	arg := checklistTestArg()
+	action := config.Action{Title: "Test checklist title mangling"}
+
+	err := ValidateArgument(&arg, "Documents,Photos", &action)
+	assert.Nil(t, err)
+}
+
+func TestValidateArgumentChecklistEmptySelection(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	arg := checklistTestArg()
+	action := config.Action{Title: "Test checklist empty"}
+
+	err := ValidateArgument(&arg, "", &action)
+	assert.Nil(t, err)
+
+	arg.RejectNull = true
+	err = ValidateArgument(&arg, "", &action)
+	assert.NotNil(t, err)
+}
+
+func TestValidateArgumentChecklistWithoutChoices(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	arg := config.ActionArgument{
+		Name: "directories",
+		Type: "checklist",
+	}
+	action := config.Action{Title: "Test checklist without choices"}
+
+	err := ValidateArgument(&arg, "documents", &action)
+	assert.NotNil(t, err)
+}
+
+func TestValidateArgumentChecklistRejectsEmptySegment(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	arg := checklistTestArg()
+	action := config.Action{Title: "Test checklist empty segment"}
+
+	err := ValidateArgument(&arg, "documents,,photos", &action)
+	assert.NotNil(t, err)
+}
+
+func TestMangleArgumentValueChecklist(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	arg := checklistTestArg()
+
+	out := MangleArgumentValue(&arg, "Documents,Music", "Test action")
+	assert.Equal(t, "documents,music", out)
+
+	out = MangleArgumentValue(&arg, "documents,photos", "Test action")
+	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{
+		Title: "Test checklist empty selection",
+		Shell: "echo 'Selected segments: {{ segments }}'",
+		Arguments: []config.ActionArgument{
+			{
+				Name: "segments",
+				Type: "checklist",
+				Choices: []config.ActionArgumentChoice{
+					{Value: "kitchen"},
+					{Value: "bedroom"},
+				},
+			},
+		},
+	}
+	req.Arguments = map[string]string{
+		"segments": "",
+	}
+
+	mangleInvalidArgumentValues(req)
+	out, err := parseActionArguments(req)
+
+	assert.Nil(t, err)
+	assert.Equal(t, "echo 'Selected segments: '", out)
+}
+
 func newExecRequest() *ExecutionRequest {
 	return &ExecutionRequest{
 		Arguments: make(map[string]string),

+ 23 - 0
service/internal/executor/log_arguments_test.go

@@ -79,6 +79,29 @@ func TestStorableArgumentsFromRequestStoresMangledCheckboxValue(t *testing.T) {
 	assert.Equal(t, "1", args["mode"])
 }
 
+func TestStorableArgumentsFromRequestStoresMangledChecklistValue(t *testing.T) {
+	req := newExecRequest()
+	req.Binding.Action.Arguments = []config.ActionArgument{
+		{
+			Name: "directories",
+			Type: "checklist",
+			Choices: []config.ActionArgumentChoice{
+				{Title: "Documents", Value: "documents"},
+				{Title: "Photos", Value: "photos"},
+			},
+		},
+	}
+	req.Arguments = map[string]string{
+		"directories": "Documents,Photos",
+	}
+
+	mangleInvalidArgumentValues(req)
+	args := storableArgumentsFromRequest(req)
+
+	require.Len(t, args, 1)
+	assert.Equal(t, "documents,photos", args["directories"])
+}
+
 func TestCopyStorableArgumentsToLogEntry(t *testing.T) {
 	req := newExecRequest()
 	req.logEntry = &InternalLogEntry{}