feat(filter): rebuild filter input component
This commit is contained in:
parent
6bd3d6d4a0
commit
b99b7bf131
|
|
@ -2,6 +2,7 @@
|
|||
<div class="datepicker-with-range-container">
|
||||
<Popup
|
||||
:open="open"
|
||||
:ignore-click-classes="ignoreClickClasses"
|
||||
@update:open="(open) => !open && $emit('update:open', false)"
|
||||
>
|
||||
<template #content="{isOpen}">
|
||||
|
|
@ -94,8 +95,10 @@ import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
|||
const props = withDefaults(defineProps<{
|
||||
modelValue: string | Date | null,
|
||||
open?: boolean
|
||||
ignoreClickClasses?: string[]
|
||||
}>(), {
|
||||
open: false,
|
||||
ignoreClickClasses: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
|
|||
|
|
@ -198,6 +198,7 @@ function onUpdateField(e) {
|
|||
max-inline-size: 100%;
|
||||
min-inline-size: 100%;
|
||||
margin-block-start: -2px;
|
||||
margin-inline: -1px;
|
||||
|
||||
button {
|
||||
background: transparent;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,493 @@
|
|||
import {Extension} from '@tiptap/core'
|
||||
import {Plugin, PluginKey} from '@tiptap/pm/state'
|
||||
import {VueRenderer} from '@tiptap/vue-3'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import {computePosition, flip, shift, offset, autoUpdate} from '@floating-ui/dom'
|
||||
|
||||
import FilterCommandsList from './FilterCommandsList.vue'
|
||||
import {
|
||||
ASSIGNEE_FIELDS,
|
||||
AUTOCOMPLETE_FIELDS,
|
||||
FILTER_OPERATORS_REGEX,
|
||||
isMultiValueOperator,
|
||||
LABEL_FIELDS,
|
||||
PROJECT_FIELDS,
|
||||
} from '@/helpers/filters'
|
||||
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import UserService from '@/services/user'
|
||||
import ProjectUserService from '@/services/projectUsers'
|
||||
import type { IUser } from '@/modelTypes/IUser'
|
||||
import type { IProject } from '@/modelTypes/IProject'
|
||||
import type { ILabel } from '@/modelTypes/ILabel'
|
||||
|
||||
export interface FilterAutocompleteOptions {
|
||||
projectId?: number
|
||||
}
|
||||
|
||||
interface AutocompleteContext {
|
||||
field: string
|
||||
prefix: string
|
||||
keyword: string
|
||||
search: string
|
||||
operator: string
|
||||
startPos: number
|
||||
endPos: number
|
||||
isComplete: boolean
|
||||
}
|
||||
|
||||
interface SuggestionItem {
|
||||
id: number
|
||||
title?: string
|
||||
username?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type AutocompleteField = 'labels' | 'assignees' | 'projects'
|
||||
|
||||
export interface AutocompleteItem {
|
||||
id: number | string
|
||||
title: string
|
||||
item: ILabel | IUser | IProject
|
||||
fieldType: AutocompleteField
|
||||
}
|
||||
|
||||
export default Extension.create<FilterAutocompleteOptions>({
|
||||
name: 'filterAutocomplete',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
projectId: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const labelStore = useLabelStore()
|
||||
const projectStore = useProjectStore()
|
||||
const userService = new UserService()
|
||||
const projectUserService = new ProjectUserService()
|
||||
|
||||
let popupElement: HTMLElement | null = null
|
||||
let component: VueRenderer | null = null
|
||||
let currentAutocompleteContext: AutocompleteContext | null = null
|
||||
let cleanupFloating: (() => void) | null = null
|
||||
let suppressNextAutocomplete = false
|
||||
let clickOutsideHandler: ((event: MouseEvent) => void) | null = null
|
||||
let debounceTimer: NodeJS.Timeout | null = null
|
||||
let lastSelectionPosition = -1
|
||||
let lastSelectionTime = 0
|
||||
|
||||
const virtualReference = {
|
||||
getBoundingClientRect: () => ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
} as DOMRect),
|
||||
}
|
||||
|
||||
const isFilterExpressionComplete = (textAfterExpression: string, keyword: string, operator: string): boolean => {
|
||||
// If the keyword is empty, it's definitely not complete
|
||||
if (!keyword.trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if we're immediately after a recent selection
|
||||
const timeSinceLastSelection = Date.now() - lastSelectionTime
|
||||
if (timeSinceLastSelection < 1000) { // 1 second grace period
|
||||
return true
|
||||
}
|
||||
|
||||
// For multi-value operators, check if we're in the middle of typing multiple values
|
||||
if (isMultiValueOperator(operator) && keyword.includes(',')) {
|
||||
const lastValue = keyword.split(',').pop()?.trim() || ''
|
||||
// If the last value after comma is empty or very short, we're likely still typing
|
||||
return lastValue.length > 1
|
||||
}
|
||||
|
||||
// Check what comes after the expression
|
||||
const trimmedAfter = textAfterExpression.trim()
|
||||
|
||||
// If there's a logical operator or end of string immediately after, it's likely complete
|
||||
if (trimmedAfter === '' || trimmedAfter.startsWith('&&') || trimmedAfter.startsWith('||') || trimmedAfter.startsWith(')')) {
|
||||
return keyword.trim().length > 1
|
||||
}
|
||||
|
||||
// If there's a space followed by non-operator text, it's likely complete
|
||||
if (trimmedAfter.startsWith(' ') && !trimmedAfter.match(/^\s*[&|()]/)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const hidePopup = () => {
|
||||
if (popupElement) {
|
||||
popupElement.style.display = 'none'
|
||||
}
|
||||
currentAutocompleteContext = null
|
||||
|
||||
if (clickOutsideHandler) {
|
||||
document.removeEventListener('mousedown', clickOutsideHandler)
|
||||
clickOutsideHandler = null
|
||||
}
|
||||
|
||||
if (component) {
|
||||
component.updateProps({
|
||||
items: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const showPopup = () => {
|
||||
if (popupElement) {
|
||||
popupElement.style.display = 'block'
|
||||
|
||||
if (!clickOutsideHandler) {
|
||||
clickOutsideHandler = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
const editorElement = (this.editor?.view?.dom) as Node
|
||||
|
||||
if (popupElement?.contains(target) || editorElement?.contains(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
hidePopup()
|
||||
}
|
||||
document.addEventListener('mousedown', clickOutsideHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSuggestions = async (autocompleteContext: AutocompleteContext, fieldType: AutocompleteField): Promise<SuggestionItem[]> => {
|
||||
try {
|
||||
if (fieldType === 'labels') {
|
||||
return labelStore.filterLabelsByQuery([], autocompleteContext.search)
|
||||
}
|
||||
|
||||
if (fieldType === 'assignees') {
|
||||
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
debounceTimer = setTimeout(async () => {
|
||||
let assigneeSuggestions: SuggestionItem[] = []
|
||||
try {
|
||||
if (this.options.projectId) {
|
||||
assigneeSuggestions = await projectUserService.getAll({projectId: this.options.projectId}, {s: autocompleteContext.search})
|
||||
} else {
|
||||
assigneeSuggestions = await userService.getAll({}, {s: autocompleteContext.search})
|
||||
}
|
||||
// For assignees, show suggestions even with empty search, but limit if we have many
|
||||
if (autocompleteContext.search === '' && assigneeSuggestions.length > 10) {
|
||||
assigneeSuggestions = assigneeSuggestions.slice(0, 10)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching assignee suggestions:', error)
|
||||
assigneeSuggestions = []
|
||||
}
|
||||
resolve(assigneeSuggestions)
|
||||
}, 300)
|
||||
})
|
||||
}
|
||||
|
||||
if (fieldType === 'projects' && !this.options.projectId) {
|
||||
return projectStore.searchProject(autocompleteContext.search)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching suggestions:', error)
|
||||
return []
|
||||
}
|
||||
|
||||
console.error('Unknown field type:', fieldType)
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
const updatePosition = async () => {
|
||||
if (!popupElement) return
|
||||
await computePosition(virtualReference, popupElement, {
|
||||
placement: 'bottom-start',
|
||||
strategy: 'fixed',
|
||||
middleware: [
|
||||
offset(25),
|
||||
flip(),
|
||||
shift({padding: 8}),
|
||||
],
|
||||
}).then(({x, y}) => {
|
||||
if (popupElement) {
|
||||
popupElement.style.left = `${x}px`
|
||||
popupElement.style.top = `${y}px`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateAutocomplete = async (view: EditorView, force: boolean = false) => {
|
||||
const {from} = view.state.selection
|
||||
|
||||
if (suppressNextAutocomplete) {
|
||||
suppressNextAutocomplete = false
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we're too close to a recent selection (position-based suppression)
|
||||
if (lastSelectionPosition >= 0 && Math.abs(from - lastSelectionPosition) <= 2) {
|
||||
const timeSinceLastSelection = Date.now() - lastSelectionTime
|
||||
if (timeSinceLastSelection < 500) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const text = view.state.doc.textContent
|
||||
const textUpToCursor = text.substring(0, from)
|
||||
|
||||
let autocompleteContext: AutocompleteContext | null = null
|
||||
let fieldType: AutocompleteField | null = null
|
||||
|
||||
for (const field of AUTOCOMPLETE_FIELDS) {
|
||||
const pattern = new RegExp(`(${field}\\s*${FILTER_OPERATORS_REGEX}\\s*)(["']?)([^"'&|()]*)?$`, 'ig')
|
||||
const match = pattern.exec(textUpToCursor)
|
||||
|
||||
if (match) {
|
||||
const [, prefix, , , keyword = ''] = match
|
||||
|
||||
let search = keyword.trim()
|
||||
const operator = match[0].match(new RegExp(FILTER_OPERATORS_REGEX))?.[0] || ''
|
||||
if (operator === 'in' || operator === '?=') {
|
||||
const keywords = keyword.split(',')
|
||||
search = keywords[keywords.length - 1].trim()
|
||||
}
|
||||
|
||||
// Check if this expression is complete
|
||||
const textAfterExpression = text.substring(from)
|
||||
const isComplete = isFilterExpressionComplete(textAfterExpression, keyword, operator)
|
||||
|
||||
autocompleteContext = {
|
||||
field,
|
||||
prefix,
|
||||
keyword,
|
||||
search,
|
||||
operator,
|
||||
startPos: match.index + prefix.length,
|
||||
endPos: match.index + prefix.length + keyword.length,
|
||||
isComplete,
|
||||
}
|
||||
|
||||
if (LABEL_FIELDS.includes(field)) {
|
||||
fieldType = 'labels'
|
||||
} else if (ASSIGNEE_FIELDS.includes(field)) {
|
||||
fieldType = 'assignees'
|
||||
} else if (PROJECT_FIELDS.includes(field)) {
|
||||
fieldType = 'projects'
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no autocomplete context or same context, and not forced, return
|
||||
if (!force && currentAutocompleteContext === autocompleteContext) {
|
||||
return
|
||||
}
|
||||
|
||||
currentAutocompleteContext = autocompleteContext
|
||||
|
||||
if (!autocompleteContext || !fieldType || autocompleteContext.isComplete) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
|
||||
const suggestions = await fetchSuggestions(autocompleteContext, fieldType)
|
||||
|
||||
const items = suggestions.map(item => ({
|
||||
id: item.id,
|
||||
title: fieldType === 'assignees' ? item.username : item.title,
|
||||
description: fieldType === 'assignees' ? `${item.name || item.username}` : item.title,
|
||||
item,
|
||||
fieldType,
|
||||
context: autocompleteContext,
|
||||
}))
|
||||
|
||||
if (items.length === 0) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
|
||||
if (!component) {
|
||||
component = new VueRenderer(FilterCommandsList, {
|
||||
props: {
|
||||
items,
|
||||
command: (item: AutocompleteItem) => {
|
||||
// Handle selection
|
||||
const newValue = item.fieldType === 'assignees' ? item.item.username : item.item.title
|
||||
const {from} = view.state.selection
|
||||
const context = autocompleteContext
|
||||
const operator = context.operator
|
||||
|
||||
let insertValue: string = newValue ?? ''
|
||||
const replaceFrom = Math.max(0, from - context.search.length)
|
||||
const replaceTo = from
|
||||
|
||||
// Handle multi-value operators
|
||||
if (isMultiValueOperator(operator) && context.keyword.includes(',')) {
|
||||
// For multi-value fields, we need to replace only the current search term
|
||||
const keywords = context.keyword.split(',')
|
||||
const currentKeywordIndex = keywords.length - 1
|
||||
|
||||
// If we're not adding the first item, add comma prefix
|
||||
if (currentKeywordIndex > 0 && keywords[currentKeywordIndex].trim() === context.search.trim()) {
|
||||
// We're replacing the last incomplete keyword
|
||||
insertValue = newValue ?? ''
|
||||
} else {
|
||||
// We're adding to existing keywords
|
||||
insertValue = ',' + newValue
|
||||
}
|
||||
}
|
||||
|
||||
const tr = view.state.tr.replaceWith(
|
||||
replaceFrom,
|
||||
replaceTo,
|
||||
view.state.schema.text(insertValue),
|
||||
)
|
||||
// Position cursor after the inserted text
|
||||
const newPos = replaceFrom + insertValue.length
|
||||
tr.setSelection(view.state.selection.constructor.near(tr.doc.resolve(newPos)))
|
||||
view.dispatch(tr)
|
||||
|
||||
// Update selection tracking
|
||||
lastSelectionPosition = newPos
|
||||
lastSelectionTime = Date.now()
|
||||
|
||||
// Return focus to editor and position cursor
|
||||
setTimeout(() => {
|
||||
view.focus()
|
||||
}, 0)
|
||||
|
||||
// For multi-value operators, don't suppress autocomplete to keep dropdown open
|
||||
if (isMultiValueOperator(operator)) {
|
||||
// Add comma and space for next entry if not already present
|
||||
setTimeout(() => {
|
||||
const currentText = view.state.doc.textContent
|
||||
const currentPos = view.state.selection.from
|
||||
if (currentText.charAt(currentPos) !== ',') {
|
||||
const tr = view.state.tr.insertText(',', currentPos)
|
||||
view.dispatch(tr)
|
||||
// Update position after comma insertion
|
||||
lastSelectionPosition = currentPos + 1
|
||||
lastSelectionTime = Date.now()
|
||||
}
|
||||
}, 10)
|
||||
} else {
|
||||
suppressNextAutocomplete = true
|
||||
hidePopup()
|
||||
}
|
||||
},
|
||||
},
|
||||
editor: this.editor,
|
||||
})
|
||||
} else {
|
||||
component.updateProps({
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
// Create popup element on demand
|
||||
if (!popupElement) {
|
||||
popupElement = document.createElement('div')
|
||||
popupElement.style.position = 'fixed'
|
||||
popupElement.style.top = '0'
|
||||
popupElement.style.left = '0'
|
||||
popupElement.style.zIndex = '20000'
|
||||
popupElement.id = 'filter-autocomplete-popup'
|
||||
popupElement.appendChild(component.element!)
|
||||
document.body.appendChild(popupElement)
|
||||
|
||||
cleanupFloating = autoUpdate(virtualReference, popupElement, updatePosition)
|
||||
}
|
||||
|
||||
// Update virtual reference to start of the current search token
|
||||
const anchorFrom = autocompleteContext ? Math.max(0, from - (autocompleteContext.search?.length || 0)) : from
|
||||
const coords = view.coordsAtPos(anchorFrom)
|
||||
const rect = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: coords.left,
|
||||
y: coords.top,
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
right: coords.left,
|
||||
bottom: coords.bottom,
|
||||
} as DOMRect
|
||||
virtualReference.getBoundingClientRect = () => rect
|
||||
|
||||
showPopup()
|
||||
await updatePosition()
|
||||
}
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('filterAutocomplete'),
|
||||
view() {
|
||||
return {
|
||||
update: (view, prevState) => {
|
||||
// Only update if the document or selection changed
|
||||
if (
|
||||
!prevState ||
|
||||
!view.state.doc.eq(prevState.doc) ||
|
||||
!view.state.selection.eq(prevState.selection)
|
||||
) {
|
||||
setTimeout(() => updateAutocomplete(view), 0)
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
if (cleanupFloating) {
|
||||
cleanupFloating()
|
||||
}
|
||||
if (clickOutsideHandler) {
|
||||
document.removeEventListener('mousedown', clickOutsideHandler)
|
||||
clickOutsideHandler = null
|
||||
}
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
if (popupElement && popupElement.parentNode) {
|
||||
popupElement.parentNode.removeChild(popupElement)
|
||||
}
|
||||
popupElement = null
|
||||
suppressNextAutocomplete = false
|
||||
currentAutocompleteContext = null
|
||||
lastSelectionPosition = -1
|
||||
lastSelectionTime = 0
|
||||
if (component) {
|
||||
component.destroy()
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
props: {
|
||||
handleKeyDown(view, event) {
|
||||
if (!popupElement || popupElement.style.display === 'none') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Forward key events to the component
|
||||
if ((component as VueRenderer & {ref?: {onKeyDown?: (params: {event: KeyboardEvent}) => boolean}})?.ref?.onKeyDown) {
|
||||
return (component as VueRenderer & {ref: {onKeyDown: (params: {event: KeyboardEvent}) => boolean}}).ref.onKeyDown({event})
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
<template>
|
||||
<div class="filter-autocompletes">
|
||||
<template v-if="items.length">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="`${item.fieldType}-${item.id}`"
|
||||
class="filter-autocomplete"
|
||||
:class="{ 'is-selected': index === selectedIndex }"
|
||||
@click="selectItem(index)"
|
||||
>
|
||||
<div class="filter-autocomplete__content">
|
||||
<XLabel
|
||||
v-if="item.fieldType === 'labels'"
|
||||
:label="(item.item as unknown as ILabel)"
|
||||
class="filter-autocomplete__label"
|
||||
/>
|
||||
<User
|
||||
v-else-if="item.fieldType === 'assignees'"
|
||||
:user="(item.item as unknown as IUser)"
|
||||
:avatar-size="20"
|
||||
class="filter-autocomplete__user"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="filter-autocomplete__project"
|
||||
>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="filter-autocomplete no-results"
|
||||
>
|
||||
{{ $t('filters.noResults') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import XLabel from '@/components/tasks/partials/Label.vue'
|
||||
import User from '@/components/misc/User.vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import type { ILabel } from '@/modelTypes/ILabel'
|
||||
import type { IUser } from '@/modelTypes/IUser'
|
||||
import type { AutocompleteItem } from './FilterAutocomplete'
|
||||
|
||||
interface Props {
|
||||
items: AutocompleteItem[]
|
||||
command: (item: AutocompleteItem) => void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const selectedIndex = ref(0)
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
selectedIndex.value = 0
|
||||
},
|
||||
)
|
||||
|
||||
function onKeyDown({event}: { event: KeyboardEvent }) {
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
upHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
downHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
enterHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function upHandler() {
|
||||
selectedIndex.value = ((selectedIndex.value + props.items.length) - 1) % props.items.length
|
||||
}
|
||||
|
||||
function downHandler() {
|
||||
selectedIndex.value = (selectedIndex.value + 1) % props.items.length
|
||||
}
|
||||
|
||||
function enterHandler() {
|
||||
selectItem(selectedIndex.value)
|
||||
}
|
||||
|
||||
function selectItem(index: number) {
|
||||
const item = props.items[index]
|
||||
if (item) {
|
||||
props.command(item)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
onKeyDown,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter-autocompletes {
|
||||
position: relative;
|
||||
border-radius: $radius;
|
||||
background: var(--white);
|
||||
color: var(--grey-900);
|
||||
overflow: hidden;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--grey-200);
|
||||
max-block-size: 12rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.filter-autocomplete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
inline-size: 100%;
|
||||
text-align: start;
|
||||
background: transparent;
|
||||
border-radius: $radius;
|
||||
border: 0;
|
||||
padding: 0.375rem 0.5rem;
|
||||
transition: background-color var(--transition);
|
||||
cursor: pointer;
|
||||
|
||||
&.is-selected,
|
||||
&:hover {
|
||||
background: var(--grey-100);
|
||||
}
|
||||
|
||||
&.no-results {
|
||||
color: var(--grey-500);
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-autocomplete__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.filter-autocomplete__label {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-autocomplete__user {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.filter-autocomplete__project {
|
||||
color: var(--grey-800);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
<script setup lang="ts">
|
||||
import {onBeforeUnmount, onMounted, ref, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import DatepickerWithValues from '@/components/date/DatepickerWithValues.vue'
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {
|
||||
transformFilterStringForApi,
|
||||
transformFilterStringFromApi,
|
||||
} from '@/helpers/filters'
|
||||
import {useDebounceFn} from '@vueuse/core'
|
||||
|
||||
import {EditorContent, useEditor} from '@tiptap/vue-3'
|
||||
import {Extension} from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import {Placeholder} from '@tiptap/extensions'
|
||||
import {Plugin, PluginKey} from '@tiptap/pm/state'
|
||||
import {filterHighlighter} from '@/components/input/filter/highlighter.ts'
|
||||
import FilterAutocomplete from '@/components/input/filter/FilterAutocomplete'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId?: IProject['id'],
|
||||
modelValue?: string,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const {t} = useI18n()
|
||||
|
||||
// Services and stores for autocomplete
|
||||
const labelStore = useLabelStore()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
// Date picker functionality
|
||||
const currentOldDatepickerValue = ref('')
|
||||
const currentDatepickerValue = ref('')
|
||||
const currentDatepickerPos = ref(0)
|
||||
const datePickerPopupOpen = ref(false)
|
||||
|
||||
// Create a custom extension for filter syntax highlighting
|
||||
const FilterHighlighter = Extension.create({
|
||||
name: 'filterHighlighter',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
filterHighlighter,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
// Create a custom extension for handling date clicks
|
||||
const DateClickHandler = Extension.create({
|
||||
name: 'dateClickHandler',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('dateClickHandler'),
|
||||
props: {
|
||||
handleClick: (view, pos, event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.classList.contains('date-value')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const dateValue = target.getAttribute('data-date-value') || ''
|
||||
const position = parseInt(target.getAttribute('data-position') || '0')
|
||||
|
||||
currentOldDatepickerValue.value = dateValue
|
||||
currentDatepickerValue.value = dateValue
|
||||
currentDatepickerPos.value = position
|
||||
datePickerPopupOpen.value = true
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
// Initialize TipTap editor
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
history: false, // We'll handle history ourselves
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: t('filters.query.placeholder'),
|
||||
}),
|
||||
FilterHighlighter,
|
||||
DateClickHandler,
|
||||
FilterAutocomplete.configure({
|
||||
get projectId() {
|
||||
return props.projectId
|
||||
},
|
||||
}),
|
||||
Extension.create({
|
||||
name: 'enterHandler',
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Enter': () => {
|
||||
const popup = document.getElementById('filter-autocomplete-popup')
|
||||
const isAutocompleteVisible = popup && popup.style.display !== 'none'
|
||||
|
||||
if (isAutocompleteVisible) {
|
||||
// Let the autocomplete handle the Enter key
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
content: '',
|
||||
onUpdate: ({editor}) => {
|
||||
const content = editor.getText()
|
||||
emit('update:modelValue', processContent(content))
|
||||
},
|
||||
})
|
||||
|
||||
// Process the editor content to output snake_cased filter
|
||||
const processContent = (content: string) => {
|
||||
return transformFilterStringForApi(
|
||||
content,
|
||||
labelTitle => labelStore.getLabelByExactTitle(labelTitle)?.id || null,
|
||||
projectTitle => {
|
||||
const found = projectStore.findProjectByExactname(projectTitle)
|
||||
return found?.id || null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Watch for changes to the model value
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
value => setEditorContentFromModelValue(value),
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
onMounted(() => setEditorContentFromModelValue(props.modelValue))
|
||||
|
||||
function setEditorContentFromModelValue(newValue: string | undefined) {
|
||||
if (!editor.value) return
|
||||
|
||||
const content = newValue ? transformFilterStringFromApi(
|
||||
newValue,
|
||||
labelId => labelStore.getLabelById(labelId)?.title || null,
|
||||
projectId => projectStore.projects[projectId]?.title || null,
|
||||
) : ''
|
||||
|
||||
if (editor.value.getText() !== content) {
|
||||
editor.value.commands.setContent(content, {
|
||||
emitUpdate: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function updateDateInQuery(newDate: string | Date | null) {
|
||||
if (!editor.value || !newDate) return
|
||||
|
||||
const dateStr = typeof newDate === 'string' ? newDate : newDate.toISOString().split('T')[0]
|
||||
const currentText = editor.value.getText()
|
||||
const newText = currentText.replace(currentOldDatepickerValue.value, dateStr)
|
||||
currentOldDatepickerValue.value = dateStr
|
||||
|
||||
editor.value.commands.setContent(newText, {
|
||||
emitUpdate: false,
|
||||
})
|
||||
emit('update:modelValue', processContent(newText))
|
||||
}
|
||||
|
||||
|
||||
// The blur from the editor might happen before the replacement after autocomplete select was done.
|
||||
const blurDebounced = useDebounceFn(() => {
|
||||
}, 500)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-input">
|
||||
<div
|
||||
class="editor-wrapper"
|
||||
@blur="blurDebounced"
|
||||
>
|
||||
<EditorContent
|
||||
:editor="editor"
|
||||
class="editor-content"
|
||||
/>
|
||||
</div>
|
||||
<DatepickerWithValues
|
||||
v-model="currentDatepickerValue"
|
||||
v-model:open="datePickerPopupOpen"
|
||||
class="filter-datepicker"
|
||||
:ignore-click-classes="['date-value']"
|
||||
@update:modelValue="updateDateInQuery"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.filter-input {
|
||||
border: 1px solid var(--input-border-color);
|
||||
border-radius: var(--input-radius);
|
||||
background: var(--white);
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.filter-datepicker {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
line-height: 1.5;
|
||||
padding: .5rem .75rem;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
white-space: pre-wrap;
|
||||
padding: 0 !important;
|
||||
|
||||
.field {
|
||||
color: var(--code-literal);
|
||||
}
|
||||
|
||||
.operator {
|
||||
color: var(--code-keyword);
|
||||
}
|
||||
|
||||
.value {
|
||||
border-radius: $radius;
|
||||
padding: .125rem .25rem;
|
||||
background: var(--grey-100);
|
||||
}
|
||||
|
||||
.label-value {
|
||||
border-radius: $radius;
|
||||
padding: .125rem .25rem;
|
||||
}
|
||||
|
||||
.date-value {
|
||||
background-color: var(--primary);
|
||||
color: var(--white);
|
||||
border-radius: $radius;
|
||||
padding: 0.125em 0.25em;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.grouping, .logical {
|
||||
color: var(--code-section);
|
||||
}
|
||||
|
||||
.user-value {
|
||||
position: relative;
|
||||
padding-inline-start: 1.5em;
|
||||
|
||||
&::before {
|
||||
content: attr(data-user);
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
inset-block-start: 50%;
|
||||
transform: translateY(-50%);
|
||||
inline-size: 1.2em;
|
||||
block-size: 1.2em;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 0.8em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
p.is-editor-empty:first-child::before {
|
||||
color: var(--grey-500);
|
||||
content: attr(data-placeholder);
|
||||
float: inline-start;
|
||||
block-size: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
import {EditorState, Plugin, PluginKey, Transaction} from '@tiptap/pm/state'
|
||||
import {Decoration, DecorationSet} from '@tiptap/pm/view'
|
||||
import {
|
||||
AVAILABLE_FILTER_FIELDS,
|
||||
DATE_FIELDS,
|
||||
FILTER_JOIN_OPERATOR,
|
||||
FILTER_OPERATORS,
|
||||
getFilterFieldRegexPattern,
|
||||
LABEL_FIELDS,
|
||||
} from '@/helpers/filters'
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark.ts'
|
||||
import {Node} from '@tiptap/pm/model'
|
||||
|
||||
export const filterHighlighter = new Plugin({
|
||||
key: new PluginKey('filterHighlighter'),
|
||||
state: {
|
||||
init(_, state: EditorState) {
|
||||
return decorateDocument(state.doc)
|
||||
},
|
||||
apply(tr: Transaction, oldState) {
|
||||
if (!tr.docChanged) return oldState
|
||||
|
||||
return decorateDocument(tr.doc)
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function decorateDocument(doc: Node) {
|
||||
const decorations: Decoration[] = []
|
||||
|
||||
const text = doc.textContent
|
||||
|
||||
const labelStore = useLabelStore()
|
||||
|
||||
const fieldRegex = new RegExp(`\\b(${AVAILABLE_FILTER_FIELDS.join('|')})\\b`, 'g')
|
||||
const operatorRegex = new RegExp(FILTER_OPERATORS_REGEX, 'g')
|
||||
const logicalRegex = new RegExp(`(${FILTER_JOIN_OPERATOR.map(op => op.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'g')
|
||||
const fieldValueRegex = new RegExp(
|
||||
`(${AVAILABLE_FILTER_FIELDS.join('|')})\\s*(${FILTER_OPERATORS.map(op => op.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*([^\\s&|()]+)`,
|
||||
'gi',
|
||||
)
|
||||
|
||||
let match
|
||||
|
||||
const valueRanges: Array<{ start: number, end: number }> = []
|
||||
|
||||
DATE_FIELDS.forEach(dateField => {
|
||||
const pattern = getFilterFieldRegexPattern(dateField)
|
||||
let dateMatch
|
||||
while ((dateMatch = pattern.exec(text)) !== null) {
|
||||
if (dateMatch[4]) { // If there's a value
|
||||
const valueText = dateMatch[4].trim()
|
||||
const valueStart = dateMatch.index + dateMatch[0].indexOf(dateMatch[4])
|
||||
const valueEnd = valueStart + dateMatch[4].length
|
||||
|
||||
const from = findPosForIndex(doc, valueStart)
|
||||
const to = findPosForIndex(doc, valueEnd)
|
||||
|
||||
if (from !== null && to !== null) {
|
||||
decorations.push(
|
||||
Decoration.inline(from, to, {
|
||||
class: 'date-value',
|
||||
'data-date-value': valueText,
|
||||
'data-position': valueStart.toString(),
|
||||
}),
|
||||
)
|
||||
valueRanges.push({start: valueStart, end: valueEnd})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
LABEL_FIELDS.forEach(labelField => {
|
||||
const pattern = getFilterFieldRegexPattern(labelField)
|
||||
let labelMatch
|
||||
while ((labelMatch = pattern.exec(text)) !== null) {
|
||||
const labelValue = labelMatch[5]?.trim()
|
||||
const operator = labelMatch[2]?.trim()
|
||||
|
||||
if (!labelValue) {
|
||||
continue
|
||||
}
|
||||
|
||||
const valueStart = labelMatch.index + labelMatch[0].lastIndexOf(labelValue)
|
||||
const valueEnd = valueStart + labelValue.length
|
||||
|
||||
const addLabelDecoration = (labelValue: string, start: number, end: number) => {
|
||||
const label = labelStore.getLabelByExactTitle(labelValue)
|
||||
|
||||
const from = findPosForIndex(doc, start)
|
||||
const to = findPosForIndex(doc, end)
|
||||
|
||||
if (from === null || to === null) {
|
||||
return
|
||||
}
|
||||
|
||||
valueRanges.push({start, end})
|
||||
|
||||
if (label) {
|
||||
// Use label color if found
|
||||
decorations.push(
|
||||
Decoration.inline(from, to, {
|
||||
class: 'label-value',
|
||||
style: `background-color: ${label.hexColor}; color: ${label.hexColor && colorIsDark(label.hexColor) ? 'white' : 'black'};`,
|
||||
}),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to generic value styling
|
||||
decorations.push(
|
||||
Decoration.inline(from, to, {class: 'value'}),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if this is a multi-value operator and the value contains commas
|
||||
const isMultiValueOperator = ['in', '?=', 'not in', '?!='].includes(operator)
|
||||
if (isMultiValueOperator && labelValue.includes(',')) {
|
||||
// Split by commas and create decorations for each individual label
|
||||
const labels = labelValue.split(',').map(l => l.trim()).filter(l => l.length > 0)
|
||||
let currentOffset = 0
|
||||
|
||||
labels.forEach(individualLabel => {
|
||||
// Find the position of this individual label within the full value
|
||||
const labelIndex = labelValue.indexOf(individualLabel, currentOffset)
|
||||
if (labelIndex !== -1) {
|
||||
const individualStart = valueStart + labelIndex
|
||||
const individualEnd = individualStart + individualLabel.length
|
||||
|
||||
addLabelDecoration(individualLabel, individualStart, individualEnd)
|
||||
|
||||
currentOffset = labelIndex + individualLabel.length
|
||||
}
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
addLabelDecoration(labelValue, valueStart, valueEnd)
|
||||
}
|
||||
})
|
||||
|
||||
// Match other values - anything coming after an operator (excluding labels)
|
||||
fieldValueRegex.lastIndex = 0
|
||||
while ((match = fieldValueRegex.exec(text)) !== null) {
|
||||
const [fullMatch, field, operator, value] = match
|
||||
|
||||
if (LABEL_FIELDS.includes(field) || DATE_FIELDS.includes(field)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (value && value.trim()) {
|
||||
// Calculate the actual position of the value by finding where it starts after the operator
|
||||
const fieldLength = field.length
|
||||
const operatorIndex = fullMatch.indexOf(operator, fieldLength)
|
||||
const operatorEnd = operatorIndex + operator.length
|
||||
const valueIndex = fullMatch.indexOf(value, operatorEnd)
|
||||
|
||||
const valueStart = match.index + valueIndex
|
||||
const valueEnd = valueStart + value.length
|
||||
|
||||
const from = findPosForIndex(doc, valueStart)
|
||||
const to = findPosForIndex(doc, valueEnd)
|
||||
|
||||
if (from !== null && to !== null) {
|
||||
decorations.push(
|
||||
Decoration.inline(from, to, {class: 'value'}),
|
||||
)
|
||||
valueRanges.push({start: valueStart, end: valueEnd})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a range overlaps with any value range
|
||||
const overlapsWithValue = (start: number, end: number): boolean => {
|
||||
return valueRanges.some(range =>
|
||||
(start >= range.start && start < range.end) ||
|
||||
(end > range.start && end <= range.end) ||
|
||||
(start <= range.start && end >= range.end),
|
||||
)
|
||||
}
|
||||
|
||||
// Match fields (excluding those within value ranges)
|
||||
fieldRegex.lastIndex = 0
|
||||
while ((match = fieldRegex.exec(text)) !== null) {
|
||||
const start = match.index
|
||||
const end = start + match[0].length
|
||||
|
||||
// Skip if this field match is within a value range
|
||||
if (overlapsWithValue(start, end)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const from = findPosForIndex(doc, start)
|
||||
const to = findPosForIndex(doc, end)
|
||||
|
||||
if (from !== null && to !== null) {
|
||||
decorations.push(
|
||||
Decoration.inline(from, to, {class: 'field'}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Match operators
|
||||
operatorRegex.lastIndex = 0
|
||||
while ((match = operatorRegex.exec(text)) !== null) {
|
||||
const start = match.index
|
||||
const end = start + match[0].length
|
||||
|
||||
const from = findPosForIndex(doc, start)
|
||||
const to = findPosForIndex(doc, end)
|
||||
|
||||
if (from !== null && to !== null) {
|
||||
decorations.push(
|
||||
Decoration.inline(from, to, {class: 'operator'}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Match logical operators
|
||||
logicalRegex.lastIndex = 0
|
||||
while ((match = logicalRegex.exec(text)) !== null) {
|
||||
const start = match.index
|
||||
const end = start + match[0].length
|
||||
|
||||
const from = findPosForIndex(doc, start)
|
||||
const to = findPosForIndex(doc, end)
|
||||
|
||||
if (from !== null && to !== null) {
|
||||
decorations.push(
|
||||
Decoration.inline(from, to, {class: 'logical'}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
// Helper function to find the position in the document for a given text index
|
||||
function findPosForIndex(doc: {
|
||||
descendants: (fn: (node: { isText: boolean, text: string }, nodePos: number) => boolean | void) => void
|
||||
}, index: number): number | null {
|
||||
let pos = 0
|
||||
let found = false
|
||||
let textIndex = 0
|
||||
|
||||
doc.descendants((node: { isText: boolean, text: string }, nodePos: number) => {
|
||||
if (found) return false
|
||||
|
||||
if (node.isText) {
|
||||
const endIndex = textIndex + node.text.length
|
||||
|
||||
if (textIndex <= index && index <= endIndex) {
|
||||
pos = nodePos + (index - textIndex)
|
||||
found = true
|
||||
return false
|
||||
}
|
||||
|
||||
textIndex = endIndex
|
||||
}
|
||||
})
|
||||
|
||||
return found ? pos : null
|
||||
}
|
||||
|
|
@ -29,9 +29,11 @@ import {onClickOutside} from '@vueuse/core'
|
|||
const props = withDefaults(defineProps<{
|
||||
hasOverflow?: boolean
|
||||
open?: boolean
|
||||
ignoreClickClasses?: string[]
|
||||
}>(), {
|
||||
hasOverflow: false,
|
||||
open: false,
|
||||
ignoreClickClasses: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -73,7 +75,14 @@ function toggle() {
|
|||
|
||||
const popup = ref<HTMLElement | null>(null)
|
||||
|
||||
onClickOutside(popup, () => close())
|
||||
onClickOutside(popup, (event) => {
|
||||
const target = event.target as HTMLElement
|
||||
// Check if the click target has any of the ignored classes
|
||||
if (target?.classList && props.ignoreClickClasses.some(className => target.classList.contains(className))) {
|
||||
return
|
||||
}
|
||||
close()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import {computed, nextTick, ref, watch} from 'vue'
|
||||
import {useAutoHeightTextarea} from '@/composables/useAutoHeightTextarea'
|
||||
import {ref, onMounted, onBeforeUnmount} from 'vue'
|
||||
import DatepickerWithValues from '@/components/date/DatepickerWithValues.vue'
|
||||
import UserService from '@/services/user'
|
||||
import AutocompleteDropdown from '@/components/input/AutocompleteDropdown.vue'
|
||||
|
|
@ -23,6 +22,14 @@ import {
|
|||
import {useDebounceFn} from '@vueuse/core'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
|
||||
// ProseMirror imports
|
||||
import {EditorView, Decoration, DecorationSet} from '@tiptap/pm/view'
|
||||
import {EditorState, Plugin, PluginKey} from '@tiptap/pm/state'
|
||||
import {Schema} from '@tiptap/pm/model'
|
||||
import {keymap} from '@tiptap/pm/keymap'
|
||||
import {history, undo, redo} from '@tiptap/pm/history'
|
||||
import {baseKeymap} from '@tiptap/pm/commands'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string,
|
||||
projectId?: number,
|
||||
|
|
@ -39,179 +46,260 @@ const projectUserService = new ProjectUserService()
|
|||
const labelStore = useLabelStore()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const filterQuery = ref<string>('')
|
||||
const {
|
||||
textarea: filterInput,
|
||||
height,
|
||||
} = useAutoHeightTextarea(filterQuery)
|
||||
|
||||
const editorRef = ref<HTMLDivElement | null>(null)
|
||||
const editor = ref<EditorView | null>(null)
|
||||
const id = ref(createRandomID())
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
filterQuery.value = props.modelValue
|
||||
|
||||
// Simple schema for plain text with highlighting
|
||||
const filterSchema = new Schema({
|
||||
nodes: {
|
||||
doc: {
|
||||
content: 'paragraph*',
|
||||
},
|
||||
paragraph: {
|
||||
content: 'text*',
|
||||
group: 'block',
|
||||
parseDOM: [{tag: 'p'}],
|
||||
toDOM() { return ['p', 0] },
|
||||
},
|
||||
text: {
|
||||
group: 'inline',
|
||||
},
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filterQuery.value,
|
||||
() => {
|
||||
if (filterQuery.value !== props.modelValue) {
|
||||
emit('update:modelValue', filterQuery.value)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function escapeHtml(unsafe: string|null|undefined): string {
|
||||
if (!unsafe) {
|
||||
return ''
|
||||
}
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function unEscapeHtml(unsafe: string|null|undefined): string {
|
||||
if (!unsafe) {
|
||||
return ''
|
||||
}
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, '\'')
|
||||
}
|
||||
|
||||
const highlightedFilterQuery = computed(() => {
|
||||
if (filterQuery.value === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
let highlighted = escapeHtml(filterQuery.value)
|
||||
DATE_FIELDS
|
||||
.forEach(o => {
|
||||
const pattern = new RegExp(o + '(\\s*)' + FILTER_OPERATORS_REGEX + '(\\s*)([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
|
||||
highlighted = highlighted.replaceAll(pattern, (match, spacesBefore, token, spacesAfter, start, value, position) => {
|
||||
if (typeof value === 'undefined') {
|
||||
value = ''
|
||||
}
|
||||
|
||||
let endPadding = ''
|
||||
if (value.endsWith(' ')) {
|
||||
const fullLength = value.length
|
||||
value = value.trimEnd()
|
||||
const numberOfRemovedSpaces = fullLength - value.length
|
||||
endPadding = endPadding.padEnd(numberOfRemovedSpaces, ' ')
|
||||
}
|
||||
|
||||
return `${o}${spacesBefore}${token}${spacesAfter}<button class="is-primary filter-query__date_value" data-position="${position}">${value}</button><span class="filter-query__date_value_placeholder">${value}</span>${endPadding}`
|
||||
})
|
||||
})
|
||||
ASSIGNEE_FIELDS
|
||||
.forEach(f => {
|
||||
const pattern = new RegExp(f + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
|
||||
highlighted = highlighted.replaceAll(pattern, (match, token, start, value) => {
|
||||
if (typeof value === 'undefined') {
|
||||
value = ''
|
||||
}
|
||||
|
||||
return `${f} ${token} <span class="filter-query__assignee_value">${value}<span>`
|
||||
})
|
||||
})
|
||||
FILTER_JOIN_OPERATOR
|
||||
.map(o => escapeHtml(o))
|
||||
.forEach(o => {
|
||||
highlighted = highlighted.replaceAll(o, `<span class="filter-query__join-operator">${o}</span>`)
|
||||
})
|
||||
LABEL_FIELDS
|
||||
.forEach(f => {
|
||||
const pattern = getFilterFieldRegexPattern(f)
|
||||
highlighted = highlighted.replaceAll(pattern, (match, prefix, operator, space, value) => {
|
||||
|
||||
if (typeof value === 'undefined') {
|
||||
value = ''
|
||||
}
|
||||
|
||||
let labelTitles = [value.trim()]
|
||||
if (operator === 'in' || operator === '?=' || operator === 'not in' || operator === '?!=') {
|
||||
labelTitles = value.split(',').map(v => v.trim())
|
||||
}
|
||||
|
||||
const labelsHtml: string[] = []
|
||||
labelTitles.forEach(t => {
|
||||
const label = labelStore.getLabelByExactTitle(t) || undefined
|
||||
labelsHtml.push(`<span class="filter-query__label_value" style="background-color: ${label?.hexColor}; color: ${label?.textColor}">${label?.title ?? t}</span>`)
|
||||
})
|
||||
|
||||
const endSpace = value.endsWith(' ') ? ' ' : ''
|
||||
return `${f} ${operator} ${labelsHtml.join(', ')}${endSpace}`
|
||||
})
|
||||
})
|
||||
FILTER_OPERATORS
|
||||
.map(o => ` ${escapeHtml(o)} `)
|
||||
.forEach(o => {
|
||||
highlighted = highlighted.replaceAll(o, `<span class="filter-query__operator">${o}</span>`)
|
||||
})
|
||||
AVAILABLE_FILTER_FIELDS.forEach(f => {
|
||||
highlighted = highlighted.replaceAll(f, `<span class="filter-query__field">${f}</span>`)
|
||||
})
|
||||
return highlighted
|
||||
marks: {},
|
||||
})
|
||||
|
||||
// Plugin for syntax highlighting
|
||||
function createHighlightPlugin() {
|
||||
return new Plugin({
|
||||
key: new PluginKey('filterHighlight'),
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty
|
||||
},
|
||||
apply(tr, decorationSet) {
|
||||
if (!tr.docChanged) {
|
||||
return decorationSet.map(tr.mapping, tr.doc)
|
||||
}
|
||||
return createDecorations(tr.doc)
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createDecorations(doc: {textContent: string}) {
|
||||
const decorations: Decoration[] = []
|
||||
const text = doc.textContent
|
||||
|
||||
// Helper function to add decoration
|
||||
const addDecoration = (from: number, to: number, className: string, attributes = {}) => {
|
||||
if (from < to && from >= 0 && to <= text.length) {
|
||||
decorations.push(
|
||||
Decoration.inline(from, to, {
|
||||
class: className,
|
||||
...attributes,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Highlight filter fields
|
||||
AVAILABLE_FILTER_FIELDS.forEach(field => {
|
||||
const regex = new RegExp(`\\b${field}\\b`, 'gi')
|
||||
let match
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
addDecoration(match.index, match.index + match[0].length, 'filter-field')
|
||||
}
|
||||
})
|
||||
|
||||
// Highlight operators
|
||||
FILTER_OPERATORS.forEach(op => {
|
||||
const escapedOp = op.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const regex = new RegExp(`\\s(${escapedOp})\\s`, 'gi')
|
||||
let match
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
addDecoration(match.index + 1, match.index + 1 + match[1].length, 'filter-operator')
|
||||
}
|
||||
})
|
||||
|
||||
// Highlight join operators
|
||||
FILTER_JOIN_OPERATOR.forEach(joinOp => {
|
||||
const regex = new RegExp(`\\b${joinOp}\\b`, 'gi')
|
||||
let match
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
addDecoration(match.index, match.index + match[0].length, 'filter-join-operator')
|
||||
}
|
||||
})
|
||||
|
||||
// Highlight date values with clickable decoration
|
||||
DATE_FIELDS.forEach(dateField => {
|
||||
const pattern = new RegExp(dateField + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'gi')
|
||||
let match
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match[3]) { // If there's a value
|
||||
const valueStart = match.index + match[0].indexOf(match[3])
|
||||
const valueEnd = valueStart + match[3].length
|
||||
addDecoration(valueStart, valueEnd, 'filter-date-value', {
|
||||
'data-date-value': match[3],
|
||||
'data-position': valueStart.toString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Highlight assignee values
|
||||
ASSIGNEE_FIELDS.forEach(assigneeField => {
|
||||
const pattern = new RegExp(assigneeField + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'gi')
|
||||
let match
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match[3]) { // If there's a value
|
||||
const valueStart = match.index + match[0].indexOf(match[3])
|
||||
const valueEnd = valueStart + match[3].length
|
||||
addDecoration(valueStart, valueEnd, 'filter-assignee-value')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Highlight label values with colors (simplified)
|
||||
LABEL_FIELDS.forEach(labelField => {
|
||||
const pattern = getFilterFieldRegexPattern(labelField)
|
||||
let match
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match[4]) { // If there's a value
|
||||
const valueStart = match.index + match[0].indexOf(match[4])
|
||||
const valueEnd = valueStart + match[4].length
|
||||
addDecoration(valueStart, valueEnd, 'filter-label-value')
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Error creating decorations:', error)
|
||||
}
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
// Initialize ProseMirror editor
|
||||
onMounted(() => {
|
||||
if (!editorRef.value) return
|
||||
|
||||
editor.value = new EditorView(editorRef.value, {
|
||||
state: createEditorState(props.modelValue),
|
||||
dispatchTransaction(tr) {
|
||||
if (!editor.value) return
|
||||
|
||||
const newState = editor.value.state.apply(tr)
|
||||
editor.value.updateState(newState)
|
||||
|
||||
// Update the model value when document changes
|
||||
if (tr.docChanged) {
|
||||
const text = newState.doc.textContent
|
||||
emit('update:modelValue', text)
|
||||
}
|
||||
},
|
||||
attributes: {
|
||||
class: 'filter-prosemirror',
|
||||
style: 'white-space: pre-wrap',
|
||||
},
|
||||
handleDOMEvents: {
|
||||
click(_, event) {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.classList.contains('filter-date-value')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const dateValue = target.getAttribute('data-date-value') || ''
|
||||
const position = parseInt(target.getAttribute('data-position') || '0')
|
||||
|
||||
currentOldDatepickerValue.value = dateValue
|
||||
currentDatepickerValue.value = dateValue
|
||||
currentDatepickerPos.value = position
|
||||
datePickerPopupOpen.value = true
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
input() {
|
||||
handleFieldInput()
|
||||
return false
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy()
|
||||
})
|
||||
|
||||
// Create a new editor state similar to the working draft approach
|
||||
function createEditorState(content = '') {
|
||||
const nodes = content ? [
|
||||
filterSchema.node('paragraph', null, [
|
||||
filterSchema.text(content),
|
||||
]),
|
||||
] : [filterSchema.node('paragraph')]
|
||||
|
||||
return EditorState.create({
|
||||
schema: filterSchema,
|
||||
plugins: [
|
||||
keymap({
|
||||
...baseKeymap,
|
||||
'Mod-z': undo,
|
||||
'Mod-y': redo,
|
||||
'Enter': () => {
|
||||
blurDebounced()
|
||||
return true
|
||||
},
|
||||
}),
|
||||
history(),
|
||||
createHighlightPlugin(),
|
||||
],
|
||||
doc: filterSchema.node('doc', null, nodes),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const currentOldDatepickerValue = ref('')
|
||||
const currentDatepickerValue = ref('')
|
||||
const currentDatepickerPos = ref()
|
||||
const currentDatepickerPos = ref(0)
|
||||
const datePickerPopupOpen = ref(false)
|
||||
|
||||
watch(
|
||||
() => highlightedFilterQuery.value,
|
||||
async () => {
|
||||
await nextTick()
|
||||
document.querySelectorAll('button.filter-query__date_value')
|
||||
.forEach(b => {
|
||||
b.addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const button = event.target
|
||||
currentOldDatepickerValue.value = button?.innerText
|
||||
currentDatepickerValue.value = button?.innerText
|
||||
currentDatepickerPos.value = parseInt(button?.dataset.position)
|
||||
datePickerPopupOpen.value = true
|
||||
})
|
||||
})
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function updateDateInQuery(newDate: string) {
|
||||
// Need to escape and unescape the query because the positions are based on the escaped query
|
||||
let escaped = escapeHtml(filterQuery.value)
|
||||
escaped = escaped
|
||||
.substring(0, currentDatepickerPos.value)
|
||||
+ escaped
|
||||
.substring(currentDatepickerPos.value)
|
||||
.replace(currentOldDatepickerValue.value, newDate)
|
||||
currentOldDatepickerValue.value = newDate
|
||||
filterQuery.value = unEscapeHtml(escaped)
|
||||
function updateDateInQuery(newDate: string | Date | null) {
|
||||
if (!editor.value || !newDate) return
|
||||
|
||||
const dateStr = typeof newDate === 'string' ? newDate : newDate.toISOString().split('T')[0]
|
||||
const currentText = editor.value.state.doc.textContent
|
||||
const newText = currentText.replace(currentOldDatepickerValue.value, dateStr)
|
||||
currentOldDatepickerValue.value = dateStr
|
||||
|
||||
// Update by recreating the editor state
|
||||
const newState = createEditorState(newText)
|
||||
editor.value.updateState(newState)
|
||||
emit('update:modelValue', newText)
|
||||
}
|
||||
|
||||
const autocompleteMatchPosition = ref(0)
|
||||
const autocompleteMatchText = ref('')
|
||||
const autocompleteResultType = ref<'labels' | 'assignees' | 'projects' | null>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const autocompleteResults = ref<any[]>([])
|
||||
const autocompleteResults = ref<Array<{id: number, title?: string, username?: string, name?: string}>>([])
|
||||
|
||||
function handleFieldInput() {
|
||||
if (!filterInput.value) return
|
||||
const cursorPosition = filterInput.value.selectionStart
|
||||
const textUpToCursor = filterQuery.value.substring(0, cursorPosition)
|
||||
if (!editor.value) return
|
||||
|
||||
const state = editor.value.state
|
||||
const selection = state.selection
|
||||
const cursorPosition = selection.from
|
||||
const text = state.doc.textContent
|
||||
const textUpToCursor = text.substring(0, cursorPosition)
|
||||
autocompleteResults.value = []
|
||||
|
||||
AUTOCOMPLETE_FIELDS.forEach(field => {
|
||||
|
|
@ -222,8 +310,7 @@ function handleFieldInput() {
|
|||
return
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [matched, prefix, operator, space, keyword] = match
|
||||
const [matched, prefix, operator, , keyword] = match
|
||||
if(!keyword) {
|
||||
return
|
||||
}
|
||||
|
|
@ -256,22 +343,27 @@ function handleFieldInput() {
|
|||
})
|
||||
}
|
||||
|
||||
function autocompleteSelect(value) {
|
||||
filterQuery.value = filterQuery.value.substring(0, autocompleteMatchPosition.value + 1) +
|
||||
(autocompleteResultType.value === 'assignees'
|
||||
? value.username
|
||||
: value.title) +
|
||||
filterQuery.value.substring(autocompleteMatchPosition.value + autocompleteMatchText.value.length + 1)
|
||||
|
||||
function autocompleteSelect(value: {id: number, username?: string, title?: string}) {
|
||||
if (!editor.value) return
|
||||
|
||||
const newValue = autocompleteResultType.value === 'assignees' ? value.username : value.title
|
||||
const currentText = editor.value.state.doc.textContent
|
||||
const newText = currentText.substring(0, autocompleteMatchPosition.value + 1) +
|
||||
newValue +
|
||||
currentText.substring(autocompleteMatchPosition.value + autocompleteMatchText.value.length + 1)
|
||||
|
||||
// Update by recreating the editor state
|
||||
const newState = createEditorState(newText)
|
||||
editor.value.updateState(newState)
|
||||
emit('update:modelValue', newText)
|
||||
|
||||
autocompleteResults.value = []
|
||||
}
|
||||
|
||||
// The blur from the textarea might happen before the replacement after autocomplete select was done.
|
||||
// That caused listeners to try and replace values earlier, resulting in broken queries.
|
||||
// The blur from the editor might happen before the replacement after autocomplete select was done.
|
||||
const blurDebounced = useDebounceFn(() => emit('blur'), 500)
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="field">
|
||||
<label
|
||||
|
|
@ -282,35 +374,22 @@ const blurDebounced = useDebounceFn(() => emit('blur'), 500)
|
|||
</label>
|
||||
<AutocompleteDropdown
|
||||
:options="autocompleteResults"
|
||||
@blur="filterInput?.blur()"
|
||||
@blur="editor?.dom.blur()"
|
||||
@update:modelValue="autocompleteSelect"
|
||||
>
|
||||
<template
|
||||
#input="{ onKeydown, onFocusField }"
|
||||
>
|
||||
<div class="control filter-input">
|
||||
<textarea
|
||||
:id
|
||||
ref="filterInput"
|
||||
v-model="filterQuery"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
class="input"
|
||||
<div
|
||||
:id="id"
|
||||
ref="editorRef"
|
||||
class="filter-editor-container"
|
||||
:class="{'has-autocomplete-results': autocompleteResults.length > 0}"
|
||||
:placeholder="$t('filters.query.placeholder')"
|
||||
@input="handleFieldInput"
|
||||
@focus="onFocusField"
|
||||
@keydown="onKeydown"
|
||||
@keydown.enter.prevent="blurDebounced"
|
||||
@blur="blurDebounced"
|
||||
/>
|
||||
<div
|
||||
class="filter-input-highlight"
|
||||
:style="{'height': height}"
|
||||
v-html="highlightedFilterQuery"
|
||||
/>
|
||||
<DatepickerWithValues
|
||||
v-model="currentDatepickerValue"
|
||||
v-model:open="datePickerPopupOpen"
|
||||
|
|
@ -339,48 +418,88 @@ const blurDebounced = useDebounceFn(() => emit('blur'), 500)
|
|||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.filter-input-highlight {
|
||||
.filter-editor-container {
|
||||
min-block-size: 2.5em;
|
||||
border: 1px solid var(--input-border-color);
|
||||
border-radius: var(--input-radius);
|
||||
padding: .5em .75em;
|
||||
background: var(--white);
|
||||
position: relative;
|
||||
|
||||
&, button.filter-query__date_value {
|
||||
color: var(--card-color);
|
||||
&.has-autocomplete-results {
|
||||
border-radius: var(--input-radius) var(--input-radius) 0 0;
|
||||
}
|
||||
|
||||
span {
|
||||
&.filter-query__field {
|
||||
&:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px hsla(var(--primary-hsl), 0.25);
|
||||
}
|
||||
|
||||
.filter-prosemirror {
|
||||
outline: none;
|
||||
min-block-size: 1.5em;
|
||||
line-height: 1.5;
|
||||
|
||||
// Placeholder support
|
||||
&:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--input-placeholder-color);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
// Syntax highlighting styles
|
||||
.filter-field {
|
||||
color: var(--code-literal);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.filter-query__operator {
|
||||
.filter-operator {
|
||||
color: var(--code-keyword);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.filter-query__join-operator {
|
||||
.filter-join-operator {
|
||||
color: var(--code-section);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.filter-query__date_value_placeholder {
|
||||
display: inline-block;
|
||||
color: transparent;
|
||||
.filter-date-value {
|
||||
background-color: var(--primary);
|
||||
color: var(--white);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.125em 0.25em;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
}
|
||||
|
||||
&.filter-query__assignee_value, &.filter-query__label_value {
|
||||
border-radius: $radius;
|
||||
.filter-label-value {
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--grey-200);
|
||||
color: var(--grey-700);
|
||||
padding: 0.125em 0.25em;
|
||||
}
|
||||
|
||||
.filter-assignee-value {
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--grey-200);
|
||||
color: var(--grey-700);
|
||||
padding: 0.125em 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
button.filter-query__date_value {
|
||||
border-radius: $radius;
|
||||
position: absolute;
|
||||
margin-block-start: calc((0.25em - 0.125rem) * -1);
|
||||
block-size: 1.75rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1.5;
|
||||
// ProseMirror base styles
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
min-block-size: 1.5em;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -388,28 +507,5 @@ const blurDebounced = useDebounceFn(() => emit('blur'), 500)
|
|||
<style lang="scss" scoped>
|
||||
.filter-input {
|
||||
position: relative;
|
||||
|
||||
textarea {
|
||||
position: absolute;
|
||||
background: transparent !important;
|
||||
resize: none;
|
||||
-webkit-text-fill-color: transparent;
|
||||
|
||||
&::placeholder {
|
||||
-webkit-text-fill-color: var(--input-placeholder-color);
|
||||
}
|
||||
|
||||
&.has-autocomplete-results {
|
||||
border-radius: var(--input-radius) var(--input-radius) 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-input-highlight {
|
||||
background: var(--white);
|
||||
block-size: 2.5em;
|
||||
line-height: 1.5;
|
||||
padding: .5em .75em;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@
|
|||
v-model="filterQuery"
|
||||
:project-id="projectId"
|
||||
@update:modelValue="() => change('modelValue')"
|
||||
@blur="() => change('blur')"
|
||||
/>
|
||||
|
||||
<pre>{{ filterQuery }}</pre>
|
||||
<div
|
||||
v-if="filterFromView"
|
||||
class="tw-text-sm tw-mbe-2"
|
||||
|
|
@ -60,7 +59,6 @@ export const ALPHABETICAL_SORT = 'title'
|
|||
<script setup lang="ts">
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
||||
import FilterInput from '@/components/project/partials/FilterInput.vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import type {TaskFilterParams} from '@/services/taskCollection'
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
|
|
@ -68,9 +66,9 @@ import {useProjectStore} from '@/stores/projects'
|
|||
import {
|
||||
hasFilterQuery,
|
||||
transformFilterStringForApi,
|
||||
transformFilterStringFromApi,
|
||||
} from '@/helpers/filters'
|
||||
import FilterInputDocs from '@/components/project/partials/FilterInputDocs.vue'
|
||||
import FilterInput from '@/components/input/filter/FilterInput.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: TaskFilterParams,
|
||||
|
|
@ -124,13 +122,7 @@ const projectStore = useProjectStore()
|
|||
watch(
|
||||
() => props.modelValue,
|
||||
(value: TaskFilterParams) => {
|
||||
const val = {...value}
|
||||
val.filter = transformFilterStringFromApi(
|
||||
val?.filter || '',
|
||||
labelId => labelStore.getLabelById(labelId)?.title || null,
|
||||
projectId => projectStore.projects[projectId]?.title || null,
|
||||
)
|
||||
params.value = val
|
||||
params.value = {...value}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
|
|
|
|||
|
|
@ -58,14 +58,14 @@ export const FILTER_JOIN_OPERATOR = [
|
|||
')',
|
||||
]
|
||||
|
||||
export const FILTER_OPERATORS_REGEX = '(<|>|<=|>=|=|!=|not in|in)'
|
||||
export const FILTER_OPERATORS_REGEX = '('+FILTER_OPERATORS.map(op => op.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')+')'
|
||||
|
||||
export function hasFilterQuery(filter: string): boolean {
|
||||
return FILTER_OPERATORS.find(o => filter.includes(o)) || false
|
||||
}
|
||||
|
||||
export function getFilterFieldRegexPattern(field: string): RegExp {
|
||||
return new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&|()<]+\\1?)?', 'ig')
|
||||
return new RegExp('(' + field + ')\\s*' + FILTER_OPERATORS_REGEX + '\\s*([\'"]?)([^\'"&|()<]+?)(?=\\s*(?:&&|\\|\\||$))', 'ig')
|
||||
}
|
||||
|
||||
export function transformFilterStringForApi(
|
||||
|
|
@ -98,14 +98,14 @@ export function transformFilterStringForApi(
|
|||
|
||||
while ((match = pattern.exec(filter)) !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [matched, prefix, operator, space, keyword] = match
|
||||
const [matched, fieldName, operator, quotes, keyword] = match
|
||||
if (!keyword) {
|
||||
continue
|
||||
}
|
||||
|
||||
let keywords = [keyword.trim()]
|
||||
if (operator === 'in' || operator === '?=' || operator === 'not in' || operator === '?!=') {
|
||||
keywords = keyword.trim().split(',').map(k => k.trim())
|
||||
if (isMultiValueOperator(operator)) {
|
||||
keywords = keyword.trim().split(',').map(k => trimQuotes(k))
|
||||
}
|
||||
|
||||
let replaced = keyword
|
||||
|
|
@ -116,8 +116,29 @@ export function transformFilterStringForApi(
|
|||
replaced = replaced.replace(k, String(id))
|
||||
}
|
||||
})
|
||||
|
||||
// Join the transformed keywords back together
|
||||
if (isMultiValueOperator(operator)) {
|
||||
replaced = transformedKeywords.join(', ')
|
||||
} else {
|
||||
replaced = transformedKeywords[0] || keyword
|
||||
}
|
||||
|
||||
const actualKeywordStart = (match?.index || 0) + prefix.length
|
||||
replaced = replaced.replaceAll('"', '').replaceAll('\'', '')
|
||||
|
||||
// Reconstruct the entire match with the replaced value
|
||||
let reconstructedMatch
|
||||
if (quotes && quotedContent) {
|
||||
// For quoted values, remove quotes since we converted to IDs
|
||||
reconstructedMatch = `${fieldName} ${operator} ${replaced}`
|
||||
} else if (unquotedContent) {
|
||||
// For unquoted values
|
||||
reconstructedMatch = `${fieldName} ${operator} ${replaced}`
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
const actualKeywordStart = (match?.index || 0) + matched.length - keyword.length
|
||||
replacements.push({
|
||||
start: actualKeywordStart,
|
||||
length: keyword.length,
|
||||
|
|
@ -178,11 +199,16 @@ export function transformFilterStringFromApi(
|
|||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(filter)) !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [matched, prefix, operator, space, keyword] = match
|
||||
const [matched, fieldName, operator, quotes, keyword] = match
|
||||
if (keyword) {
|
||||
let keywords = [keyword.trim()]
|
||||
if (operator === 'in' || operator === '?=' || operator === 'not in' || operator === '?!=') {
|
||||
keywords = keyword.trim().split(',').map(k => k.trim())
|
||||
if (isMultiValueOperator(operator)) {
|
||||
keywords = keyword.trim().split(',').map(k => {
|
||||
let trimmed = k.trim()
|
||||
// Strip quotes from individual values in multi-value scenarios
|
||||
trimmed = trimQuotes(trimmed)
|
||||
return trimmed
|
||||
})
|
||||
}
|
||||
|
||||
keywords.forEach(k => {
|
||||
|
|
@ -204,3 +230,7 @@ export function transformFilterStringFromApi(
|
|||
|
||||
return filter
|
||||
}
|
||||
|
||||
export function isMultiValueOperator(operator: string): boolean {
|
||||
return ['in', '?=', 'not in', '?!='].includes(operator)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -441,6 +441,7 @@
|
|||
"title": "Filters",
|
||||
"clear": "Clear Filters",
|
||||
"showResults": "Show results",
|
||||
"noResults": "No results",
|
||||
"fromView": "The current view has a filter set as well:",
|
||||
"fromViewBoth": "It will be used in combination with what you enter here.",
|
||||
"attributes": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue