commit
a856d46324
23
config.yaml
23
config.yaml
|
|
@ -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.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
| 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.
|
||||||
====
|
====
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-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"
|
||||||
|
: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"
|
<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()
|
||||||
|
|
||||||
|
|
@ -238,6 +243,14 @@ 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 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 +264,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 +274,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 +292,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 +309,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 +404,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 +478,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'
|
||||||
|
|
@ -484,6 +495,11 @@ async function handleSubmit(event) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.keys(formErrors.value).length > 0) {
|
||||||
|
console.log('argument form has validation errors')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const argvs = getArgumentValues()
|
const argvs = getArgumentValues()
|
||||||
console.log('argument form has elements that passed validation')
|
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, "missing", actionResult.Groups[1].Name)
|
||||||
assert.Equal(t, int32(0), actionResult.Groups[1].MaxConcurrent)
|
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 {
|
if err := cfg.validateReservedActionArgumentNames(); err != nil {
|
||||||
log.Fatalf("%v", err)
|
log.Fatalf("%v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := cfg.validateChecklistChoiceValues(); err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *Config) validateReservedActionArgumentNames() error {
|
func (cfg *Config) validateReservedActionArgumentNames() error {
|
||||||
|
|
@ -60,6 +64,49 @@ func (action *Action) validateReservedArgumentNames() error {
|
||||||
return nil
|
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() {
|
func (cfg *Config) sanitizeDashboardsForInlineActions() {
|
||||||
for _, dashboard := range cfg.Dashboards {
|
for _, dashboard := range cfg.Dashboards {
|
||||||
cfg.sanitizeDashboardComponentForInlineActions(dashboard)
|
cfg.sanitizeDashboardComponentForInlineActions(dashboard)
|
||||||
|
|
@ -441,10 +488,39 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
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{
|
||||||
|
|
|
||||||
|
|
@ -271,3 +271,26 @@ func TestValidateUniqueLocalUserAPIKeys(t *testing.T) {
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
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)
|
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,84 @@ 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 {
|
||||||
|
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 {
|
for _, choice := range arg.Choices {
|
||||||
if value == choice.Title {
|
if value == choice.Title {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
|
|
@ -437,7 +549,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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,169 @@ 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 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 {
|
func newExecRequest() *ExecutionRequest {
|
||||||
return &ExecutionRequest{
|
return &ExecutionRequest{
|
||||||
Arguments: make(map[string]string),
|
Arguments: make(map[string]string),
|
||||||
|
|
|
||||||
|
|
@ -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{}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue