feat(filters): make date values in filter query editable

This commit is contained in:
kolaente 2024-02-17 17:48:22 +01:00
parent 3bd639a110
commit c22daab28c
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
5 changed files with 156 additions and 131 deletions

View File

@ -75,14 +75,15 @@
<p> <p>
{{ $t('input.datemathHelp.canuse') }} {{ $t('input.datemathHelp.canuse') }}
<BaseButton
class="has-text-primary"
@click="showHowItWorks = true"
>
{{ $t('input.datemathHelp.learnhow') }}
</BaseButton>
</p> </p>
<BaseButton
class="has-text-primary"
@click="showHowItWorks = true"
>
{{ $t('input.datemathHelp.learnhow') }}
</BaseButton>
<modal <modal
:enabled="showHowItWorks" :enabled="showHowItWorks"
transition-name="fade" transition-name="fade"
@ -90,7 +91,7 @@
variant="hint-modal" variant="hint-modal"
@close="() => showHowItWorks = false" @close="() => showHowItWorks = false"
> >
<DatemathHelp /> <DatemathHelp/>
</modal> </modal>
</div> </div>
</div> </div>
@ -111,7 +112,7 @@ import Popup from '@/components/misc/popup.vue'
import {DATE_RANGES} from '@/components/date/dateRanges' import {DATE_RANGES} from '@/components/date/dateRanges'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import DatemathHelp from '@/components/date/datemathHelp.vue' import DatemathHelp from '@/components/date/datemathHelp.vue'
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage' import {getFlatpickrLanguage} from '@/helpers/flatpickrLanguage'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {

View File

