feat(filters): query-based filter logic (#2177)
Reviewed-on: https://kolaente.dev/vikunja/vikunja/pulls/2177
This commit is contained in:
@ -19,3 +19,28 @@ export const DATE_RANGES = {
|
||||
'thisYear': ['now/y', 'now/y+1y'],
|
||||
'restOfThisYear': ['now', 'now/y+1y'],
|
||||
}
|
||||
|
||||
export const DATE_VALUES = {
|
||||
'now': 'now',
|
||||
'startOfToday': 'now/d',
|
||||
'endOfToday': 'now/d+1d',
|
||||
|
||||
'beginningOflastWeek': 'now/w-1w',
|
||||
'endOfLastWeek': 'now/w-2w',
|
||||
'beginningOfThisWeek': 'now/w',
|
||||
'endOfThisWeek': 'now/w+1w',
|
||||
'startOfNextWeek': 'now/w+1w',
|
||||
'endOfNextWeek': 'now/w+2w',
|
||||
'in7Days': 'now+7d',
|
||||
|
||||
'beginningOfLastMonth': 'now/M-1M',
|
||||
'endOfLastMonth': 'now/M-2M',
|
||||
'startOfThisMonth': 'now/M',
|
||||
'endOfThisMonth': 'now/M+1M',
|
||||
'startOfNextMonth': 'now/M+1M',
|
||||
'endOfNextMonth': 'now/M+2M',
|
||||
'in30Days': 'now+30d',
|
||||
|
||||
'startOfThisYear': 'now/y',
|
||||
'endOfThisYear': 'now/y+1y',
|
||||
}
|
||||
|
@ -75,14 +75,15 @@
|
||||
|
||||
<p>
|
||||
{{ $t('input.datemathHelp.canuse') }}
|
||||
<BaseButton
|
||||
class="has-text-primary"
|
||||
@click="showHowItWorks = true"
|
||||
>
|
||||
{{ $t('input.datemathHelp.learnhow') }}
|
||||
</BaseButton>
|
||||
</p>
|
||||
|
||||
<BaseButton
|
||||
class="has-text-primary"
|
||||
@click="showHowItWorks = true"
|
||||
>
|
||||
{{ $t('input.datemathHelp.learnhow') }}
|
||||
</BaseButton>
|
||||
|
||||
<modal
|
||||
:enabled="showHowItWorks"
|
||||
transition-name="fade"
|
||||
@ -111,7 +112,7 @@ import Popup from '@/components/misc/popup.vue'
|
||||
import {DATE_RANGES} from '@/components/date/dateRanges'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import DatemathHelp from '@/components/date/datemathHelp.vue'
|
||||
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
|
||||
import {getFlatpickrLanguage} from '@/helpers/flatpickrLanguage'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
|
256
frontend/src/components/date/datepickerWithValues.vue
Normal file
256
frontend/src/components/date/datepickerWithValues.vue
Normal file
@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="datepicker-with-range-container">
|
||||
<Popup
|
||||
:open="open"
|
||||
@close="() => emit('close')"
|
||||
>
|
||||
<template #content="{isOpen}">
|
||||
<div
|
||||
class="datepicker-with-range"
|
||||
:class="{'is-open': isOpen}"
|
||||
>
|
||||
<div class="selections">
|
||||
<BaseButton
|
||||
:class="{'is-active': customRangeActive}"
|
||||
@click="setDate(null)"
|
||||
>
|
||||
{{ $t('misc.custom') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-for="(value, text) in DATE_VALUES"
|
||||
:key="text"
|
||||
:class="{'is-active': date === value}"
|
||||
@click="setDate(value)"
|
||||
>
|
||||
{{ $t(`input.datepickerRange.values.${text}`) }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div class="flatpickr-container input-group">
|
||||
<label class="label">
|
||||
{{ $t('input.datepickerRange.date') }}
|
||||
<div class="field has-addons">
|
||||
<div class="control is-fullwidth">
|
||||
<input
|
||||
v-model="date"
|
||||
class="input"
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button
|
||||
icon="calendar"
|
||||
variant="secondary"
|
||||
data-toggle
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<flat-pickr
|
||||
v-model="flatpickrDate"
|
||||
:config="flatPickerConfig"
|
||||
/>
|
||||
|
||||
<p>
|
||||
{{ $t('input.datemathHelp.canuse') }}
|
||||
</p>
|
||||
|
||||
<BaseButton
|
||||
class="has-text-primary"
|
||||
@click="showHowItWorks = true"
|
||||
>
|
||||
{{ $t('input.datemathHelp.learnhow') }}
|
||||
</BaseButton>
|
||||
|
||||
<modal
|
||||
:enabled="showHowItWorks"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
@close="() => showHowItWorks = false"
|
||||
>
|
||||
<DatemathHelp />
|
||||
</modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||
|
||||
import Popup from '@/components/misc/popup.vue'
|
||||
import {DATE_VALUES} from '@/components/date/dateRanges'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import DatemathHelp from '@/components/date/datemathHelp.vue'
|
||||
import {getFlatpickrLanguage} from '@/helpers/flatpickrLanguage'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const flatPickerConfig = computed(() => ({
|
||||
altFormat: t('date.altFormatLong'),
|
||||
altInput: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: false,
|
||||
wrap: true,
|
||||
locale: getFlatpickrLanguage(),
|
||||
}))
|
||||
|
||||
const showHowItWorks = ref(false)
|
||||
|
||||
const flatpickrDate = ref('')
|
||||
|
||||
const date = ref<string|Date>('')
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
date.value = newValue
|
||||
// Only set the date back to flatpickr when it's an actual date.
|
||||
// Otherwise flatpickr runs in an endless loop and slows down the browser.
|
||||
const parsed = parseDateOrString(date.value, false)
|
||||
if (parsed instanceof Date) {
|
||||
flatpickrDate.value = date.value
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function emitChanged() {
|
||||
emit('update:modelValue', date.value === '' ? null : date.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => flatpickrDate.value,
|
||||
(newVal: string | null) => {
|
||||
if (newVal === null) {
|
||||
return
|
||||
}
|
||||
|
||||
date.value = newVal
|
||||
|
||||
emitChanged()
|
||||
},
|
||||
)
|
||||
watch(() => date.value, emitChanged)
|
||||
|
||||
function setDate(range: string | null) {
|
||||
if (range === null) {
|
||||
date.value = ''
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
date.value = range
|
||||
}
|
||||
|
||||
const customRangeActive = computed<boolean>(() => {
|
||||
return !Object.values(DATE_VALUES).some(d => date.value === d)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.datepicker-with-range-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:deep(.popup) {
|
||||
z-index: 10;
|
||||
margin-top: 1rem;
|
||||
border-radius: $radius;
|
||||
border: 1px solid var(--grey-200);
|
||||
background-color: var(--white);
|
||||
box-shadow: $shadow;
|
||||
|
||||
&.is-open {
|
||||
width: 500px;
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker-with-range {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
:deep(.flatpickr-calendar) {
|
||||
margin: 0 auto 8px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.flatpickr-container {
|
||||
width: 70%;
|
||||
border-left: 1px solid var(--grey-200);
|
||||
padding: 1rem;
|
||||
font-size: .9rem;
|
||||
|
||||
// Flatpickr has no option to use it without an input field so we're hiding it instead
|
||||
:deep(input.form-control.input) {
|
||||
height: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.field .control :deep(.button) {
|
||||
border: 1px solid var(--input-border-color);
|
||||
height: 2.25rem;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid var(--input-hover-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.label, .input, :deep(.button) {
|
||||
font-size: .9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.selections {
|
||||
width: 30%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: .5rem;
|
||||
overflow-y: scroll;
|
||||
|
||||
button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: .5rem 1rem;
|
||||
transition: $transition;
|
||||
font-size: .9rem;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&.is-active {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:hover, &.is-active {
|
||||
background-color: var(--grey-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
232
frontend/src/components/input/AutocompleteDropdown.vue
Normal file
232
frontend/src/components/input/AutocompleteDropdown.vue
Normal file
@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import {type ComponentPublicInstance, nextTick, ref, watch} from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
options: any[],
|
||||
suggestion?: string,
|
||||
maxHeight?: number,
|
||||
}>(), {
|
||||
maxHeight: 200,
|
||||
suggestion: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits(['blur'])
|
||||
|
||||
const ESCAPE = 27,
|
||||
ARROW_UP = 38,
|
||||
ARROW_DOWN = 40
|
||||
|
||||
type StateType = 'unfocused' | 'focused'
|
||||
|
||||
const selectedIndex = ref(-1)
|
||||
const state = ref<StateType>('unfocused')
|
||||
const val = ref<string>('')
|
||||
const model = defineModel<string>()
|
||||
|
||||
const suggestionScrollerRef = ref<HTMLInputElement | null>(null)
|
||||
const containerRef = ref<HTMLInputElement | null>(null)
|
||||
const editorRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
watch(
|
||||
() => model.value,
|
||||
newValue => {
|
||||
val.value = newValue
|
||||
},
|
||||
)
|
||||
|
||||
function updateSuggestionScroll() {
|
||||
nextTick(() => {
|
||||
const scroller = suggestionScrollerRef.value
|
||||
const selectedItem = scroller?.querySelector('.selected')
|
||||
scroller.scrollTop = selectedItem ? selectedItem.offsetTop : 0
|
||||
})
|
||||
}
|
||||
|
||||
function setState(stateName: StateType) {
|
||||
state.value = stateName
|
||||
if (stateName === 'unfocused') {
|
||||
emit('blur')
|
||||
}
|
||||
}
|
||||
|
||||
function onFocusField() {
|
||||
setState('focused')
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
switch (e.keyCode || e.which) {
|
||||
case ESCAPE:
|
||||
e.preventDefault()
|
||||
setState('unfocused')
|
||||
break
|
||||
case ARROW_UP:
|
||||
e.preventDefault()
|
||||
select(-1)
|
||||
break
|
||||
case ARROW_DOWN:
|
||||
e.preventDefault()
|
||||
select(1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const resultRefs = ref<(HTMLElement | null)[]>([])
|
||||
|
||||
function setResultRefs(el: Element | ComponentPublicInstance | null, index: number) {
|
||||
resultRefs.value[index] = el as (HTMLElement | null)
|
||||
}
|
||||
|
||||
function select(offset: number) {
|
||||
|
||||
let index = selectedIndex.value + offset
|
||||
|
||||
if (!isFinite(index)) {
|
||||
index = 0
|
||||
}
|
||||
|
||||
if (index >= props.options.length) {
|
||||
// At the last index, now moving back to the top
|
||||
index = 0
|
||||
}
|
||||
|
||||
if (index < 0) {
|
||||
// Arrow up but we're already at the top
|
||||
index = props.options.length - 1
|
||||
}
|
||||
const elems = resultRefs.value[index]
|
||||
if (
|
||||
typeof elems === 'undefined'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedIndex.value = index
|
||||
updateSuggestionScroll()
|
||||
|
||||
if (Array.isArray(elems)) {
|
||||
elems[0].focus()
|
||||
return
|
||||
}
|
||||
elems?.focus()
|
||||
}
|
||||
|
||||
function onSelectValue(value) {
|
||||
model.value = value
|
||||
selectedIndex.value = 0
|
||||
setState('unfocused')
|
||||
}
|
||||
|
||||
function onUpdateField(e) {
|
||||
setState('focused')
|
||||
model.value = e.currentTarget.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="autocomplete"
|
||||
>
|
||||
<div class="entry-box">
|
||||
<slot
|
||||
name="input"
|
||||
:on-update-field
|
||||
:on-focus-field
|
||||
:on-keydown
|
||||
>
|
||||
<textarea
|
||||
ref="editorRef"
|
||||
class="field"
|
||||
:class="state"
|
||||
:value="val"
|
||||
@input="onUpdateField"
|
||||
@focus="onFocusField"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="state === 'focused' && options.length"
|
||||
class="suggestion-list"
|
||||
>
|
||||
<div
|
||||
v-if="options && options.length"
|
||||
class="scroll-list"
|
||||
>
|
||||
<div
|
||||
ref="suggestionScrollerRef"
|
||||
class="items"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<button
|
||||
v-for="(item, index) in options"
|
||||
:key="item"
|
||||
:ref="(el: Element | ComponentPublicInstance | null) => setResultRefs(el, index)"
|
||||
class="item"
|
||||
:class="{ selected: index === selectedIndex }"
|
||||
@click="onSelectValue(item)"
|
||||
>
|
||||
<slot
|
||||
name="result"
|
||||
:item
|
||||
:selected="index === selectedIndex"
|
||||
>
|
||||
{{ item }}
|
||||
</slot>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.autocomplete {
|
||||
position: relative;
|
||||
|
||||
.suggestion-list {
|
||||
position: absolute;
|
||||
|
||||
background: var(--white);
|
||||
border-radius: 0 0 var(--input-radius) var(--input-radius);
|
||||
border: 1px solid var(--primary);
|
||||
border-top: none;
|
||||
|
||||
max-height: 50vh;
|
||||
overflow-x: auto;
|
||||
z-index: 100;
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
margin-top: -2px;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
||||
font-size: .9rem;
|
||||
width: 100%;
|
||||
color: var(--grey-800);
|
||||
text-align: left;
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
font-family: $family-sans-serif;
|
||||
font-weight: normal;
|
||||
padding: .5rem .75rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
background: var(--grey-100);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--grey-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<slot
|
||||
name="trigger"
|
||||
:is-open="open"
|
||||
:is-open="openValue"
|
||||
:toggle="toggle"
|
||||
:close="close"
|
||||
/>
|
||||
@ -9,13 +9,13 @@
|
||||
ref="popup"
|
||||
class="popup"
|
||||
:class="{
|
||||
'is-open': open,
|
||||
'has-overflow': props.hasOverflow && open
|
||||
'is-open': openValue,
|
||||
'has-overflow': props.hasOverflow && openValue
|
||||
}"
|
||||
>
|
||||
<slot
|
||||
name="content"
|
||||
:is-open="open"
|
||||
:is-open="openValue"
|
||||
:toggle="toggle"
|
||||
:close="close"
|
||||
/>
|
||||
@ -23,7 +23,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import {ref, watch} from 'vue'
|
||||
import {onClickOutside} from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
@ -31,24 +31,35 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const open = ref(false)
|
||||
watch(
|
||||
() => props.open,
|
||||
nowOpen => {
|
||||
openValue.value = nowOpen
|
||||
},
|
||||
)
|
||||
|
||||
const openValue = ref(false)
|
||||
const popup = ref<HTMLElement | null>(null)
|
||||
|
||||
function close() {
|
||||
open.value = false
|
||||
openValue.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value
|
||||
openValue.value = !openValue.value
|
||||
}
|
||||
|
||||
onClickOutside(popup, () => {
|
||||
if (!open.value) {
|
||||
if (!openValue.value) {
|
||||
return
|
||||
}
|
||||
close()
|
||||
|
@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import FilterInput from '@/components/project/partials/FilterInput.vue'
|
||||
|
||||
function initState(value: string) {
|
||||
return {
|
||||
value,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Filter Input">
|
||||
<Variant
|
||||
title="With date values"
|
||||
:init-state="initState('dueDate < now && done = false && dueDate > now/w+1w')"
|
||||
>
|
||||
<template #default="{state}">
|
||||
<FilterInput v-model="state.value" />
|
||||
</template>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
354
frontend/src/components/project/partials/FilterInput.vue
Normal file
354
frontend/src/components/project/partials/FilterInput.vue
Normal file
@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, ref, watch} from 'vue'
|
||||
import {useAutoHeightTextarea} from '@/composables/useAutoHeightTextarea'
|
||||
import DatepickerWithValues from '@/components/date/datepickerWithValues.vue'
|
||||
import UserService from '@/services/user'
|
||||
import AutocompleteDropdown from '@/components/input/AutocompleteDropdown.vue'
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
import XLabel from '@/components/tasks/partials/label.vue'
|
||||
import User from '@/components/misc/user.vue'
|
||||
import ProjectUserService from '@/services/projectUsers'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {
|
||||
DATE_FIELDS,
|
||||
ASSIGNEE_FIELDS,
|
||||
AUTOCOMPLETE_FIELDS,
|
||||
AVAILABLE_FILTER_FIELDS,
|
||||
FILTER_JOIN_OPERATOR,
|
||||
FILTER_OPERATORS,
|
||||
FILTER_OPERATORS_REGEX, LABEL_FIELDS,
|
||||
} from '@/helpers/filters'
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
projectId,
|
||||
} = defineProps<{
|
||||
modelValue: string,
|
||||
projectId?: number,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'blur'])
|
||||
|
||||
const filterQuery = ref<string>('')
|
||||
const {
|
||||
textarea: filterInput,
|
||||
height,
|
||||
} = useAutoHeightTextarea(filterQuery)
|
||||
|
||||
watch(
|
||||
() => modelValue,
|
||||
() => {
|
||||
filterQuery.value = modelValue
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filterQuery.value,
|
||||
() => {
|
||||
if (filterQuery.value !== modelValue) {
|
||||
emit('update:modelValue', filterQuery.value)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const userService = new UserService()
|
||||
const projectUserService = new ProjectUserService()
|
||||
|
||||
function escapeHtml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function unEscapeHtml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, '\'')
|
||||
}
|
||||
|
||||
const highlightedFilterQuery = computed(() => {
|
||||
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 = ''
|
||||
}
|
||||
|
||||
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>`
|
||||
})
|
||||
})
|
||||
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 = new RegExp(f + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
|
||||
highlighted = highlighted.replaceAll(pattern, (match, token, start, value) => {
|
||||
if (typeof value === 'undefined') {
|
||||
value = ''
|
||||
}
|
||||
|
||||
const label = labelStore.getLabelsByExactTitles([value])[0] || undefined
|
||||
|
||||
return `${f} ${token} <span class="filter-query__label_value" style="background-color: ${label?.hexColor}; color: ${label?.textColor}">${label?.title ?? value}<span>`
|
||||
})
|
||||
})
|
||||
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
|
||||
})
|
||||
|
||||
const currentOldDatepickerValue = ref('')
|
||||
const currentDatepickerValue = ref('')
|
||||
const currentDatepickerPos = ref()
|
||||
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)
|
||||
}
|
||||
|
||||
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 labelStore = useLabelStore()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
function handleFieldInput() {
|
||||
const cursorPosition = filterInput.value.selectionStart
|
||||
const textUpToCursor = filterQuery.value.substring(0, cursorPosition)
|
||||
|
||||
AUTOCOMPLETE_FIELDS.forEach(field => {
|
||||
const pattern = new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&|()]+\\1?)?$', 'ig')
|
||||
const match = pattern.exec(textUpToCursor)
|
||||
|
||||
if (match !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [matched, prefix, operator, space, keyword] = match
|
||||
if (keyword) {
|
||||
if (matched.startsWith('label')) {
|
||||
autocompleteResultType.value = 'labels'
|
||||
autocompleteResults.value = labelStore.filterLabelsByQuery([], keyword)
|
||||
}
|
||||
if (matched.startsWith('assignee')) {
|
||||
autocompleteResultType.value = 'assignees'
|
||||
if (projectId) {
|
||||
projectUserService.getAll({projectId}, {s: keyword})
|
||||
.then(users => autocompleteResults.value = users.length > 1 ? users : [])
|
||||
} else {
|
||||
userService.getAll({}, {s: keyword})
|
||||
.then(users => autocompleteResults.value = users.length > 1 ? users : [])
|
||||
}
|
||||
}
|
||||
if (!projectId && matched.startsWith('project')) {
|
||||
autocompleteResultType.value = 'projects'
|
||||
autocompleteResults.value = projectStore.searchProject(keyword)
|
||||
}
|
||||
autocompleteMatchText.value = keyword
|
||||
autocompleteMatchPosition.value = prefix.length - 1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function autocompleteSelect(value) {
|
||||
filterQuery.value = filterQuery.value.substring(0, autocompleteMatchPosition.value + 1) +
|
||||
(autocompleteResultType.value === 'labels'
|
||||
? value.title
|
||||
: value.username) +
|
||||
filterQuery.value.substring(autocompleteMatchPosition.value + autocompleteMatchText.value.length + 1)
|
||||
|
||||
autocompleteResults.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('filters.query.title') }}</label>
|
||||
<AutocompleteDropdown
|
||||
:options="autocompleteResults"
|
||||
@blur="filterInput?.blur()"
|
||||
@update:modelValue="autocompleteSelect"
|
||||
>
|
||||
<template
|
||||
#input="{ onKeydown, onFocusField }"
|
||||
>
|
||||
<div class="control filter-input">
|
||||
<textarea
|
||||
ref="filterInput"
|
||||
v-model="filterQuery"
|
||||
autocomplete="off"
|
||||
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
class="input"
|
||||
:class="{'has-autocomplete-results': autocompleteResults.length > 0}"
|
||||
:placeholder="$t('filters.query.placeholder')"
|
||||
@input="handleFieldInput"
|
||||
@focus="onFocusField"
|
||||
@keydown="onKeydown"
|
||||
@blur="e => emit('blur', e)"
|
||||
/>
|
||||
<div
|
||||
class="filter-input-highlight"
|
||||
:style="{'height': height}"
|
||||
v-html="highlightedFilterQuery"
|
||||
/>
|
||||
<DatepickerWithValues
|
||||
v-model="currentDatepickerValue"
|
||||
:open="datePickerPopupOpen"
|
||||
@close="() => datePickerPopupOpen = false"
|
||||
@update:modelValue="updateDateInQuery"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template
|
||||
#result="{ item }"
|
||||
>
|
||||
<XLabel
|
||||
v-if="autocompleteResultType === 'labels'"
|
||||
:label="item"
|
||||
/>
|
||||
<User
|
||||
v-else-if="autocompleteResultType === 'assignees'"
|
||||
:user="item"
|
||||
:avatar-size="25"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ item.title }}
|
||||
</template>
|
||||
</template>
|
||||
</AutocompleteDropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.filter-input-highlight {
|
||||
span {
|
||||
&.filter-query__field {
|
||||
color: var(--code-literal);
|
||||
}
|
||||
|
||||
&.filter-query__operator {
|
||||
color: var(--code-keyword);
|
||||
}
|
||||
|
||||
&.filter-query__join-operator {
|
||||
color: var(--code-section);
|
||||
}
|
||||
|
||||
&.filter-query__date_value_placeholder {
|
||||
display: inline-block;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
&.filter-query__assignee_value, &.filter-query__label_value {
|
||||
border-radius: $radius;
|
||||
background-color: var(--grey-200);
|
||||
color: var(--grey-700);
|
||||
}
|
||||
}
|
||||
|
||||
button.filter-query__date_value {
|
||||
border-radius: $radius;
|
||||
position: absolute;
|
||||
margin-top: calc((0.25em - 0.125rem) * -1);
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter-input {
|
||||
position: relative;
|
||||
|
||||
textarea {
|
||||
position: absolute;
|
||||
background: transparent !important;
|
||||
resize: none;
|
||||
text-fill-color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
|
||||
&::placeholder {
|
||||
text-fill-color: var(--input-placeholder-color);
|
||||
-webkit-text-fill-color: var(--input-placeholder-color);
|
||||
}
|
||||
|
||||
&.has-autocomplete-results {
|
||||
border-radius: var(--input-radius) var(--input-radius) 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-input-highlight {
|
||||
height: 2.5em;
|
||||
line-height: 1.5;
|
||||
padding: .5em .75em;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
</style>
|
80
frontend/src/components/project/partials/FilterInputDocs.vue
Normal file
80
frontend/src/components/project/partials/FilterInputDocs.vue
Normal file
@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const showDocs = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseButton
|
||||
class="has-text-primary"
|
||||
@click="showDocs = !showDocs"
|
||||
>
|
||||
{{ $t('filters.query.help.link') }}
|
||||
</BaseButton>
|
||||
|
||||
<Transition>
|
||||
<div
|
||||
v-if="showDocs"
|
||||
class="content"
|
||||
>
|
||||
<p>{{ $t('filters.query.help.intro') }}</p>
|
||||
<ul>
|
||||
<li><code>done</code>: {{ $t('filters.query.help.fields.done') }}</li>
|
||||
<li><code>priority</code>: {{ $t('filters.query.help.fields.priority') }}</li>
|
||||
<li><code>percentDone</code>: {{ $t('filters.query.help.fields.percentDone') }}</li>
|
||||
<li><code>dueDate</code>: {{ $t('filters.query.help.fields.dueDate') }}</li>
|
||||
<li><code>startDate</code>: {{ $t('filters.query.help.fields.startDate') }}</li>
|
||||
<li><code>endDate</code>: {{ $t('filters.query.help.fields.endDate') }}</li>
|
||||
<li><code>doneAt</code>: {{ $t('filters.query.help.fields.doneAt') }}</li>
|
||||
<li><code>assignees</code>: {{ $t('filters.query.help.fields.assignees') }}</li>
|
||||
<li><code>labels</code>: {{ $t('filters.query.help.fields.labels') }}</li>
|
||||
<li><code>project</code>: {{ $t('filters.query.help.fields.project') }}</li>
|
||||
</ul>
|
||||
<p>{{ $t('filters.query.help.canUseDatemath') }}</p>
|
||||
<p>{{ $t('filters.query.help.operators.intro') }}</p>
|
||||
<ul>
|
||||
<li><code>!=</code>: {{ $t('filters.query.help.operators.notEqual') }}</li>
|
||||
<li><code>=</code>: {{ $t('filters.query.help.operators.equal') }}</li>
|
||||
<li><code>></code>: {{ $t('filters.query.help.operators.greaterThan') }}</li>
|
||||
<li><code>>=</code>: {{ $t('filters.query.help.operators.greaterThanOrEqual') }}</li>
|
||||
<li><code><</code>: {{ $t('filters.query.help.operators.lessThan') }}</li>
|
||||
<li><code><=</code>: {{ $t('filters.query.help.operators.lessThanOrEqual') }}</li>
|
||||
<li><code>like</code>: {{ $t('filters.query.help.operators.like') }}</li>
|
||||
<li><code>in</code>: {{ $t('filters.query.help.operators.in') }}</li>
|
||||
</ul>
|
||||
<p>{{ $t('filters.query.help.logicalOperators.intro') }}</p>
|
||||
<ul>
|
||||
<li><code>&&</code>: {{ $t('filters.query.help.logicalOperators.and') }}</li>
|
||||
<li><code>||</code>: {{ $t('filters.query.help.logicalOperators.or') }}</li>
|
||||
<li><code>(</code> and <code>)</code>: {{ $t('filters.query.help.logicalOperators.parentheses') }}</li>
|
||||
</ul>
|
||||
<p>{{ $t('filters.query.help.examples.intro') }}</p>
|
||||
<ul>
|
||||
<li><code>priority = 4</code>: {{ $t('filters.query.help.examples.priorityEqual') }}</li>
|
||||
<li><code>dueDate < now</code>: {{ $t('filters.query.help.examples.dueDatePast') }}</li>
|
||||
<li>
|
||||
<code>done = false && priority >= 3</code>:
|
||||
{{ $t('filters.query.help.examples.undoneHighPriority') }}
|
||||
</li>
|
||||
<li><code>assignees in [user1, user2]</code>: {{ $t('filters.query.help.examples.assigneesIn') }}</li>
|
||||
<li>
|
||||
<code>(priority = 1 || priority = 2) && dueDate <= now</code>:
|
||||
{{ $t('filters.query.help.examples.priorityOneOrTwoPastDue') }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
transition: all $transition-duration ease;
|
||||
}
|
||||
|
||||
.v-enter-from,
|
||||
.v-leave-to {
|
||||
transform: scaleY(0);
|
||||
}
|
||||
</style>
|
@ -25,6 +25,8 @@
|
||||
v-model="value"
|
||||
:has-title="true"
|
||||
class="filter-popup"
|
||||
@update:modelValue="emitChanges"
|
||||
@showResultsButtonClicked="() => modalOpen = false"
|
||||
/>
|
||||
</modal>
|
||||
</template>
|
||||
@ -34,47 +36,38 @@ import {computed, ref, watch} from 'vue'
|
||||
|
||||
import Filters from '@/components/project/partials/filters.vue'
|
||||
|
||||
import {getDefaultParams} from '@/composables/useTaskList'
|
||||
import {getDefaultTaskFilterParams, type TaskFilterParams} from '@/services/taskCollection'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const modelValue = defineModel<TaskFilterParams>({})
|
||||
|
||||
const value = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value) {
|
||||
if(props.modelValue === value) {
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
const value = ref<TaskFilterParams>({})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(modelValue) => {
|
||||
() => modelValue.value,
|
||||
(modelValue: TaskFilterParams) => {
|
||||
value.value = modelValue
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function emitChanges(newValue: TaskFilterParams) {
|
||||
if (modelValue.value?.filter === newValue.filter && modelValue.value?.s === newValue.s) {
|
||||
return
|
||||
}
|
||||
|
||||
modelValue.value.filter = newValue.filter
|
||||
modelValue.value.s = newValue.s
|
||||
}
|
||||
|
||||
const hasFilters = computed(() => {
|
||||
// this.value also contains the page parameter which we don't want to include in filters
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {filter_by, filter_value, filter_comparator, filter_concat, s} = value.value
|
||||
const def = {...getDefaultParams()}
|
||||
const {filter, s} = value.value
|
||||
const def = {...getDefaultTaskFilterParams()}
|
||||
|
||||
const params = {filter_by, filter_value, filter_comparator, filter_concat, s}
|
||||
const params = {filter, s}
|
||||
const defaultParams = {
|
||||
filter_by: def.filter_by,
|
||||
filter_value: def.filter_value,
|
||||
filter_comparator: def.filter_comparator,
|
||||
filter_concat: def.filter_concat,
|
||||
filter: def.filter,
|
||||
s: s ? def.s : undefined,
|
||||
}
|
||||
|
||||
@ -84,7 +77,7 @@ const hasFilters = computed(() => {
|
||||
const modalOpen = ref(false)
|
||||
|
||||
function clearFilters() {
|
||||
value.value = {...getDefaultParams()}
|
||||
value.value = {...getDefaultTaskFilterParams()}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -2,195 +2,35 @@
|
||||
<card
|
||||
class="filters has-overflow"
|
||||
:title="hasTitle ? $t('filters.title') : ''"
|
||||
role="search"
|
||||
>
|
||||
<FilterInput
|
||||
v-model="params.filter"
|
||||
:project-id="projectId"
|
||||
@blur="change()"
|
||||
/>
|
||||
|
||||
<div class="field is-flex is-flex-direction-column">
|
||||
<Fancycheckbox
|
||||
v-model="params.filter_include_nulls"
|
||||
@update:modelValue="change()"
|
||||
@blur="change()"
|
||||
>
|
||||
{{ $t('filters.attributes.includeNulls') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox
|
||||
v-model="filters.requireAllFilters"
|
||||
@update:modelValue="setFilterConcat()"
|
||||
>
|
||||
{{ $t('filters.attributes.requireAll') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox
|
||||
v-model="filters.done"
|
||||
@update:modelValue="setDoneFilter"
|
||||
>
|
||||
{{ $t('filters.attributes.showDoneTasks') }}
|
||||
</Fancycheckbox>
|
||||
<Fancycheckbox
|
||||
v-if="!['project.kanban', 'project.table'].includes($route.name as string)"
|
||||
v-model="sortAlphabetically"
|
||||
@update:modelValue="change()"
|
||||
>
|
||||
{{ $t('filters.attributes.sortAlphabetically') }}
|
||||
</Fancycheckbox>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('misc.search') }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
v-model="params.s"
|
||||
class="input"
|
||||
:placeholder="$t('misc.search')"
|
||||
@blur="change()"
|
||||
@keyup.enter="change()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.priority') }}</label>
|
||||
<div class="control single-value-control">
|
||||
<PrioritySelect
|
||||
v-model.number="filters.priority"
|
||||
:disabled="!filters.usePriority || undefined"
|
||||
@update:modelValue="setPriority"
|
||||
/>
|
||||
<Fancycheckbox
|
||||
v-model="filters.usePriority"
|
||||
@update:modelValue="setPriority"
|
||||
>
|
||||
{{ $t('filters.attributes.enablePriority') }}
|
||||
</Fancycheckbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.percentDone') }}</label>
|
||||
<div class="control single-value-control">
|
||||
<PercentDoneSelect
|
||||
v-model.number="filters.percentDone"
|
||||
:disabled="!filters.usePercentDone || undefined"
|
||||
@update:modelValue="setPercentDoneFilter"
|
||||
/>
|
||||
<Fancycheckbox
|
||||
v-model="filters.usePercentDone"
|
||||
@update:modelValue="setPercentDoneFilter"
|
||||
>
|
||||
{{ $t('filters.attributes.enablePercentDone') }}
|
||||
</Fancycheckbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.dueDate') }}</label>
|
||||
<div class="control">
|
||||
<DatepickerWithRange
|
||||
v-model="filters.dueDate"
|
||||
@update:modelValue="values => setDateFilter('due_date', values)"
|
||||
>
|
||||
<template #trigger="{toggle, buttonText}">
|
||||
<x-button
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
class="mb-2"
|
||||
@click.prevent.stop="toggle()"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</x-button>
|
||||
</template>
|
||||
</DatepickerWithRange>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.startDate') }}</label>
|
||||
<div class="control">
|
||||
<DatepickerWithRange
|
||||
v-model="filters.startDate"
|
||||
@update:modelValue="values => setDateFilter('start_date', values)"
|
||||
>
|
||||
<template #trigger="{toggle, buttonText}">
|
||||
<x-button
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
class="mb-2"
|
||||
@click.prevent.stop="toggle()"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</x-button>
|
||||
</template>
|
||||
</DatepickerWithRange>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.endDate') }}</label>
|
||||
<div class="control">
|
||||
<DatepickerWithRange
|
||||
v-model="filters.endDate"
|
||||
@update:modelValue="values => setDateFilter('end_date', values)"
|
||||
>
|
||||
<template #trigger="{toggle, buttonText}">
|
||||
<x-button
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
class="mb-2"
|
||||
@click.prevent.stop="toggle()"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</x-button>
|
||||
</template>
|
||||
</DatepickerWithRange>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.reminders') }}</label>
|
||||
<div class="control">
|
||||
<DatepickerWithRange
|
||||
v-model="filters.reminders"
|
||||
@update:modelValue="values => setDateFilter('reminders', values)"
|
||||
>
|
||||
<template #trigger="{toggle, buttonText}">
|
||||
<x-button
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
class="mb-2"
|
||||
@click.prevent.stop="toggle()"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</x-button>
|
||||
</template>
|
||||
</DatepickerWithRange>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.assignees') }}</label>
|
||||
<div class="control">
|
||||
<SelectUser
|
||||
v-model="entities.users"
|
||||
@select="changeMultiselectFilter('users', 'assignees')"
|
||||
@remove="changeMultiselectFilter('users', 'assignees')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.labels') }}</label>
|
||||
<div class="control labels-list">
|
||||
<EditLabels
|
||||
v-model="entities.labels"
|
||||
:creatable="false"
|
||||
@update:modelValue="changeLabelFilter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FilterInputDocs />
|
||||
|
||||
<template
|
||||
v-if="['filters.create', 'project.edit', 'filter.settings.edit'].includes($route.name as string)"
|
||||
v-if="hasFooter"
|
||||
#footer
|
||||
>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('project.projects') }}</label>
|
||||
<div class="control">
|
||||
<SelectProject
|
||||
v-model="entities.projects"
|
||||
:project-filter="p => p.id > 0"
|
||||
@select="changeMultiselectFilter('projects', 'project_id')"
|
||||
@remove="changeMultiselectFilter('projects', 'project_id')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<x-button
|
||||
variant="primary"
|
||||
@click.prevent.stop="changeAndEmitButton"
|
||||
>
|
||||
{{ $t('filters.showResults') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</card>
|
||||
</template>
|
||||
@ -200,419 +40,94 @@ export const ALPHABETICAL_SORT = 'title'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, onMounted, reactive, ref, shallowReactive, toRefs} from 'vue'
|
||||
import {camelCase} from 'camel-case'
|
||||
import {computed, ref} from 'vue'
|
||||
import {watchDebounced} from '@vueuse/core'
|
||||
|
||||
import type {ILabel} from '@/modelTypes/ILabel'
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
|
||||
import DatepickerWithRange from '@/components/date/datepickerWithRange.vue'
|
||||
import PrioritySelect from '@/components/tasks/partials/prioritySelect.vue'
|
||||
import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue'
|
||||
import EditLabels from '@/components/tasks/partials/editLabels.vue'
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import SelectUser from '@/components/input/SelectUser.vue'
|
||||
import SelectProject from '@/components/input/SelectProject.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'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {FILTER_OPERATORS, transformFilterStringForApi, transformFilterStringFromApi} from '@/helpers/filters'
|
||||
import FilterInputDocs from '@/components/project/partials/FilterInputDocs.vue'
|
||||
|
||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||
import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
|
||||
import {objectToSnakeCase} from '@/helpers/case'
|
||||
const {
|
||||
hasTitle= false,
|
||||
hasFooter = true,
|
||||
modelValue,
|
||||
} = defineProps<{
|
||||
hasTitle?: boolean,
|
||||
hasFooter?: boolean,
|
||||
modelValue: TaskFilterParams,
|
||||
}>()
|
||||
|
||||
import UserService from '@/services/user'
|
||||
import ProjectService from '@/services/project'
|
||||
const emit = defineEmits(['update:modelValue', 'showResultsButtonClicked'])
|
||||
|
||||
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
|
||||
import {getDefaultParams} from '@/composables/useTaskList'
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => {
|
||||
if (route.name?.startsWith('project.')) {
|
||||
return Number(route.params.projectId)
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
required: true,
|
||||
},
|
||||
hasTitle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
return undefined
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// FIXME: merge with DEFAULT_PARAMS in taskProject.js
|
||||
const DEFAULT_PARAMS = {
|
||||
const params = ref<TaskFilterParams>({
|
||||
sort_by: [],
|
||||
order_by: [],
|
||||
filter_by: [],
|
||||
filter_value: [],
|
||||
filter_comparator: [],
|
||||
filter_include_nulls: true,
|
||||
filter_concat: 'or',
|
||||
filter: '',
|
||||
filter_include_nulls: false,
|
||||
s: '',
|
||||
} as const
|
||||
|
||||
const DEFAULT_FILTERS = {
|
||||
done: false,
|
||||
dueDate: '',
|
||||
requireAllFilters: false,
|
||||
priority: 0,
|
||||
usePriority: false,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
percentDone: 0,
|
||||
usePercentDone: false,
|
||||
reminders: '',
|
||||
assignees: '',
|
||||
labels: '',
|
||||
project_id: '',
|
||||
} as const
|
||||
|
||||
const {modelValue} = toRefs(props)
|
||||
|
||||
const labelStore = useLabelStore()
|
||||
|
||||
const params = ref({...DEFAULT_PARAMS})
|
||||
const filters = ref({...DEFAULT_FILTERS})
|
||||
|
||||
const services = {
|
||||
users: shallowReactive(new UserService()),
|
||||
projects: shallowReactive(new ProjectService()),
|
||||
}
|
||||
|
||||
interface Entities {
|
||||
users: IUser[]
|
||||
labels: ILabel[]
|
||||
projects: IProject[]
|
||||
}
|
||||
|
||||
type EntityType = 'users' | 'labels' | 'projects'
|
||||
|
||||
const entities: Entities = reactive({
|
||||
users: [],
|
||||
labels: [],
|
||||
projects: [],
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
filters.value.requireAllFilters = params.value.filter_concat === 'and'
|
||||
})
|
||||
|
||||
// Using watchDebounced to prevent the filter re-triggering itself.
|
||||
// FIXME: Only here until this whole component changes a lot with the new filter syntax.
|
||||
watchDebounced(
|
||||
modelValue,
|
||||
(value) => {
|
||||
// FIXME: filters should only be converted to snake case in the last moment
|
||||
params.value = objectToSnakeCase(value)
|
||||
prepareFilters()
|
||||
() => modelValue,
|
||||
(value: TaskFilterParams) => {
|
||||
const val = {...value}
|
||||
val.filter = transformFilterStringFromApi(
|
||||
val?.filter || '',
|
||||
labelId => labelStore.getLabelById(labelId)?.title,
|
||||
projectId => projectStore.projects.value[projectId]?.title || null,
|
||||
)
|
||||
params.value = val
|
||||
},
|
||||
{immediate: true, debounce: 500, maxWait: 1000},
|
||||
)
|
||||
|
||||
const sortAlphabetically = computed({
|
||||
get() {
|
||||
return params.value?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
|
||||
},
|
||||
set(sortAlphabetically) {
|
||||
params.value.sort_by = sortAlphabetically
|
||||
? [ALPHABETICAL_SORT]
|
||||
: getDefaultParams().sort_by
|
||||
|
||||
change()
|
||||
},
|
||||
})
|
||||
const labelStore = useLabelStore()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
function change() {
|
||||
const newParams = {...params.value}
|
||||
newParams.filter_value = newParams.filter_value.map(v => v instanceof Date ? v.toISOString() : v)
|
||||
const filter = transformFilterStringForApi(
|
||||
params.value.filter,
|
||||
labelTitle => labelStore.filterLabelsByQuery([], labelTitle)[0]?.id || null,
|
||||
projectTitle => projectStore.searchProject(projectTitle)[0]?.id || null,
|
||||
)
|
||||
|
||||
let s = ''
|
||||
|
||||
// When the filter does not contain any filter tokens, assume a simple search and redirect the input
|
||||
const hasFilterQueries = FILTER_OPERATORS.find(o => filter.includes(o)) || false
|
||||
if (!hasFilterQueries) {
|
||||
s = filter
|
||||
}
|
||||
|
||||
const newParams = {
|
||||
...params.value,
|
||||
filter: s === '' ? filter : '',
|
||||
s,
|
||||
}
|
||||
|
||||
if (JSON.stringify(modelValue) === JSON.stringify(newParams)) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:modelValue', newParams)
|
||||
}
|
||||
|
||||
function prepareFilters() {
|
||||
prepareDone()
|
||||
prepareDate('due_date', 'dueDate')
|
||||
prepareDate('start_date', 'startDate')
|
||||
prepareDate('end_date', 'endDate')
|
||||
prepareSingleValue('priority', 'priority', 'usePriority', true)
|
||||
prepareSingleValue('percent_done', 'percentDone', 'usePercentDone', true)
|
||||
prepareDate('reminders', 'reminders')
|
||||
prepareRelatedObjectFilter('users', 'assignees')
|
||||
prepareProjectsFilter()
|
||||
|
||||
prepareSingleValue('labels')
|
||||
|
||||
const newLabels = typeof filters.value.labels === 'string'
|
||||
? filters.value.labels
|
||||
: ''
|
||||
const labelIds = newLabels.split(',').map(i => parseInt(i))
|
||||
|
||||
entities.labels = labelStore.getLabelsByIds(labelIds)
|
||||
}
|
||||
|
||||
function removePropertyFromFilter(filterName) {
|
||||
// Because of the way arrays work, we can only ever remove one element at once.
|
||||
// To remove multiple filter elements of the same name this function has to be called multiple times.
|
||||
for (const i in params.value.filter_by) {
|
||||
if (params.value.filter_by[i] === filterName) {
|
||||
params.value.filter_by.splice(i, 1)
|
||||
params.value.filter_comparator.splice(i, 1)
|
||||
params.value.filter_value.splice(i, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setDateFilter(filterName, {dateFrom, dateTo}) {
|
||||
dateFrom = parseDateOrString(dateFrom, null)
|
||||
dateTo = parseDateOrString(dateTo, null)
|
||||
|
||||
// Only filter if we have a date
|
||||
if (dateFrom !== null && dateTo !== null) {
|
||||
|
||||
// Check if we already have values in params and only update them if we do
|
||||
let foundStart = false
|
||||
let foundEnd = false
|
||||
params.value.filter_by.forEach((f, i) => {
|
||||
if (f === filterName && params.value.filter_comparator[i] === 'greater_equals') {
|
||||
foundStart = true
|
||||
params.value.filter_value[i] = dateFrom
|
||||
}
|
||||
if (f === filterName && params.value.filter_comparator[i] === 'less_equals') {
|
||||
foundEnd = true
|
||||
params.value.filter_value[i] = dateTo
|
||||
}
|
||||
})
|
||||
|
||||
if (!foundStart) {
|
||||
params.value.filter_by.push(filterName)
|
||||
params.value.filter_comparator.push('greater_equals')
|
||||
params.value.filter_value.push(dateFrom)
|
||||
}
|
||||
if (!foundEnd) {
|
||||
params.value.filter_by.push(filterName)
|
||||
params.value.filter_comparator.push('less_equals')
|
||||
params.value.filter_value.push(dateTo)
|
||||
}
|
||||
|
||||
filters.value[camelCase(filterName)] = {
|
||||
// Passing the dates as string values avoids an endless loop between values changing
|
||||
// in the datepicker (bubbling up to here) and changing here and bubbling down to the
|
||||
// datepicker (because there's a new date instance every time this function gets called).
|
||||
// See https://kolaente.dev/vikunja/frontend/issues/2384
|
||||
dateFrom: dateIsValid(dateFrom) ? formatISO(dateFrom) : dateFrom,
|
||||
dateTo: dateIsValid(dateTo) ? formatISO(dateTo) : dateTo,
|
||||
}
|
||||
change()
|
||||
return
|
||||
}
|
||||
|
||||
removePropertyFromFilter(filterName)
|
||||
removePropertyFromFilter(filterName)
|
||||
function changeAndEmitButton() {
|
||||
change()
|
||||
}
|
||||
|
||||
function prepareDate(filterName: string, variableName: 'dueDate' | 'startDate' | 'endDate' | 'reminders') {
|
||||
if (typeof params.value.filter_by === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
let foundDateStart: boolean | string = false
|
||||
let foundDateEnd: boolean | string = false
|
||||
for (const i in params.value.filter_by) {
|
||||
if (params.value.filter_by[i] === filterName && params.value.filter_comparator[i] === 'greater_equals') {
|
||||
foundDateStart = i
|
||||
}
|
||||
if (params.value.filter_by[i] === filterName && params.value.filter_comparator[i] === 'less_equals') {
|
||||
foundDateEnd = i
|
||||
}
|
||||
|
||||
if (foundDateStart !== false && foundDateEnd !== false) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (foundDateStart !== false && foundDateEnd !== false) {
|
||||
const startDate = new Date(params.value.filter_value[foundDateStart])
|
||||
const endDate = new Date(params.value.filter_value[foundDateEnd])
|
||||
filters.value[variableName] = {
|
||||
dateFrom: !isNaN(startDate)
|
||||
? `${startDate.getUTCFullYear()}-${startDate.getUTCMonth() + 1}-${startDate.getUTCDate()}`
|
||||
: params.value.filter_value[foundDateStart],
|
||||
dateTo: !isNaN(endDate)
|
||||
? `${endDate.getUTCFullYear()}-${endDate.getUTCMonth() + 1}-${endDate.getUTCDate()}`
|
||||
: params.value.filter_value[foundDateEnd],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setSingleValueFilter(filterName, variableName, useVariableName = '', comparator = 'equals') {
|
||||
if (useVariableName !== '' && !filters.value[useVariableName]) {
|
||||
removePropertyFromFilter(filterName)
|
||||
return
|
||||
}
|
||||
|
||||
let found = false
|
||||
params.value.filter_by.forEach((f, i) => {
|
||||
if (f === filterName) {
|
||||
found = true
|
||||
params.value.filter_value[i] = filters.value[variableName]
|
||||
}
|
||||
})
|
||||
|
||||
if (!found) {
|
||||
params.value.filter_by.push(filterName)
|
||||
params.value.filter_comparator.push(comparator)
|
||||
params.value.filter_value.push(filters.value[variableName])
|
||||
}
|
||||
|
||||
change()
|
||||
}
|
||||
|
||||
function prepareSingleValue(
|
||||
/** The filter name in the api. */
|
||||
filterName,
|
||||
/** The name of the variable in filters ref. */
|
||||
variableName = null,
|
||||
/** The name of the variable of the "Use this filter" variable. Will only be set if the parameter is not null. */
|
||||
useVariableName = null,
|
||||
/** Toggles if the value should be parsed as a number. */
|
||||
isNumber = false,
|
||||
) {
|
||||
if (variableName === null) {
|
||||
variableName = filterName
|
||||
}
|
||||
|
||||
let found = false
|
||||
for (const i in params.value.filter_by) {
|
||||
if (params.value.filter_by[i] === filterName) {
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (found === false && useVariableName !== null) {
|
||||
filters.value[useVariableName] = false
|
||||
return
|
||||
}
|
||||
|
||||
if (isNumber) {
|
||||
filters.value[variableName] = Number(params.value.filter_value[found])
|
||||
} else {
|
||||
filters.value[variableName] = params.value.filter_value[found]
|
||||
}
|
||||
|
||||
if (useVariableName !== null) {
|
||||
filters.value[useVariableName] = true
|
||||
}
|
||||
}
|
||||
|
||||
function prepareDone() {
|
||||
// Set filters.done based on params
|
||||
if (typeof params.value.filter_by === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
filters.value.done = params.value.filter_by.some((f) => f === 'done') === false
|
||||
}
|
||||
|
||||
async function prepareRelatedObjectFilter(kind: EntityType, filterName = null, servicePrefix: Omit<EntityType, 'labels'> | null = null) {
|
||||
if (filterName === null) {
|
||||
filterName = kind
|
||||
}
|
||||
|
||||
if (servicePrefix === null) {
|
||||
servicePrefix = kind
|
||||
}
|
||||
|
||||
prepareSingleValue(filterName)
|
||||
if (typeof filters.value[filterName] === 'undefined' || filters.value[filterName] === '') {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't load things if we already have something loaded.
|
||||
// This is not the most ideal solution because it prevents a re-population when filters are changed
|
||||
// from the outside. It is still fine because we're not changing them from the outside, other than
|
||||
// loading them initially.
|
||||
if (entities[kind].length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
entities[kind] = await services[servicePrefix].getAll({}, {s: filters.value[filterName]})
|
||||
}
|
||||
|
||||
async function prepareProjectsFilter() {
|
||||
await prepareRelatedObjectFilter('projects', 'project_id')
|
||||
entities.projects = entities.projects.filter(p => p.id > 0)
|
||||
}
|
||||
|
||||
function setDoneFilter() {
|
||||
if (filters.value.done) {
|
||||
removePropertyFromFilter('done')
|
||||
} else {
|
||||
params.value.filter_by.push('done')
|
||||
params.value.filter_comparator.push('equals')
|
||||
params.value.filter_value.push('false')
|
||||
}
|
||||
change()
|
||||
}
|
||||
|
||||
function setFilterConcat() {
|
||||
if (filters.value.requireAllFilters) {
|
||||
params.value.filter_concat = 'and'
|
||||
} else {
|
||||
params.value.filter_concat = 'or'
|
||||
}
|
||||
change()
|
||||
}
|
||||
|
||||
function setPriority() {
|
||||
setSingleValueFilter('priority', 'priority', 'usePriority')
|
||||
}
|
||||
|
||||
function setPercentDoneFilter() {
|
||||
setSingleValueFilter('percent_done', 'percentDone', 'usePercentDone')
|
||||
}
|
||||
|
||||
async function changeMultiselectFilter(kind: EntityType, filterName) {
|
||||
await nextTick()
|
||||
|
||||
if (entities[kind].length === 0) {
|
||||
removePropertyFromFilter(filterName)
|
||||
change()
|
||||
return
|
||||
}
|
||||
|
||||
const ids = entities[kind].map(u => kind === 'users' ? u.username : u.id)
|
||||
|
||||
filters.value[filterName] = ids.join(',')
|
||||
setSingleValueFilter(filterName, filterName, '', 'in')
|
||||
}
|
||||
|
||||
function changeLabelFilter() {
|
||||
if (entities.labels.length === 0) {
|
||||
removePropertyFromFilter('labels')
|
||||
change()
|
||||
return
|
||||
}
|
||||
|
||||
const labelIDs = entities.labels.map(u => u.id)
|
||||
filters.value.labels = labelIDs.join(',')
|
||||
setSingleValueFilter('labels', 'labels', '', 'in')
|
||||
emit('showResultsButtonClicked')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.single-value-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.fancycheckbox {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.datepicker-with-range-container .popup) {
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
|
@ -77,7 +77,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['taskAdded'])
|
||||
|
||||
const newTaskTitle = ref('')
|
||||
const newTaskInput = useAutoHeightTextarea(newTaskTitle)
|
||||
const {textarea: newTaskInput} = useAutoHeightTextarea(newTaskTitle)
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const authStore = useAuthStore()
|
||||
|
@ -6,6 +6,7 @@ import {debouncedWatch, tryOnMounted, useWindowSize, type MaybeRef} from '@vueus
|
||||
export function useAutoHeightTextarea(value: MaybeRef<string>) {
|
||||
const textarea = ref<HTMLTextAreaElement | null>(null)
|
||||
const minHeight = ref(0)
|
||||
const height = ref('')
|
||||
|
||||
// adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34
|
||||
function resize(textareaEl: HTMLTextAreaElement | null) {
|
||||
@ -19,18 +20,17 @@ export function useAutoHeightTextarea(value: MaybeRef<string>) {
|
||||
textareaEl.value = textareaEl.placeholder
|
||||
}
|
||||
|
||||
const cs = getComputedStyle(textareaEl)
|
||||
// const cs = getComputedStyle(textareaEl)
|
||||
|
||||
textareaEl.style.minHeight = ''
|
||||
textareaEl.style.height = '0'
|
||||
const offset = textareaEl.offsetHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
|
||||
const height = textareaEl.scrollHeight + offset + 'px'
|
||||
height.value = textareaEl.scrollHeight + 'px'
|
||||
|
||||
textareaEl.style.height = height
|
||||
textareaEl.style.height = height.value
|
||||
|
||||
// calculate min-height for the first time
|
||||
if (!minHeight.value) {
|
||||
minHeight.value = parseFloat(height)
|
||||
minHeight.value = parseFloat(height.value)
|
||||
}
|
||||
|
||||
textareaEl.style.minHeight = minHeight.value.toString()
|
||||
@ -68,5 +68,8 @@ export function useAutoHeightTextarea(value: MaybeRef<string>) {
|
||||
},
|
||||
)
|
||||
|
||||
return textarea
|
||||
return {
|
||||
textarea,
|
||||
height,
|
||||
}
|
||||
}
|
@ -2,7 +2,7 @@ import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useRouteQuery} from '@vueuse/router'
|
||||
|
||||
import TaskCollectionService from '@/services/taskCollection'
|
||||
import TaskCollectionService, {getDefaultTaskFilterParams} from '@/services/taskCollection'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import {error} from '@/message'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
@ -24,16 +24,6 @@ export interface SortBy {
|
||||
done_at?: Order,
|
||||
}
|
||||
|
||||
// FIXME: merge with DEFAULT_PARAMS in filters.vue
|
||||
export const getDefaultParams = () => ({
|
||||
sort_by: ['position', 'id'],
|
||||
order_by: ['asc', 'desc'],
|
||||
filter_by: ['done'],
|
||||
filter_value: ['false'],
|
||||
filter_comparator: ['equals'],
|
||||
filter_concat: 'and',
|
||||
})
|
||||
|
||||
const SORT_BY_DEFAULT: SortBy = {
|
||||
id: 'desc',
|
||||
}
|
||||
@ -67,7 +57,7 @@ export function useTaskList(projectIdGetter: ComputedGetter<IProject['id']>, sor
|
||||
|
||||
const projectId = computed(() => projectIdGetter())
|
||||
|
||||
const params = ref({...getDefaultParams()})
|
||||
const params = ref({...getDefaultTaskFilterParams()})
|
||||
|
||||
const search = ref('')
|
||||
const page = useRouteQuery('page', '1', { transform: Number })
|
||||
|
156
frontend/src/helpers/filters.test.ts
Normal file
156
frontend/src/helpers/filters.test.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {transformFilterStringForApi, transformFilterStringFromApi} from '@/helpers/filters'
|
||||
|
||||
const nullTitleToIdResolver = (title: string) => null
|
||||
const nullIdToTitleResolver = (id: number) => null
|
||||
describe('Filter Transformation', () => {
|
||||
|
||||
const fieldCases = {
|
||||
'done': 'done',
|
||||
'priority': 'priority',
|
||||
'percentDone': 'percent_done',
|
||||
'dueDate': 'due_date',
|
||||
'startDate': 'start_date',
|
||||
'endDate': 'end_date',
|
||||
'doneAt': 'done_at',
|
||||
'reminders': 'reminders',
|
||||
'assignees': 'assignees',
|
||||
'labels': 'labels',
|
||||
}
|
||||
|
||||
describe('For api', () => {
|
||||
for (const c in fieldCases) {
|
||||
it('should transform all filter params for ' + c + ' to snake_case', () => {
|
||||
const transformed = transformFilterStringForApi(c + ' = ipsum', nullTitleToIdResolver, nullTitleToIdResolver)
|
||||
|
||||
expect(transformed).toBe(fieldCases[c] + ' = ipsum')
|
||||
})
|
||||
}
|
||||
|
||||
it('should correctly resolve labels', () => {
|
||||
const transformed = transformFilterStringForApi(
|
||||
'labels = lorem',
|
||||
(title: string) => 1,
|
||||
nullTitleToIdResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels = 1')
|
||||
})
|
||||
|
||||
it('should correctly resolve multiple labels', () => {
|
||||
const transformed = transformFilterStringForApi(
|
||||
'labels = lorem && dueDate = now && labels = ipsum',
|
||||
(title: string) => {
|
||||
switch (title) {
|
||||
case 'lorem':
|
||||
return 1
|
||||
case 'ipsum':
|
||||
return 2
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
nullTitleToIdResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels = 1&& due_date = now && labels = 2')
|
||||
})
|
||||
|
||||
it('should correctly resolve projects', () => {
|
||||
const transformed = transformFilterStringForApi(
|
||||
'project = lorem',
|
||||
nullTitleToIdResolver,
|
||||
(title: string) => 1,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('project = 1')
|
||||
})
|
||||
|
||||
it('should correctly resolve multiple projects', () => {
|
||||
const transformed = transformFilterStringForApi(
|
||||
'project = lorem && dueDate = now || project = ipsum',
|
||||
nullTitleToIdResolver,
|
||||
(title: string) => {
|
||||
switch (title) {
|
||||
case 'lorem':
|
||||
return 1
|
||||
case 'ipsum':
|
||||
return 2
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
expect(transformed).toBe('project = 1&& due_date = now || project = 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('To API', () => {
|
||||
for (const c in fieldCases) {
|
||||
it('should transform all filter params for ' + c + ' to snake_case', () => {
|
||||
const transformed = transformFilterStringFromApi(fieldCases[c] + ' = ipsum', nullTitleToIdResolver, nullTitleToIdResolver)
|
||||
|
||||
expect(transformed).toBe(c + ' = ipsum')
|
||||
})
|
||||
}
|
||||
|
||||
it('should correctly resolve labels', () => {
|
||||
const transformed = transformFilterStringFromApi(
|
||||
'labels = 1',
|
||||
(id: number) => 'lorem',
|
||||
nullIdToTitleResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels = lorem')
|
||||
})
|
||||
|
||||
it('should correctly resolve multiple labels', () => {
|
||||
const transformed = transformFilterStringFromApi(
|
||||
'labels = 1 && due_date = now && labels = 2',
|
||||
(id: number) => {
|
||||
switch (id) {
|
||||
case 1:
|
||||
return 'lorem'
|
||||
case 2:
|
||||
return 'ipsum'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
nullIdToTitleResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels = lorem&& dueDate = now && labels = ipsum')
|
||||
})
|
||||
|
||||
it('should correctly resolve projects', () => {
|
||||
const transformed = transformFilterStringFromApi(
|
||||
'project = 1',
|
||||
nullIdToTitleResolver,
|
||||
(id: number) => 'lorem',
|
||||
)
|
||||
|
||||
expect(transformed).toBe('project = lorem')
|
||||
})
|
||||
|
||||
it('should correctly resolve multiple projects', () => {
|
||||
const transformed = transformFilterStringFromApi(
|
||||
'project = lorem && due_date = now || project = ipsum',
|
||||
nullIdToTitleResolver,
|
||||
(id: number) => {
|
||||
switch (id) {
|
||||
case 1:
|
||||
return 'lorem'
|
||||
case 2:
|
||||
return 'ipsum'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
expect(transformed).toBe('project = lorem && dueDate = now || project = ipsum')
|
||||
})
|
||||
})
|
||||
})
|
164
frontend/src/helpers/filters.ts
Normal file
164
frontend/src/helpers/filters.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import {snakeCase} from 'snake-case'
|
||||
|
||||
export const DATE_FIELDS = [
|
||||
'dueDate',
|
||||
'startDate',
|
||||
'endDate',
|
||||
'doneAt',
|
||||
'reminders',
|
||||
]
|
||||
|
||||
export const ASSIGNEE_FIELDS = [
|
||||
'assignees',
|
||||
]
|
||||
|
||||
export const LABEL_FIELDS = [
|
||||
'labels',
|
||||
]
|
||||
|
||||
export const PROJECT_FIELDS = [
|
||||
'project',
|
||||
]
|
||||
|
||||
export const AUTOCOMPLETE_FIELDS = [
|
||||
...LABEL_FIELDS,
|
||||
...ASSIGNEE_FIELDS,
|
||||
...PROJECT_FIELDS,
|
||||
]
|
||||
|
||||
export const AVAILABLE_FILTER_FIELDS = [
|
||||
'done',
|
||||
'priority',
|
||||
'percentDone',
|
||||
...DATE_FIELDS,
|
||||
...ASSIGNEE_FIELDS,
|
||||
...LABEL_FIELDS,
|
||||
]
|
||||
|
||||
export const FILTER_OPERATORS = [
|
||||
'!=',
|
||||
'=',
|
||||
'>',
|
||||
'>=',
|
||||
'<',
|
||||
'<=',
|
||||
'like',
|
||||
'in',
|
||||
'?=',
|
||||
]
|
||||
|
||||
export const FILTER_JOIN_OPERATOR = [
|
||||
'&&',
|
||||
'||',
|
||||
'(',
|
||||
')',
|
||||
]
|
||||
|
||||
export const FILTER_OPERATORS_REGEX = '(<|>|<=|>=|=|!=)'
|
||||
|
||||
function getFieldPattern(field: string): RegExp {
|
||||
return new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&|()]+\\1?)?', 'ig')
|
||||
}
|
||||
|
||||
export function transformFilterStringForApi(
|
||||
filter: string,
|
||||
labelResolver: (title: string) => number | null,
|
||||
projectResolver: (title: string) => number | null,
|
||||
): string {
|
||||
|
||||
if (filter.trim() === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Transform labels to ids
|
||||
LABEL_FIELDS.forEach(field => {
|
||||
const pattern = getFieldPattern(field)
|
||||
|
||||
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
|
||||
if (keyword) {
|
||||
const labelId = labelResolver(keyword.trim())
|
||||
if (labelId !== null) {
|
||||
filter = filter.replace(keyword, String(labelId))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// Transform projects to ids
|
||||
PROJECT_FIELDS.forEach(field => {
|
||||
const pattern = getFieldPattern(field)
|
||||
|
||||
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
|
||||
if (keyword) {
|
||||
const projectId = projectResolver(keyword.trim())
|
||||
if (projectId !== null) {
|
||||
filter = filter.replace(keyword, String(projectId))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Transform all attributes to snake case
|
||||
AVAILABLE_FILTER_FIELDS.forEach(f => {
|
||||
filter = filter.replace(f, snakeCase(f))
|
||||
})
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
export function transformFilterStringFromApi(
|
||||
filter: string,
|
||||
labelResolver: (id: number) => string | null,
|
||||
projectResolver: (id: number) => string | null,
|
||||
): string {
|
||||
|
||||
if (filter.trim() === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Transform all attributes from snake case
|
||||
AVAILABLE_FILTER_FIELDS.forEach(f => {
|
||||
filter = filter.replace(snakeCase(f), f)
|
||||
})
|
||||
|
||||
// Transform labels to their titles
|
||||
LABEL_FIELDS.forEach(field => {
|
||||
const pattern = getFieldPattern(field)
|
||||
|
||||
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
|
||||
if (keyword) {
|
||||
const labelTitle = labelResolver(Number(keyword.trim()))
|
||||
if (labelTitle !== null) {
|
||||
filter = filter.replace(keyword, labelTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Transform projects to ids
|
||||
PROJECT_FIELDS.forEach(field => {
|
||||
const pattern = getFieldPattern(field)
|
||||
|
||||
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
|
||||
if (keyword) {
|
||||
const project = projectResolver(Number(keyword.trim()))
|
||||
if (project !== null) {
|
||||
filter = filter.replace(keyword, project)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return filter
|
||||
}
|
@ -385,6 +385,7 @@
|
||||
"filters": {
|
||||
"title": "Filters",
|
||||
"clear": "Clear Filters",
|
||||
"showResults": "Show results",
|
||||
"attributes": {
|
||||
"title": "Title",
|
||||
"titlePlaceholder": "The saved filter title goes here…",
|
||||
@ -415,6 +416,52 @@
|
||||
"edit": {
|
||||
"title": "Edit This Saved Filter",
|
||||
"success": "The filter was saved successfully."
|
||||
},
|
||||
"query": {
|
||||
"title": "Query",
|
||||
"placeholder": "Type a search or filter query…",
|
||||
"help": {
|
||||
"intro": "To filter tasks, you can use a query syntax similar to SQL. The available fields for filtering include:",
|
||||
"link": "How does this work?",
|
||||
"canUseDatemath": "You can date math to set relative dates. Click on the date value in a query to find out more.",
|
||||
"fields": {
|
||||
"done": "Whether the task is completed or not",
|
||||
"priority": "The priority level of the task (1-5)",
|
||||
"percentDone": "The percentage of completion for the task (0-100)",
|
||||
"dueDate": "The due date of the task",
|
||||
"startDate": "The start date of the task",
|
||||
"endDate": "The end date of the task",
|
||||
"doneAt": "The date and time when the task was completed",
|
||||
"assignees": "The assignees of the task",
|
||||
"labels": "The labels associated with the task",
|
||||
"project": "The project the task belongs to (only available for saved filters, not on a project level)"
|
||||
},
|
||||
"operators": {
|
||||
"intro": "The available operators for filtering include:",
|
||||
"notEqual": "Not equal to",
|
||||
"equal": "Equal to",
|
||||
"greaterThan": "Greater than",
|
||||
"greaterThanOrEqual": "Greater than or equal to",
|
||||
"lessThan": "Less than",
|
||||
"lessThanOrEqual": "Less than or equal to",
|
||||
"like": "Matches a pattern (using wildcard %)",
|
||||
"in": "Matches any value in a list"
|
||||
},
|
||||
"logicalOperators": {
|
||||
"intro": "To combine multiple conditions, you can use the following logical operators:",
|
||||
"and": "AND operator, matches if all conditions are true",
|
||||
"or": "OR operator, matches if any of the conditions are true",
|
||||
"parentheses": "Parentheses for grouping conditions"
|
||||
},
|
||||
"examples": {
|
||||
"intro": "Here are some examples of filter queries:",
|
||||
"priorityEqual": "Matches tasks with priority level 4",
|
||||
"dueDatePast": "Matches tasks with a due date in the past",
|
||||
"undoneHighPriority": "Matches undone tasks with priority level 3 or higher",
|
||||
"assigneesIn": "Matches tasks assigned to either \"user1\" or \"user2\"",
|
||||
"priorityOneOrTwoPastDue": "Matches tasks with priority level 1 or 2 and a due date in the past"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"migrate": {
|
||||
@ -585,23 +632,42 @@
|
||||
"to": "To",
|
||||
"from": "From",
|
||||
"fromto": "{from} to {to}",
|
||||
"date": "Date",
|
||||
"ranges": {
|
||||
"today": "Today",
|
||||
|
||||
"thisWeek": "This Week",
|
||||
"restOfThisWeek": "The Rest of This Week",
|
||||
"nextWeek": "Next Week",
|
||||
"next7Days": "Next 7 Days",
|
||||
"lastWeek": "Last Week",
|
||||
|
||||
"thisMonth": "This Month",
|
||||
"restOfThisMonth": "The Rest of This Month",
|
||||
"nextMonth": "Next Month",
|
||||
"next30Days": "Next 30 Days",
|
||||
"lastMonth": "Last Month",
|
||||
|
||||
"thisYear": "This Year",
|
||||
"restOfThisYear": "The Rest of This Year"
|
||||
},
|
||||
"values": {
|
||||
"now": "Now",
|
||||
"startOfToday": "Start of today",
|
||||
"endOfToday": "End of today",
|
||||
"beginningOflastWeek": "Beginning of last week",
|
||||
"endOfLastWeek": "End of last week",
|
||||
"beginningOfThisWeek": "Beginning of this week",
|
||||
"endOfThisWeek": "End of this week",
|
||||
"startOfNextWeek": "Start of next week",
|
||||
"endOfNextWeek": "End of next week",
|
||||
"in7Days": "In 7 days",
|
||||
"beginningOfLastMonth": "Beginning of last month",
|
||||
"endOfLastMonth": "End of last month",
|
||||
"startOfThisMonth": "Start of this month",
|
||||
"endOfThisMonth": "End of this month",
|
||||
"startOfNextMonth": "Start of next month",
|
||||
"endOfNextMonth": "End of next month",
|
||||
"in30Days": "In 30 days",
|
||||
"startOfThisYear": "Beginning of this year",
|
||||
"endOfThisYear": "End of this year"
|
||||
}
|
||||
},
|
||||
"datemathHelp": {
|
||||
|
@ -1,12 +1,19 @@
|
||||
import type {IAbstract} from './IAbstract'
|
||||
import type {IUser} from './IUser'
|
||||
import type {IFilter} from '@/types/IFilter'
|
||||
|
||||
interface Filters {
|
||||
sortBy: ('start_date' | 'done' | 'id' | 'position')[],
|
||||
orderBy: ('asc' | 'desc')[],
|
||||
filter: string,
|
||||
filterIncludeNulls: boolean,
|
||||
s: string,
|
||||
}
|
||||
|
||||
export interface ISavedFilter extends IAbstract {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
filters: IFilter
|
||||
filters: Filters
|
||||
|
||||
owner: IUser
|
||||
created: Date
|
||||
|
@ -11,11 +11,9 @@ export default class SavedFilterModel extends AbstractModel<ISavedFilter> implem
|
||||
filters: ISavedFilter['filters'] = {
|
||||
sortBy: ['done', 'id'],
|
||||
orderBy: ['asc', 'desc'],
|
||||
filterBy: ['done'],
|
||||
filterValue: ['false'],
|
||||
filterComparator: ['equals'],
|
||||
filterConcat: 'and',
|
||||
filter: 'done = false',
|
||||
filterIncludeNulls: true,
|
||||
s: '',
|
||||
}
|
||||
|
||||
owner: IUser = {}
|
||||
|
@ -63,9 +63,6 @@ export default class SavedFilterService extends AbstractService<ISavedFilter> {
|
||||
// the filter values in snake_sćase for url parameters.
|
||||
model.filters = objectToCamelCase(model.filters)
|
||||
|
||||
// Make sure all filterValues are passes as strings. This is a requirement of the api.
|
||||
model.filters.filterValue = model.filters.filterValue.map(v => String(v))
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
|
@ -3,15 +3,22 @@ import TaskModel from '@/models/task'
|
||||
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
// FIXME: unite with other filter params types
|
||||
export interface GetAllTasksParams {
|
||||
sort_by: ('start_date' | 'done' | 'id')[],
|
||||
order_by: ('asc' | 'asc' | 'desc')[],
|
||||
filter_by: 'start_date'[],
|
||||
filter_comparator: ('greater_equals' | 'less_equals')[],
|
||||
filter_value: [string, string] // [dateFrom, dateTo],
|
||||
filter_concat: 'and',
|
||||
export interface TaskFilterParams {
|
||||
sort_by: ('start_date' | 'done' | 'id' | 'position')[],
|
||||
order_by: ('asc' | 'desc')[],
|
||||
filter: string,
|
||||
filter_include_nulls: boolean,
|
||||
s: string,
|
||||
}
|
||||
|
||||
export function getDefaultTaskFilterParams(): TaskFilterParams {
|
||||
return {
|
||||
sort_by: ['position', 'id'],
|
||||
order_by: ['asc', 'desc'],
|
||||
filter: '',
|
||||
filter_include_nulls: false,
|
||||
s: '',
|
||||
}
|
||||
}
|
||||
|
||||
export default class TaskCollectionService extends AbstractService<ITask> {
|
||||
|
@ -33,6 +33,10 @@ export const useLabelStore = defineStore('label', () => {
|
||||
const getLabelsByIds = computed(() => {
|
||||
return (ids: ILabel['id'][]) => Object.values(labels.value).filter(({id}) => ids.includes(id))
|
||||
})
|
||||
|
||||
const getLabelById = computed(() => {
|
||||
return (labelId: ILabel['id']) => Object.values(labels.value).find(({id}) => id === labelId)
|
||||
})
|
||||
|
||||
// **
|
||||
// * Checks if a project of labels is available in the store and filters them then query
|
||||
@ -138,6 +142,7 @@ export const useLabelStore = defineStore('label', () => {
|
||||
isLoading,
|
||||
|
||||
getLabelsByIds,
|
||||
getLabelById,
|
||||
filterLabelsByQuery,
|
||||
getLabelsByExactTitles,
|
||||
|
||||
|
@ -1,9 +0,0 @@
|
||||
export interface IFilter {
|
||||
sortBy: ('done' | 'id')[]
|
||||
orderBy: ('asc' | 'desc')[]
|
||||
filterBy: 'done'[]
|
||||
filterValue: 'false'[]
|
||||
filterComparator: 'equals'[]
|
||||
filterConcat: 'and'
|
||||
filterIncludeNulls: boolean
|
||||
}
|
@ -59,6 +59,7 @@
|
||||
:class="{ 'disabled': filterService.loading}"
|
||||
:disabled="filterService.loading"
|
||||
class="has-no-shadow has-no-border"
|
||||
:has-footer="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -296,6 +296,7 @@ import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
import {isSavedFilter} from '@/services/savedFilter'
|
||||
import {success} from '@/message'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import type {TaskFilterParams} from '@/services/taskCollection'
|
||||
|
||||
const {
|
||||
projectId = undefined,
|
||||
@ -347,11 +348,12 @@ const collapsedBuckets = ref<CollapsedBuckets>({})
|
||||
const taskUpdating = ref<{[id: ITask['id']]: boolean}>({})
|
||||
const oneTaskUpdating = ref(false)
|
||||
|
||||
const params = ref({
|
||||
filter_by: [],
|
||||
filter_value: [],
|
||||
filter_comparator: [],
|
||||
filter_concat: 'and',
|
||||
const params = ref<TaskFilterParams>({
|
||||
sort_by: [],
|
||||
order_by: [],
|
||||
filter: '',
|
||||
filter_include_nulls: false,
|
||||
s: '',
|
||||
})
|
||||
|
||||
const getTaskDraggableTaskComponentData = computed(() => (bucket: IBucket) => {
|
||||
|
@ -8,7 +8,7 @@ import {useRouteFilters} from '@/composables/useRouteFilters'
|
||||
import {useGanttTaskList} from './useGanttTaskList'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {GetAllTasksParams} from '@/services/taskCollection'
|
||||
import type {TaskFilterParams} from '@/services/taskCollection'
|
||||
|
||||
import type {DateISO} from '@/types/DateISO'
|
||||
import type {DateKebab} from '@/types/DateKebab'
|
||||
@ -75,14 +75,11 @@ function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw {
|
||||
}
|
||||
}
|
||||
|
||||
function ganttFiltersToApiParams(filters: GanttFilters): GetAllTasksParams {
|
||||
function ganttFiltersToApiParams(filters: GanttFilters): TaskFilterParams {
|
||||
return {
|
||||
sort_by: ['start_date', 'done', 'id'],
|
||||
order_by: ['asc', 'asc', 'desc'],
|
||||
filter_by: ['start_date', 'start_date'],
|
||||
filter_comparator: ['greater_equals', 'less_equals'],
|
||||
filter_value: [isoToKebabDate(filters.dateFrom), isoToKebabDate(filters.dateTo)],
|
||||
filter_concat: 'and',
|
||||
filter: 'start_date >= "' + isoToKebabDate(filters.dateFrom) + '" && start_date <= "' + isoToKebabDate(filters.dateTo) + '"',
|
||||
filter_include_nulls: filters.showTasksWithoutDates,
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import {klona} from 'klona/lite'
|
||||
import type {Filters} from '@/composables/useRouteFilters'
|
||||
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
|
||||
|
||||
import TaskCollectionService, {type GetAllTasksParams} from '@/services/taskCollection'
|
||||
import TaskCollectionService, {type TaskFilterParams} from '@/services/taskCollection'
|
||||
import TaskService from '@/services/task'
|
||||
|
||||
import TaskModel from '@/models/task'
|
||||
@ -13,7 +13,7 @@ import {error, success} from '@/message'
|
||||
// FIXME: unify with general `useTaskList`
|
||||
export function useGanttTaskList<F extends Filters>(
|
||||
filters: Ref<F>,
|
||||
filterToApiParams: (filters: F) => GetAllTasksParams,
|
||||
filterToApiParams: (filters: F) => TaskFilterParams,
|
||||
options: {
|
||||
loadAll?: boolean,
|
||||
} = {
|
||||
@ -26,7 +26,7 @@ export function useGanttTaskList<F extends Filters>(
|
||||
|
||||
const tasks = ref<Map<ITask['id'], ITask>>(new Map())
|
||||
|
||||
async function fetchTasks(params: GetAllTasksParams, page = 1): Promise<ITask[]> {
|
||||
async function fetchTasks(params: TaskFilterParams, page = 1): Promise<ITask[]> {
|
||||
const tasks = await taskCollectionService.getAll({projectId: filters.value.projectId}, params, page) as ITask[]
|
||||
if (options.loadAll && page < taskCollectionService.totalPages) {
|
||||
const nextTasks = await fetchTasks(params, page + 1)
|
||||
@ -40,7 +40,7 @@ export function useGanttTaskList<F extends Filters>(
|
||||
* Normally there is no need to trigger this manually
|
||||
*/
|
||||
async function loadTasks() {
|
||||
const params: GetAllTasksParams = filterToApiParams(filters.value)
|
||||
const params: TaskFilterParams = filterToApiParams(filters.value)
|
||||
|
||||
const loadedTasks = await fetchTasks(params)
|
||||
tasks.value = new Map()
|
||||
|
Reference in New Issue
Block a user