diff --git a/config.yaml b/config.yaml index c50b2df..d8b503a 100644 --- a/config.yaml +++ b/config.yaml @@ -143,6 +143,29 @@ actions: - type: confirmation title: Are you sure?! + # Checklist arguments let users pick multiple predefined options. Selected + # values are passed to the action as a comma-separated string. + # + # Docs: https://docs.olivetin.app/args/input_checklist.html + - title: Backup selected directories + icon: backup + shell: 'echo "Backing up: {{ directories }}"' + arguments: + - name: directories + title: Directories to back up + type: checklist + description: Select one or more directories to include in the backup. + choices: + - title: Documents + value: documents + - title: Photos + value: photos + - title: Music + value: music + - title: Videos + value: videos + default: documents,photos + # This is an action that runs a script included with OliveTin, that will # download themes. You will still need to set theme "themeName" in your config. # diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 29896a8..ac12883 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -73,6 +73,7 @@ ** xref:args/regex.adoc[Input: Regex] ** xref:args/password.adoc[Input: Password] ** xref:args/input_checkbox.adoc[Input: Checkbox/Boolean] +** xref:args/input_checklist.adoc[Input: Checklist] ** xref:args/input_dropdown.adoc[Input: Dropdown] ** xref:args/input_datetime.adoc[Input: Date & Time] ** xref:args/input_confirmation.adoc[Input: Confirmation] diff --git a/docs/modules/ROOT/pages/args/input_checklist.adoc b/docs/modules/ROOT/pages/args/input_checklist.adoc new file mode 100644 index 0000000..229814e --- /dev/null +++ b/docs/modules/ROOT/pages/args/input_checklist.adoc @@ -0,0 +1,80 @@ +[#checklist] += Input: Checklist + +The `checklist` type argument renders multiple checkboxes from predefined `choices`. Users can select one or more options, and the selected values are passed to your action as a comma-separated string. + +[source,yaml] +---- +actions: + - title: Backup selected directories + shell: echo "Backing up: {{ directories }}" + arguments: + - name: directories + title: Directories to back up + type: checklist + choices: + - title: Documents + value: documents + - title: Photos + value: photos + - title: Music + value: music + default: documents,photos +---- + +When the example above runs with Documents and Photos selected, the shell command becomes: + +[source,shell] +---- +echo "Backing up: documents,photos" +---- + +== Select all / Select none + +The web interface includes **Select all** and **Select none** controls above the checkbox list. + +== Empty selections + +If no options are selected, the argument value is an empty string. Use `rejectNull: true` when at least one selection is required. + +[source,yaml] +---- +arguments: + - name: directories + type: checklist + rejectNull: true + choices: + - value: documents + - value: photos +---- + +== Choice values + +Choice `value` fields must not contain commas, because commas are used to join multiple selections together. + +Each `title` is shown in the web interface. If a submitted segment matches a choice `title`, OliveTin maps it to the corresponding `value` before validation, matching the behaviour of xref:args/input_checkbox.adoc[checkbox] arguments with choices. + +== Using Entities + +Checklist options can be generated from entities, using the same pattern as xref:args/input_dropdown.adoc#args-dropdown-entities[entity-backed dropdowns]. Define one choice template and set `entity` to the entity type name: + +[source,yaml] +---- +actions: + - title: Restart selected containers + shell: 'docker restart {{ containers }}' + arguments: + - name: containers + title: Containers to restart + type: checklist + entity: container + choices: + - value: '{{ container.Names }}' + title: '{{ container.Names }}' + +entities: + - file: entities/containers.json + name: container +---- + +OliveTin expands the template once per entity instance and renders each result as a checkbox. Selected values are still passed as a comma-separated string. diff --git a/docs/modules/ROOT/pages/args/types.adoc b/docs/modules/ROOT/pages/args/types.adoc index ffdfed0..6d6f687 100644 --- a/docs/modules/ROOT/pages/args/types.adoc +++ b/docs/modules/ROOT/pages/args/types.adoc @@ -9,9 +9,9 @@ A full list of argument types are below; | Type | Rendered as | Allowed values | (default) | xref:args/input.adoc[Textbox] | If a `type:` is not set, and `choices:` is empty, then ascii will be used, and a warning will be logged. It is recommended that you set the type explicitly, rather than relying on defaults. | ascii | xref:args/input.adoc[Textbox] | a-z (case insensitive), 0-9, but no spaces or punctuation -| ascii_identifier | xref:args/input.adoc[Textbox] | Like a DNS name, a-Z (case insensitive), 0-9, `-`, `.`, and `_`. +| ascii_identifier | xref:args/input.adoc[Textbox] | Like a DNS name, a-Z (case insensitive), 0-9, `-`, `.`, and `_`. | shell_safe_identifier | xref:args/input.adoc[Textbox] | Like an ascii identifier, but also allows `@` and `+`. Useful for shell-safe usernames and email-style identifiers. -| ascii_sentence | xref:args/input.adoc[Textbox] | a-z (case insensitive), 0-9, with spaces, `.` and `,`. +| ascii_sentence | xref:args/input.adoc[Textbox] | a-z (case insensitive), 0-9, with spaces, `.` and `,`. | unicode_identifier | xref:args/input.adoc[Textbox] | Like an ascii identifier, but allows unicode characters. This is useful for languages that use non-ascii characters, such as Chinese, Japanese, etc. | email | xref:args/input.adoc[Textbox] | An email address. | password | xref:args/password.adoc[Password] | A password, which is hidden when typed. @@ -20,6 +20,7 @@ A full list of argument types are below; | int | xref:args/input.adoc[Textbox] | Any number, made up of the characters 0 to 9. Negative numbers are not supported. | url | xref:args/input.adoc[Textbox] | A URL (e.g. https://example.com). Accepts any scheme, including `file://` and `ftp://`. See warning below. | confirmation | xref:args/input_confirmation.adoc[Confirmation] | A "hidden" argument that makes the action require a confirmation before launching. +| checklist | xref:args/input_checklist.adoc[Checklist] | Multiple checkboxes from predefined choices. Selected values are passed as a comma-separated string. | n/a, but `choices` used | xref:args/input_dropdown.adoc[Dropdown] | A "hidden" argument that makes the action require a confirmation before launching. | raw_string_multiline | xref:args/input_textarea.adoc[Textarea] | Anything. This is **dangerous**, as effectively people can type anything they like |=== @@ -31,4 +32,3 @@ The `url` argument type does not restrict the URL scheme. Users can enter `file: If your action might be used by untrusted users, validate or filter the URL in your script (e.g. allow only `https://`) before using the value. ==== - diff --git a/frontend/resources/vue/components/ChoiceChecklist.vue b/frontend/resources/vue/components/ChoiceChecklist.vue new file mode 100644 index 0000000..04235d6 --- /dev/null +++ b/frontend/resources/vue/components/ChoiceChecklist.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/frontend/resources/vue/utils/choiceChecklistHelpers.js b/frontend/resources/vue/utils/choiceChecklistHelpers.js new file mode 100644 index 0000000..1737d33 --- /dev/null +++ b/frontend/resources/vue/utils/choiceChecklistHelpers.js @@ -0,0 +1,36 @@ +export function parseChecklistValue(value) { + if (!value || value === '') { + return [] + } + + return value.split(',').map((segment) => segment.trim()).filter((segment) => segment !== '') +} + +export function formatChecklistValue(selected) { + if (!Array.isArray(selected) || selected.length === 0) { + return '' + } + + return selected.join(',') +} + +export function toggleChoice(selected, value) { + const current = Array.isArray(selected) ? [...selected] : [] + const index = current.indexOf(value) + + if (index === -1) { + current.push(value) + return current + } + + current.splice(index, 1) + return current +} + +export function choiceLabel(choice) { + return choice.title || choice.value || '' +} + +export function allChoiceValues(choices) { + return choices.map((choice) => choice.value) +} diff --git a/frontend/resources/vue/utils/choiceChecklistHelpers.test.mjs b/frontend/resources/vue/utils/choiceChecklistHelpers.test.mjs new file mode 100644 index 0000000..3ccb7d0 --- /dev/null +++ b/frontend/resources/vue/utils/choiceChecklistHelpers.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + allChoiceValues, + choiceLabel, + formatChecklistValue, + parseChecklistValue, + toggleChoice +} from './choiceChecklistHelpers.js' + +const choices = [ + { title: 'Documents', value: 'documents' }, + { title: 'Photos', value: 'photos' } +] + +test('parseChecklistValue splits comma-delimited values', () => { + assert.deepEqual(parseChecklistValue('documents,photos'), ['documents', 'photos']) + assert.deepEqual(parseChecklistValue('documents, photos'), ['documents', 'photos']) + assert.deepEqual(parseChecklistValue(''), []) +}) + +test('formatChecklistValue joins selected values', () => { + assert.equal(formatChecklistValue(['documents', 'photos']), 'documents,photos') + assert.equal(formatChecklistValue([]), '') +}) + +test('toggleChoice adds and removes values', () => { + assert.deepEqual(toggleChoice([], 'documents'), ['documents']) + assert.deepEqual(toggleChoice(['documents'], 'photos'), ['documents', 'photos']) + assert.deepEqual(toggleChoice(['documents', 'photos'], 'documents'), ['photos']) +}) + +test('choiceLabel prefers title over value', () => { + assert.equal(choiceLabel(choices[0]), 'Documents') + assert.equal(choiceLabel({ value: 'music' }), 'music') +}) + +test('allChoiceValues returns every choice value', () => { + assert.deepEqual(allChoiceValues(choices), ['documents', 'photos']) +}) diff --git a/frontend/resources/vue/views/ArgumentForm.vue b/frontend/resources/vue/views/ArgumentForm.vue index f62bf89..e00f23e 100644 --- a/frontend/resources/vue/views/ArgumentForm.vue +++ b/frontend/resources/vue/views/ArgumentForm.vue @@ -8,7 +8,7 @@