feat(filter): rebuild filter input component

This commit is contained in:
kolaente 2025-05-25 22:45:40 +02:00
parent 6bd3d6d4a0
commit b99b7bf131
11 changed files with 1631 additions and 256 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function unEscapeHtml(unsafe: string|null|undefined): string {
if (!unsafe) {
return ''
}
return unsafe
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot/g, '"')
.replace(/&#039;/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>

View File

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

View File

@ -58,14 +58,14 @@ export const FILTER_JOIN_OPERATOR = [
')',
]
export const FILTER_OPERATORS_REGEX = '(&lt;|&gt;|&lt;=|&gt;=|=|!=|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)
}

View File

@ -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": {