feat: Checklist support (#922) (#1066)
Build & Release pipeline / build (push) Has been cancelled
Codestyle checks / codestyle (push) Has been cancelled
Antora docs / antora (push) Has been cancelled
Antora docs / trigger-docs-publish (push) Has been cancelled

This commit is contained in:
James Read
2026-07-06 12:30:46 +01:00
committed by GitHub
17 changed files with 998 additions and 19 deletions
+23
View File
@@ -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
View File
@@ -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]
@@ -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
View File
@@ -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.
====
@@ -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>
@@ -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)
}
@@ -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
View File
@@ -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')
@@ -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')
})
})
@@ -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 }}'
@@ -0,0 +1,2 @@
- hostname: attic
- hostname: basement
+20
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)
}
+76
View File
@@ -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
View File
@@ -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
View File
@@ -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 mangleArgumentValueByType(arg, value, actionTitle)
}
func mangleArgumentValueByType(arg *config.ActionArgument, value string, actionTitle string) string {
switch arg.Type {
case "datetime":
return mangleDatetimeValue(arg, value, actionTitle)
}
if arg.Type == "checkbox" {
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
View File
@@ -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),
@@ -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{}