@ -1,13 +1,9 @@
<template> <template>
<div class="datepicker-with-range-container"> <div class="datepicker-with-range-container">
<Popup> <Popup
<template #trigger="{toggle}"> :open="open"
<slot @close="() => emit('close')"
name="trigger" >
:toggle="toggle"
:button-text="buttonText"
/>
</template>
<template #content="{isOpen}"> <template #content="{isOpen}">
<div <div
class="datepicker-with-range" class="datepicker-with-range"
@ -16,45 +12,26 @@
<div class="selections"> <div class="selections">
<BaseButton <BaseButton
:class="{'is-active': customRangeActive}" :class="{'is-active': customRangeActive}"
@click="setDateRange(null)" @click="setDate(null)"
> >
{{ $t('misc.custom') }} {{ $t('misc.custom') }}
</BaseButton> </BaseButton>
<BaseButton <BaseButton
v-for="(value, text) in DATE_RANGES" v-for="(value, text) in DATE_RANGES"
:key="text" :key="text"
:class="{'is-active': from === value[0] && to === value[1]}" :class="{'is-active': date === value[0]}"
@click="setDateRange(value)" @click="setDate(value[0])"
> >
{{ $t(`input.datepickerRange.ranges.${text}`) }} {{ $t(`input.datepickerRange.ranges.${text}`) }}
</BaseButton> </BaseButton>
</div> </div>
<div class="flatpickr-container input-group"> <div class="flatpickr-container input-group">
<label class="label"> <label class="label">
{{ $t('input.datepickerRange.from') }} {{ $t('input.datepickerRange.date') }}
<div class="field has-addons"> <div class="field has-addons">
<div class="control is-fullwidth"> <div class="control is-fullwidth">
<input <input
v-model="from" v-model="date"
class="input"
type="text"
>
</div>
<div class="control">
<x-button
icon="calendar"
variant="secondary"
data-toggle
/>
</div>
</div>
</label>
<label class="label">
{{ $t('input.datepickerRange.to') }}
<div class="field has-addons">
<div class="control is-fullwidth">
<input
v-model="to"
class="input" class="input"
type="text" type="text"
> >
@ -69,20 +46,21 @@
</div> </div>
</label> </label>
<flat-pickr <flat-pickr
v-model="flatpickrRange" v-model="flatpickrDate"
:config="flatPickerConfig" :config="flatPickerConfig"
/> />
<p> <p>
{{ $t('input.datemathHelp.canuse') }} {{ $t('input.datemathHelp.canuse') }}
<BaseButton
class="has-text-primary"
@click="showHowItWorks = true"
>
{{ $t('input.datemathHelp.learnhow') }}
</BaseButton>
</p> </p>
<BaseButton
class="has-text-primary"
@click="showHowItWorks = true"
>
{{ $t('input.datemathHelp.learnhow') }}
</BaseButton>
<modal <modal
:enabled="showHowItWorks" :enabled="showHowItWorks"
transition-name="fade" transition-name="fade"
@ -90,7 +68,7 @@
variant="hint-modal" variant="hint-modal"
@close="() => showHowItWorks = false" @close="() => showHowItWorks = false"
> >
<DatemathHelp /> <DatemathHelp/>
</modal> </modal>
</div> </div>
</div> </div>
@ -111,15 +89,19 @@ import Popup from '@/components/misc/popup.vue'
import {DATE_RANGES} from '@/components/date/dateRanges' import {DATE_RANGES} from '@/components/date/dateRanges'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import DatemathHelp from '@/components/date/datemathHelp.vue' import DatemathHelp from '@/components/date/datemathHelp.vue'
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage' import {getFlatpickrLanguage} from '@/helpers/flatpickrLanguage'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
required: false, required: false,
}, },
open: {
type: Boolean,
default: false,
},
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue', 'close'])
const {t} = useI18n({useScope: 'global'}) const {t} = useI18n({useScope: 'global'})
@ -129,87 +111,58 @@ const flatPickerConfig = computed(() => ({
dateFormat: 'Y-m-d H:i', dateFormat: 'Y-m-d H:i',
enableTime: false, enableTime: false,
wrap: true, wrap: true,
mode: 'range',
locale: getFlatpickrLanguage(), locale: getFlatpickrLanguage(),
})) }))
const showHowItWorks = ref(false) const showHowItWorks = ref(false)
const flatpickrRange = ref('') const flatpickrDate = ref('')
const from = ref('') const date = ref<string|Date>('')
const to = ref('')
watch( watch(
() => props.modelValue, () => props.modelValue,
newValue => { newValue => {
from.value = newValue.dateFrom date.value = newValue
to.value = newValue.dateTo
// Only set the date back to flatpickr when it's an actual date. // 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. // Otherwise flatpickr runs in an endless loop and slows down the browser.
const dateFrom = parseDateOrString(from.value, false) const parsed = parseDateOrString(date.value, false)
const dateTo = parseDateOrString(to.value, false) if (parsed instanceof Date) {
if (dateFrom instanceof Date && dateTo instanceof Date) { flatpickrDate.value = date.value
flatpickrRange.value = `${from.value} to ${to.value}`
} }
}, },
) )
function emitChanged() { function emitChanged() {
const args = { emit('update:modelValue', date.value === '' ? null : date.value)
dateFrom: from.value === '' ? null : from.value,
dateTo: to.value === '' ? null : to.value,
}
emit('update:modelValue', args)
} }
watch( watch(
() => flatpickrRange.value, () => flatpickrDate.value,
(newVal: string | null) => { (newVal: string | null) => {
if (newVal === null) { if (newVal === null) {
return return
} }
const [fromDate, toDate] = newVal.split(' to ') date.value = newVal
if (typeof fromDate === 'undefined' || typeof toDate === 'undefined') {
return
}
from.value = fromDate
to.value = toDate
emitChanged() emitChanged()
}, },
) )
watch(() => from.value, emitChanged) watch(() => date.value, emitChanged)
watch(() => to.value, emitChanged)
function setDateRange(range: string[] | null) { function setDate(range: string | null) {
if (range === null) { if (range === null) {
from.value = '' date.value = ''
to.value = ''
return return
} }
from.value = range[0] date.value = range
to.value = range[1]
} }
const customRangeActive = computed<boolean>(() => { const customRangeActive = computed<boolean>(() => {
return !Object.values(DATE_RANGES).some(range => from.value === range[0] && to.value === range[1]) return !Object.values(DATE_RANGES).some(d => date.value === d)
})
const buttonText = computed<string>(() => {
if (from.value !== '' && to.value !== '') {
return t('input.datepickerRange.fromto', {
from: from.value,
to: to.value,
})
}
return t('task.show.select')
}) })
</script> </script>

View File

@ -23,7 +23,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {ref} from 'vue' import {ref, watch} from 'vue'
import {onClickOutside} from '@vueuse/core' import {onClickOutside} from '@vueuse/core'
const props = defineProps({ const props = defineProps({
@ -31,8 +31,19 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
open: {
type: Boolean,
default: false,
},
}) })
watch(
() => props.open,
nowOpen => {
open.value = nowOpen
},
)
const emit = defineEmits(['close']) const emit = defineEmits(['close'])
const open = ref(false) const open = ref(false)

