mirror of
https://github.com/OliveTin/OliveTin
synced 2026-07-08 12:50:51 +00:00
+23
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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.
|
||||
@@ -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'])
|
||||
})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
Reference in New Issue
Block a user