Explorar o código

feat: filtering comboboxes (#887) (#1054)

James Read hai 2 semanas
pai
achega
c660554de8

+ 1 - 1
config.yaml

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

+ 337 - 0
frontend/resources/vue/components/ChoiceCombobox.vue

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

+ 10 - 6
frontend/resources/vue/views/ArgumentForm.vue

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

+ 20 - 8
integration-tests/tests/multipleDropdowns/multipleDropdowns.js

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