View File

@ -1,18 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import FilterInput from '@/components/project/partials/FilterInput.vue' import FilterInput from '@/components/project/partials/FilterInput.vue'
import {ref} from "vue";
function initState(value: string) { function initState(value: string) {
return { return {
value, value,
} }
} }
const value = ref('')
</script> </script>
<template> <template>
<Story title="Filter Input"> <Story title="Filter Input">
<FilterInput v-model="value"/> <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> </Story>
</template> </template>

View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref, watch} from 'vue' import {computed, nextTick, ref, watch} from 'vue'
import {useAutoHeightTextarea} from '@/composables/useAutoHeightTextarea' import {useAutoHeightTextarea} from '@/composables/useAutoHeightTextarea'
import DatepickerWithValues from '@/components/date/datepickerWithValues.vue'
const { const {
modelValue, modelValue,
@ -59,16 +60,36 @@ const filterJoinOperators = [
')', ')',
] ]
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function unEscapeHtml(unsafe: string): string {
return unsafe
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot/g, '"')
.replace(/&#039;/g, "'")
}
const highlightedFilterQuery = computed(() => { const highlightedFilterQuery = computed(() => {
let highlighted = escapeHtml(filterQuery.value) let highlighted = escapeHtml(filterQuery.value)
dateFields dateFields
.map(o => escapeHtml(o)) .map(o => escapeHtml(o))
.forEach(o => { .forEach(o => {
const pattern = new RegExp(o + '\\s*(&lt;|&gt;|&lt;=|&gt;=|=|!=)\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig'); const pattern = new RegExp(o + '\\s*(&lt;|&gt;|&lt;=|&gt;=|=|!=)\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig');
highlighted = highlighted.replaceAll(pattern, (match, token, start, value, position) => { highlighted = highlighted.replaceAll(pattern, (match, token, start, value, position, last) => {
console.log({match, token, value, position}) console.log({position, last})
return `${o} ${token} <span class="filter-query__special_value">${value}</span><span class="filter-query__special_value_placeholder">${value}</span>` if (typeof value === 'undefined') {
// TODO: make special value a button with datepicker popup value = ''
}
return `${o} ${token} <button class="button is-primary filter-query__date_value" data-position="${position}">${value}</button><span class="filter-query__date_value_placeholder">${value}</span>`
}) })
}) })
filterOperators filterOperators
@ -87,13 +108,41 @@ const highlightedFilterQuery = computed(() => {
return highlighted return highlighted
}) })
function escapeHtml(unsafe: string): string { const currentOldDatepickerValue = ref('')
return unsafe const currentDatepickerValue = ref('')
.replace(/&/g, '&amp;') const currentDatepickerPos = ref()
.replace(/</g, '&lt;') const datePickerPopupOpen = ref(false)
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;') watch(
.replace(/'/g, '&#039;') () => 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
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)
} }
</script> </script>
@ -110,41 +159,48 @@ function escapeHtml(unsafe: string): string {
class="input" class="input"
ref="filterInput" ref="filterInput"
></textarea> ></textarea>
<div <div
class="filter-input-highlight" class="filter-input-highlight"
:style="{'height': height}" :style="{'height': height}"
v-html="highlightedFilterQuery" v-html="highlightedFilterQuery"
></div> ></div>
<DatepickerWithValues
v-model="currentDatepickerValue"
:open="datePickerPopupOpen"
@close="() => datePickerPopupOpen = false"
@update:model-value="updateDateInQuery"
/>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss"> <style lang="scss">
.filter-input-highlight span { .filter-input-highlight {
&.filter-query__field { span {
color: var(--code-literal); &.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 {
padding: .125rem .25rem;
display: inline-block;
}
} }
&.filter-query__operator { button.filter-query__date_value {
color: var(--code-keyword);
}
&.filter-query__join-operator {
color: var(--code-section);
}
&.filter-query__special_value_placeholder {
padding: .125rem .25rem; padding: .125rem .25rem;
display: inline-block;
}
&.filter-query__special_value {
background: var(--primary);
padding: .125rem .25rem;
color: white;
border-radius: $radius; border-radius: $radius;
position: absolute; position: absolute;
margin-top: calc((0.25em - 0.125rem) * -1); margin-top: calc((0.25em - 0.125rem) * -1);
height: 1.75rem;
} }
} }
</style> </style>