feat: Checklist support (#922)

This commit is contained in:
jamesread 2026-07-06 11:01:22 +01:00
parent cdf501e62a
commit e24ae6265b
14 changed files with 746 additions and 19 deletions

View File

@ -143,6 +143,29 @@ actions:
- type: confirmation - type: confirmation
title: Are you sure?! 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 # 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. # download themes. You will still need to set theme "themeName" in your config.
# #

View File

@ -73,6 +73,7 @@
** xref:args/regex.adoc[Input: Regex] ** xref:args/regex.adoc[Input: Regex]
** xref:args/password.adoc[Input: Password] ** xref:args/password.adoc[Input: Password]
** xref:args/input_checkbox.adoc[Input: Checkbox/Boolean] ** 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_dropdown.adoc[Input: Dropdown]
** xref:args/input_datetime.adoc[Input: Date & Time] ** xref:args/input_datetime.adoc[Input: Date & Time]
** xref:args/input_confirmation.adoc[Input: Confirmation] ** xref:args/input_confirmation.adoc[Input: Confirmation]

View File

@ -0,0 +1,55 @@
[#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.

View File

@ -9,9 +9,9 @@ A full list of argument types are below;
| Type | Rendered as | Allowed values | 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. | (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 | 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. | 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. | 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. | email | xref:args/input.adoc[Textbox] | An email address.
| password | xref:args/password.adoc[Password] | A password, which is hidden when typed. | 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. | 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. | 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. | 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. | 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 | 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. 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.
==== ====

View File

@ -0,0 +1,149 @@
<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">{{ 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="hidden"
:value="modelValue"
:required="required && modelValue === ''"
/>
</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
},
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(3, minmax(0, 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>

View File

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

View File

@ -0,0 +1,39 @@
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(''), [])
})
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'])
})

View File

@ -8,7 +8,7 @@
<template v-if="actionArguments.length > 0"> <template v-if="actionArguments.length > 0">
<template v-for="arg in actionArguments" :key="arg.name"> <template v-for="arg in actionArguments" :key="arg.name">
<label :for="arg.name"> <label :for="arg.type === 'checklist' ? undefined : arg.name">
{{ formatLabel(arg.title) }} {{ formatLabel(arg.title) }}
</label> </label>
@ -25,6 +25,10 @@
:choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required" :choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
@update:model-value="handleChoiceUpdate(arg, $event)" /> @update:model-value="handleChoiceUpdate(arg, $event)" />
<ChoiceChecklist v-else-if="arg.type === 'checklist'" :id="arg.name" :name="arg.name"
:choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
@update:model-value="handleChecklistUpdate(arg, $event)" />
<component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name" <component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name"
:value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)" :value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
:checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined" :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 { useRouter } from 'vue-router'
import { requestReconnectNow } from '../../../js/websocket.js' import { requestReconnectNow } from '../../../js/websocket.js'
import ChoiceCombobox from '../components/ChoiceCombobox.vue' import ChoiceCombobox from '../components/ChoiceCombobox.vue'
import ChoiceChecklist from '../components/ChoiceChecklist.vue'
const router = useRouter() const router = useRouter()
@ -140,6 +145,8 @@ async function setup() {
} else { } else {
argValues.value[arg.name] = false argValues.value[arg.name] = false
} }
} else if (arg.type === 'checklist') {
argValues.value[arg.name] = paramValue !== null ? paramValue : arg.defaultValue || ''
} else { } else {
argValues.value[arg.name] = paramValue !== null ? paramValue : arg.defaultValue || '' argValues.value[arg.name] = paramValue !== null ? paramValue : arg.defaultValue || ''
} }
@ -238,6 +245,20 @@ function handleChange(arg, event) {
validateArgument(arg, event.target.value) validateArgument(arg, event.target.value)
} }
function getValidationElement(arg) {
if (arg.type === 'checklist') {
return document.getElementById(`${arg.name}-value`)
}
return document.getElementById(arg.name)
}
function handleChecklistUpdate(arg, value) {
argValues.value[arg.name] = value
updateUrlWithArg(arg.name, value)
validateArgument(arg, value)
}
function handleChoiceUpdate(arg, value) { function handleChoiceUpdate(arg, value) {
argValues.value[arg.name] = value argValues.value[arg.name] = value
updateUrlWithArg(arg.name, value) updateUrlWithArg(arg.name, value)
@ -251,7 +272,7 @@ async function validateArgument(arg, value) {
// Skip validation for datetime - backend will handle mangling values without seconds // Skip validation for datetime - backend will handle mangling values without seconds
if (arg.type === 'datetime') { if (arg.type === 'datetime') {
const inputElement = document.getElementById(arg.name) const inputElement = getValidationElement(arg)
if (inputElement) { if (inputElement) {
inputElement.setCustomValidity('') inputElement.setCustomValidity('')
} }
@ -261,7 +282,7 @@ async function validateArgument(arg, value) {
// Skip validation for checkbox and confirmation - they're always valid // Skip validation for checkbox and confirmation - they're always valid
if (arg.type === 'checkbox' || arg.type === 'confirmation') { if (arg.type === 'checkbox' || arg.type === 'confirmation') {
const inputElement = document.getElementById(arg.name) const inputElement = getValidationElement(arg)
if (inputElement) { if (inputElement) {
inputElement.setCustomValidity('') inputElement.setCustomValidity('')
} }
@ -279,8 +300,7 @@ async function validateArgument(arg, value) {
const validation = await window.client.validateArgumentType(validateArgumentTypeArgs) const validation = await window.client.validateArgumentType(validateArgumentTypeArgs)
// Get the input element to set custom validity const inputElement = getValidationElement(arg)
const inputElement = document.getElementById(arg.name)
if (validation.valid) { if (validation.valid) {
delete formErrors.value[arg.name] delete formErrors.value[arg.name]
@ -297,8 +317,7 @@ async function validateArgument(arg, value) {
} }
} catch (err) { } catch (err) {
console.warn('Validation failed:', err) console.warn('Validation failed:', err)
// On error, clear any custom validity const inputElement = getValidationElement(arg)
const inputElement = document.getElementById(arg.name)
if (inputElement) { if (inputElement) {
inputElement.setCustomValidity('') inputElement.setCustomValidity('')
} }
@ -393,7 +412,7 @@ function saveBrowserSuggestions() {
const value = argValues.value[arg.name] const value = argValues.value[arg.name]
// Only save non-empty values for non-checkbox/confirmation/password types // 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 { try {
const key = `olivetin-suggestions-${arg.suggestionsBrowserKey}` const key = `olivetin-suggestions-${arg.suggestionsBrowserKey}`
const stored = localStorage.getItem(key) const stored = localStorage.getItem(key)
@ -467,7 +486,7 @@ async function handleSubmit(event) {
for (const arg of actionArguments.value) { for (const arg of actionArguments.value) {
const value = argValues.value[arg.name] const value = argValues.value[arg.name]
const inputElement = document.getElementById(arg.name) const inputElement = getValidationElement(arg)
if (arg.required && (!value || value === '')) { if (arg.required && (!value || value === '')) {
formErrors.value[arg.name] = 'This field is required' formErrors.value[arg.name] = 'This field is required'

View File

@ -0,0 +1,156 @@
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() {
await getRootAndWait()
const btn = await getActionButton(webdriver, 'Test checklist argument')
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 waitForTerminalOutput(expectedValue) {
await webdriver.wait(
new Condition(`wait for checklist value ${expectedValue} in 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 output.trim().includes(`Selected segments: ${expectedValue}`)
} catch (e) {
return false
}
}),
DEFAULT_UI_WAIT_MS
)
}
async function waitForTerminalOutputPattern(pattern) {
await webdriver.wait(
new Condition(`wait for terminal output matching ${pattern}`, 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 pattern.test(output.trim())
} catch (e) {
return false
}
}),
10000
)
}
async function getCheckboxByValueIndex(index) {
return await webdriver.findElement(By.id(`segments-${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 hidden = await webdriver.findElement(By.id('segments-value'))
expect(await hidden.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')
})
})

View File

@ -0,0 +1,24 @@
---
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
logLevel: "DEBUG"
checkForUpdates: false
defaultPopupOnStart: execution-dialog
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

View File

@ -441,10 +441,23 @@ func (arg *ActionArgument) sanitize() {
} }
arg.sanitizeNoType() arg.sanitizeNoType()
arg.sanitizeChecklist()
// Default value validation runs in executor at config load (validateArgumentDefaults). // Default value validation runs in executor at config load (validateArgumentDefaults).
} }
func (arg *ActionArgument) sanitizeChecklist() {
if arg.Type != "checklist" {
return
}
if len(arg.Choices) == 0 {
log.WithFields(log.Fields{
"arg": arg.Name,
}).Warn("Checklist argument has no choices defined")
}
}
func (arg *ActionArgument) sanitizeNoType() { func (arg *ActionArgument) sanitizeNoType() {
if len(arg.Choices) == 0 && arg.Type == "" { if len(arg.Choices) == 0 && arg.Type == "" {
log.WithFields(log.Fields{ log.WithFields(log.Fields{

View File

@ -191,6 +191,10 @@ func typecheckActionArgumentFound(value string, arg *config.ActionArgument) erro
return typecheckNull(arg) return typecheckNull(arg)
} }
if arg.Type == "checklist" {
return typecheckChecklist(value, arg)
}
if len(arg.Choices) > 0 { if len(arg.Choices) > 0 {
return typecheckChoice(value, arg) return typecheckChoice(value, arg)
} }
@ -211,6 +215,8 @@ func TypeSafetyCheck(name string, value string, argumentType string) error {
return nil return nil
case "checkbox": case "checkbox":
return nil return nil
case "checklist":
return nil
case "email": case "email":
return typeSafetyCheckEmail(value) return typeSafetyCheckEmail(value)
case "url": case "url":
@ -230,6 +236,28 @@ func typecheckNull(arg *config.ActionArgument) error {
return nil 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 { func typecheckChoice(value string, arg *config.ActionArgument) error {
if arg.Entity != "" { if arg.Entity != "" {
return typecheckChoiceEntity(value, arg) return typecheckChoiceEntity(value, arg)
@ -333,6 +361,7 @@ func mangleInvalidArgumentValues(req *ExecutionRequest) {
} }
mangleCheckboxValues(req, &arg) mangleCheckboxValues(req, &arg)
mangleChecklistValues(req, &arg)
} }
} }
@ -389,15 +418,20 @@ func MangleArgumentValue(arg *config.ActionArgument, value string, actionTitle s
return value 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) return mangleDatetimeValue(arg, value, actionTitle)
} case "checkbox":
if arg.Type == "checkbox" {
return mangleCheckboxValue(arg, value, actionTitle) 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 { func mangleDatetimeValue(arg *config.ActionArgument, value string, actionTitle string) string {
@ -430,6 +464,47 @@ func mangleCheckboxValue(arg *config.ActionArgument, value string, actionTitle s
return value 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 {
for _, choice := range arg.Choices { for _, choice := range arg.Choices {
if value == choice.Title { if value == choice.Title {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
@ -437,7 +512,7 @@ func mangleCheckboxValue(arg *config.ActionArgument, value string, actionTitle s
"oldValue": value, "oldValue": value,
"newValue": choice.Value, "newValue": choice.Value,
"actionTitle": actionTitle, "actionTitle": actionTitle,
}).Infof("Mangled checkbox value") }).Infof("Mangled choice segment")
return choice.Value return choice.Value
} }

View File

@ -115,6 +115,120 @@ func TestValidateArgumentCheckboxWithChoices(t *testing.T) {
assert.NotNil(t, err, "Expected unknown checkbox title to be rejected against choices") 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 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 { func newExecRequest() *ExecutionRequest {
return &ExecutionRequest{ return &ExecutionRequest{
Arguments: make(map[string]string), Arguments: make(map[string]string),

View File

@ -79,6 +79,29 @@ func TestStorableArgumentsFromRequestStoresMangledCheckboxValue(t *testing.T) {
assert.Equal(t, "1", args["mode"]) 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) { func TestCopyStorableArgumentsToLogEntry(t *testing.T) {
req := newExecRequest() req := newExecRequest()
req.logEntry = &InternalLogEntry{} req.logEntry = &InternalLogEntry{}