commit
c660554de8
|
|
@ -118,7 +118,7 @@ actions:
|
|||
# Docs: https://docs.olivetin.app/solutions/container-control-panel/index.html
|
||||
- title: Restart Docker Container
|
||||
icon: restart
|
||||
shell: docker restart {{ .CurrentEntity }}
|
||||
shell: docker restart {{ container }}
|
||||
arguments:
|
||||
- name: container
|
||||
title: Container name
|
||||
|
|
|
|||
|
|
@ -0,0 +1,337 @@
|
|||
<template>
|
||||
<div class="choice-combobox" ref="rootRef">
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
:id="id"
|
||||
type="text"
|
||||
class="choice-combobox-input"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-controls="listboxId"
|
||||
:aria-activedescendant="activeDescendantId"
|
||||
:placeholder="placeholderText"
|
||||
:value="query"
|
||||
:required="required"
|
||||
@focus="handleFocus"
|
||||
@input="handleSearchInput"
|
||||
@keydown="handleKeydown"
|
||||
@blur="handleBlur"
|
||||
/>
|
||||
<input
|
||||
:name="name"
|
||||
type="hidden"
|
||||
:value="modelValue"
|
||||
/>
|
||||
<ul
|
||||
v-if="isOpen && filteredChoices.length > 0"
|
||||
:id="listboxId"
|
||||
role="listbox"
|
||||
class="choice-combobox-list"
|
||||
>
|
||||
<li
|
||||
v-for="(choice, index) in filteredChoices"
|
||||
:id="`${listboxId}-option-${index}`"
|
||||
:key="choice.value"
|
||||
role="option"
|
||||
:aria-selected="choice.value === modelValue"
|
||||
:class="{
|
||||
highlighted: index === highlightedIndex,
|
||||
selected: choice.value === modelValue
|
||||
}"
|
||||
@mousedown.prevent="selectChoice(choice)"
|
||||
>
|
||||
{{ choiceLabel(choice) }}
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else-if="isOpen && query" class="choice-combobox-list choice-combobox-empty">
|
||||
No matching options
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
choices: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const closeOthersEvent = 'olivetin-choice-combobox-close-others'
|
||||
|
||||
const rootRef = ref(null)
|
||||
const searchInputRef = ref(null)
|
||||
const isOpen = ref(false)
|
||||
const query = ref('')
|
||||
const highlightedIndex = ref(0)
|
||||
|
||||
const listboxId = computed(() => `${props.id}-listbox`)
|
||||
|
||||
const activeDescendantId = computed(() => {
|
||||
if (!isOpen.value || filteredChoices.value.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return `${listboxId.value}-option-${highlightedIndex.value}`
|
||||
})
|
||||
|
||||
const placeholderText = computed(() => {
|
||||
if (props.required) {
|
||||
return 'Search and select...'
|
||||
}
|
||||
|
||||
return 'Search options...'
|
||||
})
|
||||
|
||||
const filteredChoices = computed(() => {
|
||||
const search = query.value.trim().toLowerCase()
|
||||
if (!search) {
|
||||
return props.choices
|
||||
}
|
||||
|
||||
return props.choices.filter(choice => {
|
||||
const label = choiceLabel(choice).toLowerCase()
|
||||
const value = String(choice.value).toLowerCase()
|
||||
return label.includes(search) || value.includes(search)
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
if (!isOpen.value) {
|
||||
query.value = selectedLabel()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function choiceLabel(choice) {
|
||||
return choice.title || choice.value
|
||||
}
|
||||
|
||||
function selectedLabel() {
|
||||
const match = props.choices.find(choice => choice.value === props.modelValue)
|
||||
if (!match) {
|
||||
return props.modelValue || ''
|
||||
}
|
||||
|
||||
return choiceLabel(match)
|
||||
}
|
||||
|
||||
function openList() {
|
||||
document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } }))
|
||||
isOpen.value = true
|
||||
highlightedIndex.value = 0
|
||||
}
|
||||
|
||||
function closeList() {
|
||||
isOpen.value = false
|
||||
query.value = selectedLabel()
|
||||
}
|
||||
|
||||
function emitValue(value) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
function selectChoice(choice) {
|
||||
emitValue(choice.value)
|
||||
closeList()
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
query.value = isOpen.value ? query.value : selectedLabel()
|
||||
openList()
|
||||
}
|
||||
|
||||
function handleSearchInput(event) {
|
||||
query.value = event.target.value
|
||||
openList()
|
||||
highlightedIndex.value = 0
|
||||
}
|
||||
|
||||
function moveHighlight(delta) {
|
||||
if (filteredChoices.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndex = highlightedIndex.value + delta
|
||||
if (nextIndex < 0) {
|
||||
highlightedIndex.value = filteredChoices.value.length - 1
|
||||
return
|
||||
}
|
||||
|
||||
if (nextIndex >= filteredChoices.value.length) {
|
||||
highlightedIndex.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
highlightedIndex.value = nextIndex
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
const wasOpen = isOpen.value
|
||||
openList()
|
||||
if (wasOpen) {
|
||||
moveHighlight(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
const wasOpen = isOpen.value
|
||||
openList()
|
||||
if (wasOpen) {
|
||||
moveHighlight(-1)
|
||||
} else if (filteredChoices.value.length > 0) {
|
||||
highlightedIndex.value = filteredChoices.value.length - 1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
if (!isOpen.value || filteredChoices.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
selectChoice(filteredChoices.value[highlightedIndex.value])
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeList()
|
||||
searchInputRef.value?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
closeList()
|
||||
}
|
||||
|
||||
function handleCloseOthers(event) {
|
||||
if (event.detail.id !== props.id) {
|
||||
closeList()
|
||||
}
|
||||
}
|
||||
|
||||
function handleOutsideMouseDown(event) {
|
||||
if (!isOpen.value || rootRef.value?.contains(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
closeList()
|
||||
}
|
||||
|
||||
watch(isOpen, open => {
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleOutsideMouseDown, true)
|
||||
return
|
||||
}
|
||||
|
||||
document.removeEventListener('mousedown', handleOutsideMouseDown, true)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener(closeOthersEvent, handleCloseOthers)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', handleOutsideMouseDown, true)
|
||||
document.removeEventListener(closeOthersEvent, handleCloseOthers)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.choice-combobox {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.choice-combobox:focus-within {
|
||||
z-index: 11;
|
||||
}
|
||||
|
||||
.choice-combobox-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.choice-combobox-list {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
margin: 0.125rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: 1px solid var(--border-color, #ccc);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--standout-bg-color, #fff);
|
||||
color: var(--text-color, inherit);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.choice-combobox-list li {
|
||||
padding: 0.375rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.choice-combobox-list li.highlighted,
|
||||
.choice-combobox-list li:hover {
|
||||
background: var(--hover-background-color, #eef3ff);
|
||||
color: var(--hover-text-color, inherit);
|
||||
}
|
||||
|
||||
.choice-combobox-list li.selected {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.choice-combobox-empty {
|
||||
padding: 0.375rem 0.5rem;
|
||||
color: var(--disabled-text-color, #666);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.choice-combobox-list,
|
||||
.choice-combobox-empty {
|
||||
background-color: #4e4e4e;
|
||||
color: #ddd;
|
||||
border-color: var(--border-color, #595959);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.choice-combobox-list li.highlighted,
|
||||
.choice-combobox-list li:hover {
|
||||
background-color: var(--hover-background-color, #1d345c);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.choice-combobox-empty {
|
||||
color: var(--disabled-text-color, #999);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -21,12 +21,9 @@
|
|||
</option>
|
||||
</datalist>
|
||||
|
||||
<select v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
|
||||
:required="arg.required" @input="handleInput(arg, $event)" @change="handleChange(arg, $event)">
|
||||
<option v-for="choice in arg.choices" :key="choice.value" :value="choice.value">
|
||||
{{ choice.title || choice.value }}
|
||||
</option>
|
||||
</select>
|
||||
<ChoiceCombobox v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name"
|
||||
: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)"
|
||||
|
|
@ -67,6 +64,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'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
|
|
@ -240,6 +238,12 @@ function handleChange(arg, event) {
|
|||
validateArgument(arg, event.target.value)
|
||||
}
|
||||
|
||||
function handleChoiceUpdate(arg, value) {
|
||||
argValues.value[arg.name] = value
|
||||
updateUrlWithArg(arg.name, value)
|
||||
validateArgument(arg, value)
|
||||
}
|
||||
|
||||
async function validateArgument(arg, value) {
|
||||
if (!arg.type || arg.type.startsWith('regex:')) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { describe, it, before, after } from 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { By, until, Condition } from 'selenium-webdriver'
|
||||
import {
|
||||
getRootAndWait,
|
||||
import { By, until, Condition, Key } from 'selenium-webdriver'
|
||||
import {
|
||||
getRootAndWait,
|
||||
getActionButtons,
|
||||
takeScreenshotOnFailure,
|
||||
} from '../../lib/elements.js'
|
||||
|
|
@ -52,10 +52,22 @@ describe('config: multipleDropdowns', function () {
|
|||
return url.includes('/actionBinding/') && url.includes('/argumentForm')
|
||||
}), 8000)
|
||||
|
||||
const selects = await webdriver.findElements(By.css('main select'))
|
||||
|
||||
expect(selects).to.have.length(2)
|
||||
expect(await selects[0].findElements(By.tagName('option'))).to.have.length(2)
|
||||
expect(await selects[1].findElements(By.tagName('option'))).to.have.length(3)
|
||||
const comboboxes = await webdriver.findElements(By.css('main .choice-combobox'))
|
||||
|
||||
expect(comboboxes).to.have.length(2)
|
||||
|
||||
const firstInput = await comboboxes[0].findElement(By.css('.choice-combobox-input'))
|
||||
await firstInput.click()
|
||||
await webdriver.wait(new Condition('wait for first combobox list', async () => {
|
||||
const lists = await comboboxes[0].findElements(By.css('.choice-combobox-list li'))
|
||||
return lists.length === 2
|
||||
}), 2000)
|
||||
|
||||
await firstInput.sendKeys(Key.TAB)
|
||||
|
||||
await webdriver.wait(new Condition('wait for second combobox list', async () => {
|
||||
const lists = await comboboxes[1].findElements(By.css('.choice-combobox-list li'))
|
||||
return lists.length === 3
|
||||
}), 2000)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue