feat(filter): add autocompletion poc for labels
This commit is contained in:
parent
356399f853
commit
7fc1f27ef5
237
frontend/src/components/input/AutocompleteDropdown.vue
Normal file
237
frontend/src/components/input/AutocompleteDropdown.vue
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {type ComponentPublicInstance, computed, nextTick, ref, watch} from 'vue'
|
||||||
|
|
||||||
|
const TAB = 9,
|
||||||
|
ENTER = 13,
|
||||||
|
ESCAPE = 27,
|
||||||
|
ARROW_UP = 38,
|
||||||
|
ARROW_DOWN = 40
|
||||||
|
|
||||||
|
type state = 'unfocused' | 'focused'
|
||||||
|
|
||||||
|
const selectedIndex = ref(-1)
|
||||||
|
const state = ref<state>('unfocused')
|
||||||
|
const val = ref<string>('')
|
||||||
|
const isResizing = ref(false)
|
||||||
|
const model = defineModel<string>()
|
||||||
|
|
||||||
|
const suggestionScrollerRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const containerRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const editorRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => model.value,
|
||||||
|
newValue => {
|
||||||
|
val.value = newValue
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits(['blur'])
|
||||||
|
|
||||||
|
const placeholderText = computed(() => {
|
||||||
|
const value = (model.value || '').replace(/[\n\r\t]/gi, ' ')
|
||||||
|
|
||||||
|
if (state.value === 'unfocused') {
|
||||||
|
return value ? '' : props.suggestion
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || !value.trim()) {
|
||||||
|
return props.suggestion
|
||||||
|
}
|
||||||
|
|
||||||
|
return lookahead()
|
||||||
|
})
|
||||||
|
|
||||||
|
const spacerText = computed(() => {
|
||||||
|
const value = (model.value || '').replace(/[\n\r\t]/gi, ' ')
|
||||||
|
|
||||||
|
if (!value || !value.trim()) {
|
||||||
|
return props.suggestion
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
options: any[],
|
||||||
|
suggestion?: string,
|
||||||
|
maxHeight?: number,
|
||||||
|
}>(), {
|
||||||
|
maxHeight: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
function lookahead() {
|
||||||
|
if (!props.options.length) {
|
||||||
|
return model.value
|
||||||
|
}
|
||||||
|
const index = Math.max(0, Math.min(selectedIndex.value, props.options.length - 1))
|
||||||
|
const match = props.options[index]
|
||||||
|
return model.value + (match ? match.substring(model.value?.length) : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSuggestionScroll() {
|
||||||
|
nextTick(() => {
|
||||||
|
const scroller = suggestionScrollerRef.value
|
||||||
|
const selectedItem = scroller?.querySelector('.selected')
|
||||||
|
scroller.scrollTop = selectedItem ? selectedItem.offsetTop : 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScrollWindowSize() {
|
||||||
|
if (isResizing.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isResizing.value = true
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
isResizing.value = false
|
||||||
|
|
||||||
|
const scroller = suggestionScrollerRef.value
|
||||||
|
const parent = containerRef.value
|
||||||
|
if (scroller) {
|
||||||
|
const rect = parent.getBoundingClientRect()
|
||||||
|
const pxTop = rect.top
|
||||||
|
const pxBottom = window.innerHeight - rect.bottom
|
||||||
|
const maxHeight = Math.max(pxTop, pxBottom, props.maxHeight)
|
||||||
|
const isReversed = pxBottom < props.maxHeight && pxTop > pxBottom
|
||||||
|
scroller.style.maxHeight = Math.min(isReversed ? pxTop : pxBottom, props.maxHeight) + 'px'
|
||||||
|
scroller.parentNode.style.transform =
|
||||||
|
isReversed ? 'translateY(-100%) translateY(-1.4rem)'
|
||||||
|
: 'translateY(.4rem)'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setState(stateName: state) {
|
||||||
|
state.value = stateName
|
||||||
|
if (stateName === 'unfocused') {
|
||||||
|
emit('blur')
|
||||||
|
} else {
|
||||||
|
updateScrollWindowSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocusField(e) {
|
||||||
|
setState('focused')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
switch (e.keyCode || e.which) {
|
||||||
|
case ESCAPE:
|
||||||
|
e.preventDefault()
|
||||||
|
setState('unfocused')
|
||||||
|
break
|
||||||
|
case ARROW_UP:
|
||||||
|
e.preventDefault()
|
||||||
|
select(-1)
|
||||||
|
break
|
||||||
|
case ARROW_DOWN:
|
||||||
|
e.preventDefault()
|
||||||
|
select(1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultRefs = ref<(HTMLElement | null)[]>([])
|
||||||
|
|
||||||
|
function setResultRefs(el: Element | ComponentPublicInstance | null, index: number) {
|
||||||
|
resultRefs.value[index] = el as (HTMLElement | null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(offset: number) {
|
||||||
|
|
||||||
|
let index = selectedIndex.value + offset
|
||||||
|
|
||||||
|
if (!isFinite(index)) {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index >= props.options.length) {
|
||||||
|
// At the last index, now moving back to the top
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < 0) {
|
||||||
|
// Arrow up but we're already at the top
|
||||||
|
index = props.options.length - 1
|
||||||
|
}
|
||||||
|
let elems = resultRefs.value[index]
|
||||||
|
if (
|
||||||
|
typeof elems === 'undefined'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedIndex.value = index
|
||||||
|
updateSuggestionScroll()
|
||||||
|
|
||||||
|
if (Array.isArray(elems)) {
|
||||||
|
elems[0].focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elems?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectValue(value) {
|
||||||
|
model.value = value
|
||||||
|
selectedIndex.value = 0
|
||||||
|
setState('unfocused')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpdateField(e) {
|
||||||
|
setState('focused')
|
||||||
|
model.value = e.currentTarget.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="autocomplete" ref="containerRef">
|
||||||
|
<div class="entry-box">
|
||||||
|
<slot
|
||||||
|
name="input"
|
||||||
|
:spacerText
|
||||||
|
:placeholderText
|
||||||
|
:onUpdateField
|
||||||
|
:onFocusField
|
||||||
|
:onKeydown
|
||||||
|
>
|
||||||
|
<div class="spacer">{{ spacerText }}</div>
|
||||||
|
<div class="placeholder">{{ placeholderText }}</div>
|
||||||
|
<textarea class="field"
|
||||||
|
@input="onUpdateField"
|
||||||
|
@focus="onFocusField"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
:class="state"
|
||||||
|
:value="val"
|
||||||
|
ref="editorRef"></textarea>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<div class="suggestion-list" v-if="state === 'focused' && options.length">
|
||||||
|
<div v-if="options && options.length" class="scroll-list">
|
||||||
|
<div
|
||||||
|
class="items"
|
||||||
|
ref="suggestionScrollerRef"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="(item, index) in options"
|
||||||
|
class="item"
|
||||||
|
@click="onSelectValue(item)"
|
||||||
|
:class="{ selected: index === selectedIndex }"
|
||||||
|
:key="item"
|
||||||
|
:ref="(el: Element | ComponentPublicInstance | null) => setResultRefs(el, index)"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
name="result"
|
||||||
|
:item
|
||||||
|
:selected="index === selectedIndex"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
</slot>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -5,6 +5,7 @@ import DatepickerWithValues from '@/components/date/datepickerWithValues.vue'
|
|||||||
import UserService from '@/services/user'
|
import UserService from '@/services/user'
|
||||||
import {getAvatarUrl, getDisplayName} from '@/models/user'
|
import {getAvatarUrl, getDisplayName} from '@/models/user'
|
||||||
import {createRandomID} from '@/helpers/randomId'
|
import {createRandomID} from '@/helpers/randomId'
|
||||||
|
import AutocompleteDropdown from '@/components/input/AutocompleteDropdown.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
modelValue,
|
modelValue,
|
||||||
@ -40,14 +41,18 @@ const assigneeFields = [
|
|||||||
'assignees',
|
'assignees',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const labelFields = [
|
||||||
|
'labels',
|
||||||
|
]
|
||||||
|
|
||||||
const availableFilterFields = [
|
const availableFilterFields = [
|
||||||
'done',
|
'done',
|
||||||
'priority',
|
'priority',
|
||||||
'usePriority',
|
'usePriority',
|
||||||
'percentDone',
|
'percentDone',
|
||||||
'labels',
|
|
||||||
...dateFields,
|
...dateFields,
|
||||||
...assigneeFields,
|
...assigneeFields,
|
||||||
|
...labelFields,
|
||||||
]
|
]
|
||||||
|
|
||||||
const filterOperators = [
|
const filterOperators = [
|
||||||
@ -69,6 +74,9 @@ const filterJoinOperators = [
|
|||||||
')',
|
')',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const FILTER_OPERATORS_REGEX = '(<|>|<=|>=|=|!=)'
|
||||||
|
const FILTER_JOIN_OPERATORS_REGEX = '(&&|\|\||\(|\))'
|
||||||
|
|
||||||
function escapeHtml(unsafe: string): string {
|
function escapeHtml(unsafe: string): string {
|
||||||
return unsafe
|
return unsafe
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@ -91,7 +99,7 @@ const highlightedFilterQuery = computed(() => {
|
|||||||
let highlighted = escapeHtml(filterQuery.value)
|
let highlighted = escapeHtml(filterQuery.value)
|
||||||
dateFields
|
dateFields
|
||||||
.forEach(o => {
|
.forEach(o => {
|
||||||
const pattern = new RegExp(o + '(\\s*)(<|>|<=|>=|=|!=)(\\s*)([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
|
const pattern = new RegExp(o + '(\\s*)' + FILTER_OPERATORS_REGEX + '(\\s*)([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
|
||||||
highlighted = highlighted.replaceAll(pattern, (match, spacesBefore, token, spacesAfter, start, value, position) => {
|
highlighted = highlighted.replaceAll(pattern, (match, spacesBefore, token, spacesAfter, start, value, position) => {
|
||||||
if (typeof value === 'undefined') {
|
if (typeof value === 'undefined') {
|
||||||
value = ''
|
value = ''
|
||||||
@ -102,7 +110,7 @@ const highlightedFilterQuery = computed(() => {
|
|||||||
})
|
})
|
||||||
assigneeFields
|
assigneeFields
|
||||||
.forEach(f => {
|
.forEach(f => {
|
||||||
const pattern = new RegExp(f + '\\s*(<|>|<=|>=|=|!=)\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
|
const pattern = new RegExp(f + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
|
||||||
highlighted = highlighted.replaceAll(pattern, (match, token, start, value) => {
|
highlighted = highlighted.replaceAll(pattern, (match, token, start, value) => {
|
||||||
if (typeof value === 'undefined') {
|
if (typeof value === 'undefined') {
|
||||||
value = ''
|
value = ''
|
||||||
@ -189,33 +197,70 @@ function updateDateInQuery(newDate: string) {
|
|||||||
currentOldDatepickerValue.value = newDate
|
currentOldDatepickerValue.value = newDate
|
||||||
filterQuery.value = unEscapeHtml(escaped)
|
filterQuery.value = unEscapeHtml(escaped)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleFieldInput(e, autocompleteOnInput) {
|
||||||
|
const cursorPosition = filterInput.value.selectionStart
|
||||||
|
const textUpToCursor = filterQuery.value.substring(0, cursorPosition)
|
||||||
|
|
||||||
|
labelFields.forEach(l => {
|
||||||
|
const pattern = new RegExp('(' + l + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&\|\(\)]+\\1?)?$', 'ig')
|
||||||
|
const match = pattern.exec(textUpToCursor)
|
||||||
|
|
||||||
|
if (match !== null) {
|
||||||
|
const [matched, prefix, operator, space, keyword] = match
|
||||||
|
if (keyword) {
|
||||||
|
autocompleteResults.value = ['loool', keyword]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const autocompleteResults = ref<any[]>([])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ $t('filters.query.title') }}</label>
|
<label class="label">{{ $t('filters.query.title') }}</label>
|
||||||
<div class="control filter-input">
|
<AutocompleteDropdown
|
||||||
<textarea
|
:options="autocompleteResults"
|
||||||
autocomplete="off"
|
@blur="filterInput?.blur()"
|
||||||
autocorrect="off"
|
>
|
||||||
autocapitalize="off"
|
<template
|
||||||
spellcheck="false"
|
v-slot:input="{ onKeydown, onFocusField, onUpdateField }"
|
||||||
v-model="filterQuery"
|
>
|
||||||
class="input"
|
<div class="control filter-input">
|
||||||
ref="filterInput"
|
<textarea
|
||||||
></textarea>
|
@input="e => handleFieldInput(e, onUpdateField)"
|
||||||
<div
|
@focus="onFocusField"
|
||||||
class="filter-input-highlight"
|
@keydown="onKeydown"
|
||||||
:style="{'height': height}"
|
|
||||||
v-html="highlightedFilterQuery"
|
autocomplete="off"
|
||||||
></div>
|
autocorrect="off"
|
||||||
<DatepickerWithValues
|
autocapitalize="off"
|
||||||
v-model="currentDatepickerValue"
|
spellcheck="false"
|
||||||
:open="datePickerPopupOpen"
|
v-model="filterQuery"
|
||||||
@close="() => datePickerPopupOpen = false"
|
class="input"
|
||||||
@update:model-value="updateDateInQuery"
|
ref="filterInput"
|
||||||
/>
|
></textarea>
|
||||||
</div>
|
<div
|
||||||
|
class="filter-input-highlight"
|
||||||
|
:style="{'height': height}"
|
||||||
|
v-html="highlightedFilterQuery"
|
||||||
|
></div>
|
||||||
|
<DatepickerWithValues
|
||||||
|
v-model="currentDatepickerValue"
|
||||||
|
:open="datePickerPopupOpen"
|
||||||
|
@close="() => datePickerPopupOpen = false"
|
||||||
|
@update:model-value="updateDateInQuery"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-slot:result="{ item }"
|
||||||
|
>
|
||||||
|
whoo {{ item }}
|
||||||
|
</template>
|
||||||
|
</AutocompleteDropdown>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user