feat: add date math for filters #1342
|
@ -72,7 +72,7 @@ describe('Lists', () => {
|
|||
.should('contain', newListName)
|
||||
.should('not.contain', lists[0].title)
|
||||
cy.visit('/')
|
||||
cy.get('.card-content .tasks')
|
||||
cy.get('.card-content')
|
||||
.should('contain', newListName)
|
||||
.should('not.contain', lists[0].title)
|
||||
})
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
export const DATE_RANGES = {
|
||||
konrad marked this conversation as resolved
Outdated
|
||||
// Format:
|
||||
// Key is the title, as a translation string, the first entry of the value array
|
||||
// is the "from" date, the second one is the "to" date.
|
||||
'today': ['now/d', 'now/d+1d'],
|
||||
|
||||
'lastWeek': ['now/w-1w', 'now/w-2w'],
|
||||
'thisWeek': ['now/w', 'now/w+1w'],
|
||||
'restOfThisWeek': ['now', 'now/w+1w'],
|
||||
'nextWeek': ['now/w+1w', 'now/w+2w'],
|
||||
'next7Days': ['now', 'now+7d'],
|
||||
|
||||
'lastMonth': ['now/M-1M', 'now/M-2M'],
|
||||
'thisMonth': ['now/M', 'now/M+1M'],
|
||||
'restOfThisMonth': ['now', 'now/M+1M'],
|
||||
'nextMonth': ['now/M+1M', 'now/M+2M'],
|
||||
'next30Days': ['now', 'now+30d'],
|
||||
|
||||
'thisYear': ['now/y', 'now/y+1y'],
|
||||
'restOfThisYear': ['now', 'now/y+1y'],
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
<template>
|
||||
<card
|
||||
class="has-no-shadow how-it-works-modal"
|
||||
:title="$t('input.datemathHelp.title')">
|
||||
<p>
|
||||
{{ $t('input.datemathHelp.intro') }}
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
The naming of this feels wrong now that it's not part of the component anymore. The naming of this feels wrong now that it's not part of the component anymore.
If this help is specific to datepickerRange maybe we should also show that via the component name.
konrad
commented
Changed! Changed!
|
||||
</p>
|
||||
<p>
|
||||
<i18n-t keypath="input.datemathHelp.expression">
|
||||
<code>now</code>
|
||||
<code>||</code>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<p>
|
||||
<i18n-t keypath="input.datemathHelp.similar">
|
||||
<BaseButton
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
use BaseButton use BaseButton
konrad
commented
Done. Done.
|
||||
href="https://grafana.com/docs/grafana/latest/dashboards/time-range-controls/"
|
||||
target="_blank">
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Remove rel attribute here and below. Remove rel attribute here and below.
Show we also add `target="_blank"` as default for the BaseButton is a link case?
konrad
commented
Mh I think there will still be cases where we want to control this so right now I think we should not add one. > Show we also add target="_blank" as default for the BaseButton is a link case?
Mh I think there will still be cases where we want to control this so right now I think we should not add one.
|
||||
Grafana
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
use BaseButton use BaseButton
|
||||
href="https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math"
|
||||
target="_blank">
|
||||
Elasticsearch
|
||||
</BaseButton>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<p>{{ $t('misc.forExample') }}</p>
|
||||
<ul>
|
||||
<li><code>+1d</code>{{ $t('input.datemathHelp.add1Day') }}</li>
|
||||
<li><code>-1d</code>{{ $t('input.datemathHelp.minus1Day') }}</li>
|
||||
<li><code>/d</code>{{ $t('input.datemathHelp.roundDay') }}</li>
|
||||
</ul>
|
||||
<p>{{ $t('input.datemathHelp.supportedUnits') }}</p>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>s</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.seconds') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>m</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.minutes') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>h</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>H</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>d</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.days') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>w</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.weeks') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>M</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.months') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>y</code></td>
|
||||
<td>{{ $t('input.datemathHelp.units.years') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>{{ $t('input.datemathHelp.someExamples') }}</p>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>now</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.now') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now+24h</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.in24h') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now/d</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.today') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now/w</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.beginningOfThisWeek') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now/w+1w</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.endOfThisWeek') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>now+30d</code></td>
|
||||
<td>{{ $t('input.datemathHelp.examples.in30Days') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{{ exampleDate }}||+1M/d</code></td>
|
||||
<td>
|
||||
<i18n-t keypath="input.datemathHelp.examples.datePlusMonth">
|
||||
<code>{{ exampleDate }}</code>
|
||||
</i18n-t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {format} from 'date-fns'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const exampleDate = format(new Date(), 'yyyy-MM-dd')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.how-it-works-modal {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.base-button {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,254 @@
|
|||
<template>
|
||||
<div class="datepicker-with-range-container">
|
||||
<popup>
|
||||
<template #trigger="{toggle}">
|
||||
<slot name="trigger" :toggle="toggle" :buttonText="buttonText"></slot>
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Provide Provide `buttonText` as slot prop aswell
konrad
commented
Done. Done.
|
||||
</template>
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
The styles (=props) selected for the button are hightly specific and create an indirect dependency to its indented use case. The latter might change in the future and then we have to remember this dependency. This is why I think it makes sense to remove at least the specific use case of this button. Since we wouldn't use it then at all anymore (since you need to overwrite the default slot) it might make it simpler to only define the slot. The styles (=props) selected for the button are hightly specific and create an indirect dependency to its indented use case. The latter might change in the future and then we have to remember this dependency. This is why I think it makes sense to remove at least the specific use case of this button. Since we wouldn't use it then at all anymore (since you need to overwrite the default slot) it might make it simpler to only define the slot.
konrad
commented
Moved everything to a slot. Moved everything to a slot.
|
||||
<template #content="{isOpen}">
|
||||
<div class="datepicker-with-range" :class="{'is-open': isOpen}">
|
||||
<div class="selections">
|
||||
<BaseButton @click="setDateRange(null)" :class="{'is-active': customRangeActive}">
|
||||
{{ $t('misc.custom') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-for="(value, text) in DATE_RANGES"
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Use BaseButton Use BaseButton
konrad
commented
Done. Done.
|
||||
:key="text"
|
||||
@click="setDateRange(value)"
|
||||
:class="{'is-active': from === value[0] && to === value[1]}">
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Use BaseButton Use BaseButton
konrad
commented
Done. Done.
|
||||
{{ $t(`input.datepickerRange.ranges.${text}`) }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div class="flatpickr-container input-group">
|
||||
<label class="label">
|
||||
{{ $t('input.datepickerRange.from') }}
|
||||
<div class="field has-addons">
|
||||
<div class="control is-fullwidth">
|
||||
<input class="input" type="text" v-model="from"/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button icon="calendar" variant="secondary" data-toggle/>
|
||||
</div>
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Instead of calling Instead of calling `inputChanged` from here better watch the `from` and below the `to` value.
This prevents future mistakes
konrad
commented
Changed. I kept Changed. I kept `inputChanged` though because I need to watchers.
|
||||
</div>
|
||||
</label>
|
||||
<label class="label">
|
||||
{{ $t('input.datepickerRange.to') }}
|
||||
<div class="field has-addons">
|
||||
<div class="control is-fullwidth">
|
||||
<input class="input" type="text" v-model="to"/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button icon="calendar" variant="secondary" data-toggle/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<flat-pickr
|
||||
:config="flatPickerConfig"
|
||||
v-model="flatpickrRange"
|
||||
/>
|
||||
|
||||
<p>
|
||||
{{ $t('input.datemathHelp.canuse') }}
|
||||
<BaseButton class="has-text-primary" @click="showHowItWorks = true">
|
||||
{{ $t('input.datemathHelp.learnhow') }}
|
||||
</BaseButton>
|
||||
</p>
|
||||
|
||||
konrad marked this conversation as resolved
dpschen
commented
Use BaseButton Use BaseButton
konrad
commented
Done. Done.
|
||||
<modal
|
||||
@close="() => showHowItWorks = false"
|
||||
:enabled="showHowItWorks"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
>
|
||||
<DatemathHelp/>
|
||||
</modal>
|
||||
</div>
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
The whole explanation card should be its own component. The whole explanation card should be its own component.
konrad
commented
Done! Done!
|
||||
</div>
|
||||
</template>
|
||||
</popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
I think it's really cool, that we support this, but it would be cool, if we could break this down and explain it in our / simpler terms. I think it's really cool, that we support this, but it would be cool, if we could break this down and explain it in our / simpler terms.
konrad
commented
I think I'm already doing that but wanted to highlight for people who know the syntax from Elasticsearch or Grafana they can use it here as well. Was there anything in the explanation you noticed as not quite understandable? I think I'm already doing that but wanted to highlight for people who know the syntax from Elasticsearch or Grafana they can use it here as well.
Was there anything in the explanation you noticed as not quite understandable?
dpschen
commented
I didn't even check to be honest. I didn't even check to be honest.
Was just something I though of while reading this.
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
|
||||
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'
|
||||
|
||||
const store = useStore()
|
||||
const {t} = useI18n()
|
||||
|
||||
const emit = defineEmits(['dateChanged'])
|
||||
|
||||
// FIXME: This seems to always contain the default value - that breaks the picker
|
||||
const weekStart = computed<number>(() => store.state.auth.settings.weekStart ?? 0)
|
||||
const flatPickerConfig = computed(() => ({
|
||||
altFormat: t('date.altFormatLong'),
|
||||
altInput: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: false,
|
||||
wrap: true,
|
||||
mode: 'range',
|
||||
locale: {
|
||||
firstDayOf7Days: weekStart.value,
|
||||
},
|
||||
}))
|
||||
|
||||
const showHowItWorks = ref(false)
|
||||
|
||||
const flatpickrRange = ref('')
|
||||
|
||||
const from = ref('')
|
||||
const to = ref('')
|
||||
|
||||
function emitChanged() {
|
||||
emit('dateChanged', {
|
||||
dateFrom: from.value === '' ? null : from.value,
|
||||
dateTo: to.value === '' ? null : to.value,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => flatpickrRange.value,
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Why do we need to reset this every time? Why do we need to reset this every time?
konrad
commented
I don't think we have to. I've checked and it looks like this doesn't really break anything so I've removed it. Lets us get rid of I don't think we have to. I've checked and it looks like this doesn't really break anything so I've removed it. Lets us get rid of `inputChanged`.
|
||||
(newVal: string | null) => {
|
||||
dpschen
commented
Called here! Called here!
|
||||
if (newVal === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const [fromDate, toDate] = newVal.split(' to ')
|
||||
|
||||
if (typeof fromDate === 'undefined' || typeof toDate === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
from.value = fromDate
|
||||
to.value = toDate
|
||||
|
||||
emitChanged()
|
||||
},
|
||||
)
|
||||
watch(() => from.value, emitChanged)
|
||||
watch(() => to.value, emitChanged)
|
||||
|
||||
dpschen
commented
This is triggered three times! Once for each of these lines:
in the called function inputChanged of the watcher. This is triggered three times!
Once for each of these lines:
```js
from.value = fromDate
to.value = toDate
```
in the called function inputChanged of the watcher.
And then here again.
konrad
commented
mhh how can we fix this? Using a debounce to prevent it getting called three times if it changed all values at once? Or removing the watchers again? It looks like the tasks are only loaded once, not three times. Maybe that's already enough? mhh how can we fix this? Using a debounce to prevent it getting called three times if it changed all values at once? Or removing the watchers again?
It looks like the tasks are only loaded once, not three times. Maybe that's already enough?
|
||||
function setDateRange(range: string[] | null) {
|
||||
if (range === null) {
|
||||
from.value = ''
|
||||
to.value = ''
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
from.value = range[0]
|
||||
to.value = range[1]
|
||||
}
|
||||
|
||||
const customRangeActive = computed<boolean>(() => {
|
||||
return !Object.values(DATE_RANGES).some(range => from.value === range[0] && to.value === range[1])
|
||||
})
|
||||
|
||||
const buttonText = computed<string>(() => {
|
||||
if (from.value !== '' && to.value !== '') {
|
||||
return t('input.datepickerRange.fromto', {
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
picky: picky: `el` is a bit misleading here. We don't have elements
konrad
commented
right, I've changed it. right, I've changed it.
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
})
|
||||
}
|
||||
|
||||
return t('task.show.select')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.datepicker-with-range-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:deep(.popup) {
|
||||
z-index: 10;
|
||||
margin-top: 1rem;
|
||||
border-radius: $radius;
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Picky: I like to group imports from external packages and from internal stuff.
As you can see this is more or less random, so happy to discuss this. Picky:
I like to group imports from external packages and from internal stuff.
Usally I follow this order (but not strict):
- external packages beginning with vue libs
- external packages other libs and css
- internal instances like store, message, etc
- services and models
- component imports
- helpers
As you can see this is more or less random, so happy to discuss this.
Or we can simple keep it random :D
konrad
commented
Oh I think it absolutely makes sense to have something like that, even if not strictly enforced. Do you think we could put this in a linter? Oh I think it absolutely makes sense to have something like that, even if not strictly enforced. Do you think we could put this in a linter?
dpschen
commented
I think I encountered at least something like external stuff first in a linter already. I think I encountered at least something like external stuff first in a linter already.
|
||||
border: 1px solid var(--grey-200);
|
||||
background-color: var(--white);
|
||||
box-shadow: $shadow;
|
||||
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
After using it a while I prefer now to use the After using it a while I prefer now to use the `useStore` method to get the current store instance.
The reason is that it makes it easier to refactor in composables later because `useStore` will always give you the store.
konrad
commented
Makes sense! Changed it. Makes sense! Changed it.
|
||||
&.is-open {
|
||||
width: 500px;
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker-with-range {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
We could remove this prop if we would only expose the slot for the trigger. We could remove this prop if we would only expose the slot for the trigger.
konrad
commented
Done. Done.
|
||||
}
|
||||
|
||||
:deep(.flatpickr-calendar) {
|
||||
margin: 0 auto 8px;
|
||||
box-shadow: none;
|
||||
}
|
||||
dpschen
commented
Continuing from #1261 (comment) With default value you mean the value of store.state.auth.settings.weekStart? Continuing from https://kolaente.dev/vikunja/frontend/pulls/1261#issuecomment-22675
With default value you mean the value of store.state.auth.settings.weekStart?
konrad
commented
As said in #1261:
As said in #1261:
> It's not reflected in the flatpicker config (below). That always contains `0` as week start, no matter what is set in store.
|
||||
|
||||
.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 {
|
||||
dpschen
commented
I think this is a good usecase for a reactive. I think this is a good usecase for a reactive.
konrad
commented
What would be the advantage over a ref? What would be the advantage over a ref?
dpschen
commented
Both values can be changed at the same time Both values can be changed at the same time
=> watchers aren't triggered twice when you change stuff shortly after each other.
konrad
commented
How do I type it properly? If I only pass a string into a reactive it complains I should pass in an object instead. Edit: using How do I type it properly? If I only pass a string into a reactive it complains I should pass in an object instead.
Edit: using `String` (capital S) seems to work.
konrad
commented
Okay even with the typing solved it still complains when trying to set it: Because we do Okay even with the typing solved it still complains when trying to set it: Because we do `const from = ...` I won't be able to do `from = fromDate`. Any idea how to solve this?
|
||||
border: 1px solid var(--input-hover-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
dpschen
commented
Use v-model for values. Use v-model for values.
Might be useful: https://vueuse.org/core/usevmodel/
konrad
commented
What I don't like with using a v-model here would be the need to introduce another watcher in ShowTasks because we need to push the changes to the route. I think the current way with an event nicely circumvents this. Also I'd like to keep the current behaviour where it only does one route change if you change both values instead of two (but that could easily be kept with passing an object to v-model?). What I don't like with using a v-model here would be the need to introduce another watcher in ShowTasks because we need to push the changes to the route. I think the current way with an event nicely circumvents this.
Also I'd like to keep the current behaviour where it only does one route change if you change both values instead of two (but that could easily be kept with passing an object to v-model?).
dpschen
commented
correct > Also I'd like to keep the current behaviour where it only does one route change if you change both values instead of two (but that could easily be kept with passing an object to v-model?).
correct
|
||||
.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>
|
|
@ -6,7 +6,7 @@
|
|||
>
|
||||
{{ $t('filters.clear') }}
|
||||
</x-button>
|
||||
<popup>
|
||||
<popup :has-overflow="true">
|
||||
<template #trigger="{toggle}">
|
||||
<x-button
|
||||
@click.prevent.stop="toggle()"
|
||||
|
|
|
@ -67,49 +67,49 @@
|
|||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.dueDate') }}</label>
|
||||
<div class="control">
|
||||
<flat-pickr
|
||||
:config="flatPickerConfig"
|
||||
@on-close="setDueDateFilter"
|
||||
class="input"
|
||||
:placeholder="$t('filters.attributes.dueDateRange')"
|
||||
v-model="filters.dueDate"
|
||||
/>
|
||||
<datepicker-with-range @dateChanged="values => setDateFilter('due_date', values)">
|
||||
<template #trigger="{toggle, buttonText}">
|
||||
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
|
||||
{{ buttonText }}
|
||||
</x-button>
|
||||
</template>
|
||||
</datepicker-with-range>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.startDate') }}</label>
|
||||
<div class="control">
|
||||
<flat-pickr
|
||||
:config="flatPickerConfig"
|
||||
@on-close="setStartDateFilter"
|
||||
class="input"
|
||||
:placeholder="$t('filters.attributes.startDateRange')"
|
||||
v-model="filters.startDate"
|
||||
/>
|
||||
<datepicker-with-range @dateChanged="values => setDateFilter('start_date', values)">
|
||||
<template #trigger="{toggle, buttonText}">
|
||||
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
|
||||
{{ buttonText }}
|
||||
</x-button>
|
||||
</template>
|
||||
</datepicker-with-range>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.endDate') }}</label>
|
||||
<div class="control">
|
||||
<flat-pickr
|
||||
:config="flatPickerConfig"
|
||||
@on-close="setEndDateFilter"
|
||||
class="input"
|
||||
:placeholder="$t('filters.attributes.endDateRange')"
|
||||
v-model="filters.endDate"
|
||||
/>
|
||||
<datepicker-with-range @dateChanged="values => setDateFilter('end_date', values)">
|
||||
<template #trigger="{toggle, buttonText}">
|
||||
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
|
||||
{{ buttonText }}
|
||||
</x-button>
|
||||
</template>
|
||||
</datepicker-with-range>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.reminders') }}</label>
|
||||
<div class="control">
|
||||
<flat-pickr
|
||||
:config="flatPickerConfig"
|
||||
@on-close="setReminderFilter"
|
||||
class="input"
|
||||
:placeholder="$t('filters.attributes.reminderRange')"
|
||||
v-model="filters.reminders"
|
||||
/>
|
||||
<datepicker-with-range @dateChanged="values => setDateFilter('reminders', values)">
|
||||
<template #trigger="{toggle, buttonText}">
|
||||
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
|
||||
{{ buttonText }}
|
||||
</x-button>
|
||||
</template>
|
||||
</datepicker-with-range>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -175,15 +175,14 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import DatepickerWithRange from '@/components/date/datepickerWithRange'
|
||||
import Fancycheckbox from '../../input/fancycheckbox'
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
|
||||
import {includesById} from '@/helpers/utils'
|
||||
import {formatISO} from 'date-fns'
|
||||
import PrioritySelect from '@/components/tasks/partials/prioritySelect.vue'
|
||||
import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue'
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||
|
||||
import UserService from '@/services/user'
|
||||
import ListService from '@/services/list'
|
||||
|
@ -222,15 +221,15 @@ const DEFAULT_FILTERS = {
|
|||
namespace: '',
|
||||
}
|
||||
|
||||
export const ALPHABETICAL_SORT = 'title'
|
||||
export const ALPHABETICAL_SORT = 'title'
|
||||
|
||||
export default {
|
||||
name: 'filters',
|
||||
components: {
|
||||
DatepickerWithRange,
|
||||
EditLabels,
|
||||
PrioritySelect,
|
||||
Fancycheckbox,
|
||||
flatPickr,
|
||||
PercentDoneSelect,
|
||||
Multiselect,
|
||||
},
|
||||
|
@ -281,7 +280,7 @@ export default {
|
|||
return this.params?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
|
||||
},
|
||||
set(sortAlphabetically) {
|
||||
this.params.sort_by = sortAlphabetically
|
||||
this.params.sort_by = sortAlphabetically
|
||||
? [ALPHABETICAL_SORT]
|
||||
: getDefaultParams().sort_by
|
||||
|
||||
|
@ -291,19 +290,6 @@ export default {
|
|||
foundLabels() {
|
||||
return this.$store.getters['labels/filterLabelsByQuery'](this.labels, this.query)
|
||||
},
|
||||
flatPickerConfig() {
|
||||
return {
|
||||
altFormat: this.$t('date.altFormatLong'),
|
||||
altInput: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: true,
|
||||
time_24hr: true,
|
||||
mode: 'range',
|
||||
locale: {
|
||||
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
change() {
|
||||
|
@ -343,19 +329,12 @@ export default {
|
|||
}
|
||||
}
|
||||
},
|
||||
setDateFilter(filterName, variableName = null) {
|
||||
if (variableName === null) {
|
||||
variableName = filterName
|
||||
}
|
||||
|
||||
// Only filter if we have a start and end due date
|
||||
if (this.filters[variableName] !== '') {
|
||||
setDateFilter(filterName, {dateFrom, dateTo}) {
|
||||
dateFrom = parseDateOrString(dateFrom, null)
|
||||
dateTo = parseDateOrString(dateTo, null)
|
||||
|
||||
const parts = this.filters[variableName].split(' to ')
|
||||
|
||||
if (parts.length < 2) {
|
||||
return
|
||||
}
|
||||
// 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
|
||||
|
@ -363,23 +342,23 @@ export default {
|
|||
this.params.filter_by.forEach((f, i) => {
|
||||
if (f === filterName && this.params.filter_comparator[i] === 'greater_equals') {
|
||||
foundStart = true
|
||||
this.params.filter_value[i] = formatISO(new Date(parts[0]))
|
||||
this.params.filter_value[i] = dateFrom
|
||||
}
|
||||
if (f === filterName && this.params.filter_comparator[i] === 'less_equals') {
|
||||
foundEnd = true
|
||||
this.params.filter_value[i] = formatISO(new Date(parts[1]))
|
||||
this.params.filter_value[i] = dateTo
|
||||
}
|
||||
})
|
||||
|
||||
if (!foundStart) {
|
||||
this.params.filter_by.push(filterName)
|
||||
this.params.filter_comparator.push('greater_equals')
|
||||
this.params.filter_value.push(formatISO(new Date(parts[0])))
|
||||
this.params.filter_value.push(dateFrom)
|
||||
}
|
||||
if (!foundEnd) {
|
||||
this.params.filter_by.push(filterName)
|
||||
this.params.filter_comparator.push('less_equals')
|
||||
this.params.filter_value.push(formatISO(new Date(parts[1])))
|
||||
this.params.filter_value.push(dateTo)
|
||||
}
|
||||
this.change()
|
||||
return
|
||||
|
@ -513,24 +492,12 @@ export default {
|
|||
this.params.filter_concat = 'or'
|
||||
}
|
||||
},
|
||||
setDueDateFilter() {
|
||||
this.setDateFilter('due_date', 'dueDate')
|
||||
},
|
||||
setPriority() {
|
||||
this.setSingleValueFilter('priority', 'priority', 'usePriority')
|
||||
},
|
||||
setStartDateFilter() {
|
||||
this.setDateFilter('start_date', 'startDate')
|
||||
},
|
||||
setEndDateFilter() {
|
||||
this.setDateFilter('end_date', 'endDate')
|
||||
},
|
||||
setPercentDoneFilter() {
|
||||
this.setSingleValueFilter('percent_done', 'percentDone', 'usePercentDone')
|
||||
},
|
||||
setReminderFilter() {
|
||||
this.setDateFilter('reminders')
|
||||
},
|
||||
clear(kind) {
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
These abstractions are unnecessary and just increase complexity, call These abstractions are unnecessary and just increase complexity, call `setDateFilter` directly, like:
`@dateChanged="(values) => setDateFilter('due_date', values)"`
konrad
commented
Done. Done.
|
||||
this[`found${kind}`] = []
|
||||
},
|
||||
|
@ -609,7 +576,7 @@ export default {
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.single-value-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -618,4 +585,8 @@ export default {
|
|||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.datepicker-with-range-container .popup) {
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<slot name="trigger" :isOpen="open" :toggle="toggle"></slot>
|
||||
<div class="popup" :class="{'is-open': open}" ref="popup">
|
||||
<div class="popup" :class="{'is-open': open, 'has-overflow': props.hasOverflow && open}" ref="popup">
|
||||
<slot name="content" :isOpen="open"/>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -16,6 +16,13 @@ const toggle = () => {
|
|||
open.value = !open.value
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
hasOverflow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
function hidePopup(e) {
|
||||
if (!open.value) {
|
||||
return
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
>
|
||||
<div
|
||||
class="modal-container"
|
||||
:class="{'has-overflow': overflow}"
|
||||
@click.self.prevent.stop="$emit('close')"
|
||||
v-shortcut="'Escape'"
|
||||
>
|
||||
|
@ -199,7 +198,6 @@ export default {
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export function getNextWeekDate(): Date {
|
||||
return new Date((new Date()).getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
export function parseDateOrString(rawValue: string | undefined, fallback: any): string | Date {
|
||||
konrad marked this conversation as resolved
Outdated
konrad
commented
Maybe we should just move this to the emits section of the date range component? To avoid callees requiring to import it every time? Maybe we should just move this to the emits section of the date range component? To avoid callees requiring to import it every time?
dpschen
commented
Yes that makes sense! Yes that makes sense!
konrad
commented
Since we're now using route props where we need this function I think we'll have to leave it as is. Since we're now using route props where we need this function I think we'll have to leave it as is.
|
||||
if (typeof rawValue === 'undefined') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const d = new Date(rawValue)
|
||||
|
||||
// @ts-ignore if rawValue is an invalid date, isNan will return false.
|
||||
return !isNaN(d)
|
||||
? d
|
||||
: rawValue
|
||||
}
|
|
@ -485,7 +485,8 @@
|
|||
"showMenu": "Show the menu",
|
||||
"hideMenu": "Hide the menu",
|
||||
"forExample": "For example:",
|
||||
"welcomeBack": "Welcome Back!"
|
||||
"welcomeBack": "Welcome Back!",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"input": {
|
||||
"resetColor": "Reset Color",
|
||||
|
@ -524,6 +525,60 @@
|
|||
"multiselect": {
|
||||
"createPlaceholder": "Create new",
|
||||
"selectPlaceholder": "Click or press enter to select"
|
||||
},
|
||||
"datepickerRange": {
|
||||
"to": "To",
|
||||
"from": "From",
|
||||
"fromto": "{from} to {to}",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"datemathHelp": {
|
||||
"canuse": "You can use date math to filter for relative dates.",
|
||||
"learnhow": "Check out how it works",
|
||||
"title": "Date Math",
|
||||
"intro": "Date Math allows you to specifiy relative dates which are resolved on the fly by Vikunja when applying the filter.",
|
||||
"expression": "Each Date Math expression starts with an anchor date, which can either be {0}, or a date string ending with {1}. This anchor date can optionally be followed by one or more maths expressions.",
|
||||
"similar": "These expressions are similar to the ones provided by {0} and {1}.",
|
||||
"add1Day": "Add one day",
|
||||
"minus1Day": "Subtract one day",
|
||||
"roundDay": "Round down to the nearest day",
|
||||
"supportedUnits": "Supported time units are:",
|
||||
"someExamples": "Some examples of time expressions:",
|
||||
"units": {
|
||||
"seconds": "Seconds",
|
||||
"minutes": "Minutes",
|
||||
"hours": "Hours",
|
||||
"days": "Days",
|
||||
"weeks": "Weeks",
|
||||
"months": "Months",
|
||||
"years": "Years"
|
||||
},
|
||||
"examples": {
|
||||
"now": "Right now",
|
||||
"in24h": "In 24h",
|
||||
"today": "Today at 00:00",
|
||||
"beginningOfThisWeek": "The beginning of this week at 00:00",
|
||||
"endOfThisWeek": "The end of this week",
|
||||
"in30Days": "In 30 days",
|
||||
"datePlusMonth": "{0} plus one month at 00:00 of that day"
|
||||
}
|
||||
}
|
||||
},
|
||||
"task": {
|
||||
|
@ -541,12 +596,9 @@
|
|||
"titleCurrent": "Current Tasks",
|
||||
"titleDates": "Tasks from {from} until {to}",
|
||||
"noDates": "Show tasks without dates",
|
||||
"current": "Current tasks",
|
||||
"from": "Tasks from",
|
||||
"until": "until",
|
||||
"today": "Today",
|
||||
"nextWeek": "Next Week",
|
||||
"nextMonth": "Next Month",
|
||||
"overdue": "Show overdue tasks",
|
||||
"fromuntil": "Tasks from {from} until {until}",
|
||||
"select": "Select a date range",
|
||||
"noTasks": "Nothing to do — Have a nice day!"
|
||||
},
|
||||
"detail": {
|
||||
|
|
|
@ -3,6 +3,8 @@ import {saveLastVisited} from '@/helpers/saveLastVisited'
|
|||
import {store} from '@/store'
|
||||
|
||||
import {saveListView, getListView} from '@/helpers/saveListView'
|
||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||
import {getNextWeekDate} from '@/helpers/time/getNextWeekDate'
|
||||
|
||||
import HomeComponent from '../views/Home.vue'
|
||||
import NotFoundComponent from '../views/404.vue'
|
||||
|
@ -13,7 +15,7 @@ import RegisterComponent from '../views/user/Register.vue'
|
|||
import OpenIdAuth from '../views/user/OpenIdAuth.vue'
|
||||
import DataExportDownload from '../views/user/DataExportDownload.vue'
|
||||
// Tasks
|
||||
import ShowTasksInRangeComponent from '../views/tasks/ShowTasksInRange.vue'
|
||||
import UpcomingTasksComponent from '../views/tasks/ShowTasks.vue'
|
||||
import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth.vue'
|
||||
import ListNamespaces from '../views/namespaces/ListNamespaces.vue'
|
||||
import TaskDetailView from '../views/tasks/TaskDetailView.vue'
|
||||
|
@ -248,7 +250,13 @@ const router = createRouter({
|
|||
{
|
||||
path: '/tasks/by/upcoming',
|
||||
name: 'tasks.range',
|
||||
component: ShowTasksInRangeComponent,
|
||||
component: UpcomingTasksComponent,
|
||||
props: route => ({
|
||||
dateFrom: parseDateOrString(route.query.from as string, new Date()),
|
||||
dateTo: parseDateOrString(route.query.to as string, getNextWeekDate()),
|
||||
showNulls: route.query.showNulls === 'true',
|
||||
showOverdue: route.query.showOverdue === 'true',
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: '/lists/new/:namespaceId/',
|
||||
|
|
|
@ -2,6 +2,31 @@ import axios from 'axios'
|
|||
import {objectToSnakeCase} from '@/helpers/case'
|
||||
import {getToken} from '@/helpers/auth'
|
||||
|
||||
function convertObject(o) {
|
||||
if (o instanceof Date) {
|
||||
return o.toISOString()
|
||||
}
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
function prepareParams(params) {
|
||||
if (typeof params !== 'object') {
|
||||
return params
|
||||
}
|
||||
|
||||
for (const p in params) {
|
||||
if (Array.isArray(params[p])) {
|
||||
params[p] = params[p].map(convertObject)
|
||||
continue
|
||||
}
|
||||
|
||||
params[p] = convertObject(params[p])
|
||||
}
|
||||
|
||||
return objectToSnakeCase(params)
|
||||
}
|
||||
|
||||
export default class AbstractService {
|
||||
|
||||
/////////////////////////////
|
||||
|
@ -292,7 +317,7 @@ export default class AbstractService {
|
|||
const finalUrl = this.getReplacedRoute(url, model)
|
||||
|
||||
try {
|
||||
const response = await this.http.get(finalUrl, {params})
|
||||
const response = await this.http.get(finalUrl, {params: prepareParams(params)})
|
||||
const result = this.modelGetFactory(response.data)
|
||||
result.maxRight = Number(response.headers['x-max-right'])
|
||||
return result
|
||||
|
@ -331,7 +356,7 @@ export default class AbstractService {
|
|||
const finalUrl = this.getReplacedRoute(this.paths.getAll, model)
|
||||
|
||||
try {
|
||||
const response = await this.http.get(finalUrl, {params: params})
|
||||
const response = await this.http.get(finalUrl, {params: prepareParams(params)})
|
||||
this.resultCount = Number(response.headers['x-pagination-result-count'])
|
||||
this.totalPages = Number(response.headers['x-pagination-total-pages'])
|
||||
|
||||
|
|
|
@ -70,7 +70,7 @@ h6 {
|
|||
}
|
||||
|
||||
.has-overflow {
|
||||
overflow: visible;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.has-horizontal-overflow {
|
||||
|
|
|
@ -50,7 +50,11 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ShowTasks class="mt-4" :show-all="true" v-if="hasLists" :key="showTasksKey"/>
|
||||
<ShowTasks
|
||||
v-if="hasLists"
|
||||
class="mt-4"
|
||||
:key="showTasksKey"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -83,13 +87,14 @@ const userInfo = computed(() => store.state.auth.info)
|
|||
const hasTasks = computed(() => store.state.hasTasks)
|
||||
const defaultListId = computed(() => store.state.auth.defaultListId)
|
||||
const defaultNamespaceId = computed(() => store.state.namespaces.namespaces?.[0]?.id || 0)
|
||||
const hasLists = computed (() => store.state.namespaces.namespaces?.[0]?.lists.length > 0)
|
||||
const hasLists = computed(() => store.state.namespaces.namespaces?.[0]?.lists.length > 0)
|
||||
const loading = computed(() => store.state.loading && store.state.loadingModule === 'tasks')
|
||||
const deletionScheduledAt = computed(() => parseDateOrNull(store.state.auth.info?.deletionScheduledAt))
|
||||
|
||||
// This is to reload the tasks list after adding a new task through the global task add.
|
||||
// FIXME: Should use vuex (somehow?)
|
||||
const showTasksKey = ref(0)
|
||||
|
||||
function updateTaskList() {
|
||||
showTasksKey.value++
|
||||
}
|
||||
|
|
|
@ -1,287 +1,236 @@
|
|||
<template>
|
||||
<div class="is-max-width-desktop show-tasks">
|
||||
<fancycheckbox
|
||||
@change="setDate"
|
||||
class="is-pulled-right"
|
||||
v-if="!showAll"
|
||||
v-model="showNulls"
|
||||
>
|
||||
{{ $t('task.show.noDates') }}
|
||||
</fancycheckbox>
|
||||
<h3 v-if="showAll && tasks.length > 0">
|
||||
{{ $t('task.show.current') }}
|
||||
<div class="is-max-width-desktop has-text-left ">
|
||||
<h3 class="mb-2 title">
|
||||
{{ pageTitle }}
|
||||
</h3>
|
||||
<h3 v-else-if="!showAll" class="mb-2">
|
||||
{{ $t('task.show.from') }}
|
||||
<flat-pickr
|
||||
:class="{ 'disabled': loading}"
|
||||
:config="flatPickerConfig"
|
||||
:disabled="loading"
|
||||
@on-close="setDate"
|
||||
class="input"
|
||||
v-model="cStartDate"
|
||||
/>
|
||||
{{ $t('task.show.until') }}
|
||||
<flat-pickr
|
||||
:class="{ 'disabled': loading}"
|
||||
:config="flatPickerConfig"
|
||||
:disabled="loading"
|
||||
@on-close="setDate"
|
||||
class="input"
|
||||
v-model="cEndDate"
|
||||
/>
|
||||
</h3>
|
||||
<div v-if="!showAll" class="mb-4">
|
||||
<x-button variant="secondary" @click="showTodaysTasks()" class="mr-2">{{ $t('task.show.today') }}</x-button>
|
||||
<x-button variant="secondary" @click="setDatesToNextWeek()" class="mr-2">{{ $t('task.show.nextWeek') }}</x-button>
|
||||
<x-button variant="secondary" @click="setDatesToNextMonth()">{{ $t('task.show.nextMonth') }}</x-button>
|
||||
</div>
|
||||
<p v-if="!showAll" class="show-tasks-options">
|
||||
<datepicker-with-range @dateChanged="setDate">
|
||||
<template #trigger="{toggle}">
|
||||
<x-button @click.prevent.stop="toggle()" variant="primary" :shadow="false" class="mb-2">
|
||||
{{ $t('task.show.select') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</datepicker-with-range>
|
||||
<fancycheckbox @change="setShowNulls" class="mr-2">
|
||||
{{ $t('task.show.noDates') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox @change="setShowOverdue">
|
||||
{{ $t('task.show.overdue') }}
|
||||
</fancycheckbox>
|
||||
</p>
|
||||
<template v-if="!loading && (!tasks || tasks.length === 0) && showNothingToDo">
|
||||
<h3 class="nothing">{{ $t('task.show.noTasks') }}</h3>
|
||||
<LlamaCool class="llama-cool" />
|
||||
<h3 class="has-text-centered mt-6">{{ $t('task.show.noTasks') }}</h3>
|
||||
<LlamaCool class="llama-cool"/>
|
||||
</template>
|
||||
<div :class="{ 'is-loading': loading}" class="spinner"></div>
|
||||
|
||||
<card :padding="false" class="has-overflow" :has-content="false" v-if="tasks && tasks.length > 0">
|
||||
<div class="tasks">
|
||||
<card
|
||||
v-if="hasTasks"
|
||||
:padding="false"
|
||||
class="has-overflow"
|
||||
:has-content="false"
|
||||
:loading="loading"
|
||||
>
|
||||
<div class="p-2">
|
||||
<single-task-in-list
|
||||
v-for="t in tasksSorted"
|
||||
:key="t.id"
|
||||
class="task"
|
||||
v-for="t in tasks"
|
||||
:show-list="true"
|
||||
:the-task="t"
|
||||
@taskUpdated="updateTasks"/>
|
||||
</div>
|
||||
</card>
|
||||
<div v-else :class="{ 'is-loading': loading}" class="spinner"></div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import SingleTaskInList from '../../components/tasks/partials/singleTaskInList'
|
||||
import {mapState} from 'vuex'
|
||||
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import Fancycheckbox from '../../components/input/fancycheckbox'
|
||||
import {LOADING, LOADING_MODULE} from '../../store/mutation-types'
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watchEffect} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import TaskModel from '@/models/task'
|
||||
import {formatDate} from '@/helpers/time/formatDate'
|
||||
import {setTitle} from '@/helpers/setTitle'
|
||||
|
||||
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
|
||||
import SingleTaskInList from '@/components/tasks/partials/singleTaskInList.vue'
|
||||
import DatepickerWithRange from '@/components/date/datepickerWithRange.vue'
|
||||
import {DATE_RANGES} from '@/components/date/dateRanges'
|
||||
import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
|
||||
import LlamaCool from '@/assets/llama-cool.svg?component'
|
||||
|
||||
export default {
|
||||
name: 'ShowTasks',
|
||||
components: {
|
||||
Fancycheckbox,
|
||||
SingleTaskInList,
|
||||
flatPickr,
|
||||
LlamaCool,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tasks: [],
|
||||
showNulls: true,
|
||||
showOverdue: false,
|
||||
const store = useStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const {t} = useI18n()
|
||||
|
||||
cStartDate: null,
|
||||
cEndDate: null,
|
||||
const tasks = ref<TaskModel[]>([])
|
||||
const showNothingToDo = ref<boolean>(false)
|
||||
|
||||
showNothingToDo: false,
|
||||
}
|
||||
},
|
||||
props: {
|
||||
startDate: Date,
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Readd route props Readd route props
konrad
commented
Changed it to route props. However, we still have a dependency on the router: every time when seleting a date in ShowTasks it will push the change to the route. I don't know how to change that without massively overengeneering everything so I'd say we leave it at that. Changed it to route props.
However, we still have a dependency on the router: every time when seleting a date in ShowTasks it will push the change to the route. I don't know how to change that without massively overengeneering everything so I'd say we leave it at that.
dpschen
commented
I think it's fine for now. I thought a while about this but also don't have a better soltion for this at the moment (except the v-model version) I think it's fine for now. I thought a while about this but also don't have a better soltion for this at the moment (except the v-model version)
|
||||
endDate: Date,
|
||||
showAll: Boolean,
|
||||
},
|
||||
created() {
|
||||
this.cStartDate = this.startDate
|
||||
this.cEndDate = this.endDate
|
||||
this.loadPendingTasks()
|
||||
},
|
||||
mounted() {
|
||||
setTimeout(() => this.showNothingToDo = true, 100)
|
||||
},
|
||||
watch: {
|
||||
'$route': {
|
||||
handler: 'loadPendingTasks',
|
||||
deep: true,
|
||||
},
|
||||
startDate(newVal) {
|
||||
this.cStartDate = newVal
|
||||
},
|
||||
endDate(newVal) {
|
||||
this.cEndDate = newVal
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
flatPickerConfig() {
|
||||
return {
|
||||
altFormat: this.$t('date.altFormatLong'),
|
||||
altInput: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: true,
|
||||
time_24hr: true,
|
||||
locale: {
|
||||
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
|
||||
},
|
||||
}
|
||||
},
|
||||
...mapState({
|
||||
userAuthenticated: state => state.auth.authenticated,
|
||||
loading: state => state[LOADING] && state[LOADING_MODULE] === 'tasks',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
setDate() {
|
||||
this.$router.push({
|
||||
name: this.$route.name,
|
||||
query: {
|
||||
from: +new Date(this.cStartDate),
|
||||
to: +new Date(this.cEndDate),
|
||||
showOverdue: this.showOverdue,
|
||||
showNulls: this.showNulls,
|
||||
},
|
||||
})
|
||||
},
|
||||
async loadPendingTasks() {
|
||||
// Since this route is authentication only, users would get an error message if they access the page unauthenticated.
|
||||
// Since this component is mounted as the home page before unauthenticated users get redirected
|
||||
// to the login page, they will almost always see the error message.
|
||||
if (!this.userAuthenticated) {
|
||||
return
|
||||
}
|
||||
setTimeout(() => showNothingToDo.value = true, 100)
|
||||
|
||||
// Make sure all dates are date objects
|
||||
if (typeof this.$route.query.from !== 'undefined' && typeof this.$route.query.to !== 'undefined') {
|
||||
this.cStartDate = new Date(Number(this.$route.query.from))
|
||||
this.cEndDate = new Date(Number(this.$route.query.to))
|
||||
} else {
|
||||
this.cStartDate = new Date(this.cStartDate)
|
||||
this.cEndDate = new Date(this.cEndDate)
|
||||
}
|
||||
this.showOverdue = this.$route.query.showOverdue
|
||||
this.showNulls = this.$route.query.showNulls
|
||||
// Linting disabled because we explicitely enabled destructuring in vite's config, this will work.
|
||||
// eslint-disable-next-line vue/no-setup-props-destructure
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Wouldn't it be better to make showAll a computed that gets autoset when dateFrom and dateTo doesn't contain a value? Wouldn't it be better to make showAll a computed that gets autoset when dateFrom and dateTo doesn't contain a value?
konrad
commented
Excellent idea. Changed it! Excellent idea. Changed it!
|
||||
const {
|
||||
dateFrom,
|
||||
dateTo,
|
||||
showNulls = false,
|
||||
showOverdue = false,
|
||||
} = defineProps<{
|
||||
dateFrom?: Date | string,
|
||||
dateTo?: Date | string,
|
||||
showNulls?: Boolean,
|
||||
showOverdue?: Boolean,
|
||||
}>()
|
||||
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
get value from props to remove dependency on route. Removing dependency from router makes the components easier reusable nested inside another view (which is what we do). get value from props to remove dependency on route. Removing dependency from router makes the components easier reusable nested inside another view (which is what we do).
|
||||
if (this.showAll) {
|
||||
this.setTitle(this.$t('task.show.titleCurrent'))
|
||||
} else {
|
||||
this.setTitle(this.$t('task.show.titleDates', {
|
||||
from: this.cStartDate.toLocaleDateString(),
|
||||
to: this.cEndDate.toLocaleDateString(),
|
||||
}))
|
||||
}
|
||||
const showAll = computed(() => typeof dateFrom === 'undefined' || typeof dateTo === 'undefined')
|
||||
|
||||
const params = {
|
||||
sort_by: ['due_date', 'id'],
|
||||
order_by: ['desc', 'desc'],
|
||||
filter_by: ['done'],
|
||||
filter_value: [false],
|
||||
filter_comparator: ['equals'],
|
||||
filter_concat: 'and',
|
||||
filter_include_nulls: this.showNulls,
|
||||
}
|
||||
if (!this.showAll) {
|
||||
if (this.showNulls) {
|
||||
params.filter_by.push('start_date')
|
||||
params.filter_value.push(this.cStartDate)
|
||||
params.filter_comparator.push('greater')
|
||||
const pageTitle = computed(() => {
|
||||
// We need to define "key" because it is the first parameter in the array and we need the second
|
||||
const predefinedRange = Object.entries(DATE_RANGES)
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
.find(([key, value]) => dateFrom === value[0] && dateTo === value[1])
|
||||
?.[0]
|
||||
if (typeof predefinedRange !== 'undefined') {
|
||||
return t(`input.datepickerRange.ranges.${predefinedRange}`)
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Unsure: I think eslint doesn't complain here if you use underscore ( Unsure: I think eslint doesn't complain here if you use underscore (`_`) to spread the unused variable.
dpschen
commented
Add Add `?.[0]` so we return the key, which is what we actually want to use and adjust variable name.
konrad
commented
Nope, does not seem to have an effect. > Unsure: I think eslint doesn't complain here if you use underscore (_) to spread the unused variable.
Nope, does not seem to have an effect.
konrad
commented
Done. > Add ?.[0] so we return the key, which is what we actually want to use and adjust variable name.
Done.
dpschen
commented
Interesting! > Nope, does not seem to have an effect.
Interesting!
|
||||
}
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Remove title var and return directly:
Remove title var and return directly:
```js
if (typeof predefinedRange !== 'undefined') {
return t(`input.datepickerRange.ranges.${predefinedRangeKey}`)
} else {
return showAll
? t('task.show.titleCurrent')
: t('task.show.fromuntil', {
from: formatDate(dateFrom, 'PPP'),
until: formatDate(dateTo, 'PPP'),
})
}
konrad
commented
Done. Done.
|
||||
|
||||
params.filter_by.push('end_date')
|
||||
params.filter_value.push(this.cEndDate)
|
||||
params.filter_comparator.push('less')
|
||||
}
|
||||
return showAll.value
|
||||
? t('task.show.titleCurrent')
|
||||
: t('task.show.fromuntil', {
|
||||
from: formatDate(dateFrom, 'PPP'),
|
||||
until: formatDate(dateTo, 'PPP'),
|
||||
})
|
||||
})
|
||||
const tasksSorted = computed(() => {
|
||||
// Sort all tasks to put those with a due date before the ones without a due date, the
|
||||
// soonest before the later ones.
|
||||
// We can't use the api sorting here because that sorts tasks with a due date after
|
||||
// ones without a due date.
|
||||
|
||||
params.filter_by.push('due_date')
|
||||
params.filter_value.push(this.cEndDate)
|
||||
params.filter_comparator.push('less')
|
||||
const tasksWithDueDate = [...tasks.value]
|
||||
.filter(t => t.dueDate !== null)
|
||||
.sort((a, b) => {
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
This is a sideeffect inside a computed. Remove this.
This is a sideeffect inside a computed. Remove this.
Better watch the computed and react to that:
```js
watchEffect(() => setTitle(pageTitle.value))
```
konrad
commented
Done. Done.
|
||||
const sortByDueDate = a.dueDate - b.dueDate
|
||||
return sortByDueDate === 0
|
||||
? b.id - a.id
|
||||
: sortByDueDate
|
||||
})
|
||||
const tasksWithoutDueDate = [...tasks.value]
|
||||
.filter(t => t.dueDate === null)
|
||||
|
||||
if (!this.showOverdue) {
|
||||
params.filter_by.push('due_date')
|
||||
params.filter_value.push(this.cStartDate)
|
||||
params.filter_comparator.push('greater')
|
||||
}
|
||||
}
|
||||
return [
|
||||
...tasksWithDueDate,
|
||||
...tasksWithoutDueDate,
|
||||
]
|
||||
})
|
||||
const hasTasks = computed(() => tasks.value && tasks.value.length > 0)
|
||||
const userAuthenticated = computed(() => store.state.auth.authenticated)
|
||||
const loading = computed(() => store.state[LOADING] && store.state[LOADING_MODULE] === 'tasks')
|
||||
|
||||
const tasks = await this.$store.dispatch('tasks/loadTasks', params)
|
||||
if (!tasks) {
|
||||
// When no tasks where returned, we won't be able to sort them.
|
||||
return
|
||||
}
|
||||
|
||||
// FIXME: sort tasks in computed
|
||||
// Sort all tasks to put those with a due date before the ones without a due date, the
|
||||
// soonest before the later ones.
|
||||
// We can't use the api sorting here because that sorts tasks with a due date after
|
||||
// ones without a due date.
|
||||
this.tasks = tasks.sort((a, b) => {
|
||||
const sortByDueDate = b.dueDate - a.dueDate
|
||||
return sortByDueDate === 0
|
||||
? b.id - a.id
|
||||
: sortByDueDate
|
||||
})
|
||||
},
|
||||
|
||||
// FIXME: this modification should happen in the store
|
||||
updateTasks(updatedTask) {
|
||||
for (const t in this.tasks) {
|
||||
if (this.tasks[t].id === updatedTask.id) {
|
||||
this.tasks[t] = updatedTask
|
||||
// Move the task to the end of the done tasks if it is now done
|
||||
if (updatedTask.done) {
|
||||
this.tasks.splice(t, 1)
|
||||
this.tasks.push(updatedTask)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setDatesToNextWeek() {
|
||||
const now = new Date()
|
||||
this.cStartDate = now
|
||||
this.cEndDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||
this.showOverdue = false
|
||||
this.setDate()
|
||||
},
|
||||
|
||||
setDatesToNextMonth() {
|
||||
const now = new Date()
|
||||
this.cStartDate = now
|
||||
this.cEndDate = new Date((new Date()).setMonth(now.getMonth() + 1))
|
||||
this.showOverdue = false
|
||||
this.setDate()
|
||||
},
|
||||
|
||||
showTodaysTasks() {
|
||||
const now = new Date()
|
||||
this.cStartDate = now
|
||||
this.cEndDate = new Date((new Date()).setDate(now.getDate() + 1))
|
||||
this.showOverdue = true
|
||||
this.setDate()
|
||||
},
|
||||
},
|
||||
interface dateStrings {
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
}
|
||||
|
||||
function setDate(dates: dateStrings) {
|
||||
router.push({
|
||||
name: route.name as string,
|
||||
query: {
|
||||
from: dates.dateFrom ?? dateFrom,
|
||||
to: dates.dateTo ?? dateTo,
|
||||
showOverdue: showOverdue ? 'true' : 'false',
|
||||
showNulls: showNulls ? 'true' : 'false',
|
||||
},
|
||||
})
|
||||
}
|
||||
dpschen marked this conversation as resolved
Outdated
dpschen
commented
Mhh that is a bit annoying that we have to convert to strings here :/ We might need to abstract this in the future, so let's keep that in the back of our head =) Mhh that is a bit annoying that we have to convert to strings here :/
But I also don't have a better idea for now.
Right now this might be still fine, but I remember similar usecases where the conversion from state object to the url representation was quite complex.
We might need to abstract this in the future, so let's keep that in the back of our head =)
|
||||
|
||||
function setShowOverdue(show: boolean) {
|
||||
router.push({
|
||||
name: route.name as string,
|
||||
query: {
|
||||
...route.query,
|
||||
showOverdue: show ? 'true' : 'false',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function setShowNulls(show: boolean) {
|
||||
router.push({
|
||||
name: route.name as string,
|
||||
query: {
|
||||
...route.query,
|
||||
showNulls: show ? 'true' : 'false',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function loadPendingTasks(from: string, to: string) {
|
||||
// FIXME: HACK! This should never happen.
|
||||
// Since this route is authentication only, users would get an error message if they access the page unauthenticated.
|
||||
// Since this component is mounted as the home page before unauthenticated users get redirected
|
||||
// to the login page, they will almost always see the error message.
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
This should be marked as a Hack because it's something that shouldn't happen. This should be marked as a Hack because it's something that shouldn't happen.
When I tried to change the auth these days I tried to fix this, but got tangled.
We should try to solve this in the future because else we will just add more and more hacks =)
konrad
commented
Done. Done.
|
||||
if (!userAuthenticated.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const params = {
|
||||
sortBy: ['due_date', 'id'],
|
||||
orderBy: ['desc', 'desc'],
|
||||
filterBy: ['done'],
|
||||
filterValue: ['false'],
|
||||
filterComparator: ['equals'],
|
||||
filterConcat: 'and',
|
||||
filterIncludeNulls: showNulls,
|
||||
}
|
||||
|
||||
if (!showAll.value) {
|
||||
params.filterBy.push('due_date')
|
||||
params.filterValue.push(to)
|
||||
params.filterComparator.push('less')
|
||||
|
||||
// NOTE: Ideally we could also show tasks with a start or end date in the specified range, but the api
|
||||
// is not capable (yet) of combining multiple filters with 'and' and 'or'.
|
||||
|
||||
if (!showOverdue) {
|
||||
params.filterBy.push('due_date')
|
||||
params.filterValue.push(from)
|
||||
params.filterComparator.push('greater')
|
||||
}
|
||||
}
|
||||
|
||||
tasks.value = await store.dispatch('tasks/loadTasks', params)
|
||||
}
|
||||
|
||||
// FIXME: this modification should happen in the store
|
||||
function updateTasks(updatedTask: TaskModel) {
|
||||
for (const t in tasks.value) {
|
||||
if (tasks.value[t].id === updatedTask.id) {
|
||||
tasks.value[t] = updatedTask
|
||||
// Move the task to the end of the done tasks if it is now done
|
||||
konrad marked this conversation as resolved
Outdated
dpschen
commented
Instead of creating the params already in snake_case: Instead of creating the params already in snake_case:
Wait until the last moment (ideally abstracted away from a apiClient) until you convert params from camelCase to snake_case, needed by the api. This way we use consequently use camelCase in most areas of the frontend – which is what you usually do in JS.
konrad
commented
I agree. However, this whole filter thing is a mess right now, not only here. I'd like to fix this everywhere at once at some point, simplifying the filter handling in the process as well. That being said, I've changed there here for now. I agree. However, this whole filter thing is a mess right now, not only here. I'd like to fix this everywhere at once at some point, simplifying the filter handling in the process as well.
That being said, I've changed there here for now.
|
||||
if (updatedTask.done) {
|
||||
tasks.value.splice(t, 1)
|
||||
tasks.value.push(updatedTask)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => loadPendingTasks(dateFrom as string, dateTo as string))
|
||||
watchEffect(() => setTitle(pageTitle.value))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
h3 {
|
||||
text-align: left;
|
||||
|
||||
&.nothing {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
:deep(.input) {
|
||||
width: 190px;
|
||||
vertical-align: middle;
|
||||
margin: .5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tasks {
|
||||
padding: .5rem;
|
||||
.show-tasks-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.llama-cool {
|
||||
margin-top: 2rem;
|
||||
margin: 3rem auto 0;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
|
@ -1,20 +0,0 @@
|
|||
<template>
|
||||
<div class="content has-text-centered">
|
||||
<ShowTasks
|
||||
:end-date="endDate"
|
||||
:start-date="startDate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import ShowTasks from './ShowTasks.vue'
|
||||
|
||||
function getNextWeekDate() {
|
||||
return new Date((new Date()).getTime() + 7 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
const startDate = ref(new Date())
|
||||
const endDate = ref(getNextWeekDate())
|
||||
</script>
|
|
@ -1,7 +1,8 @@
|
|||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite'
|
||||
import {defineConfig} from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import legacyFn from '@vitejs/plugin-legacy'
|
||||
|
||||
const {VitePWA} = require('vite-plugin-pwa')
|
||||
const path = require('path')
|
||||
const {visualizer} = require('rollup-plugin-visualizer')
|
||||
|
@ -49,6 +50,7 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
},
|
||||
reactivityTransform: true,
|
||||
}),
|
||||
legacy,
|
||||
svgLoader({
|
||||
|
|
Use UPPER_SNAKE_CASE for constants
Done.