Better reminders (#308)
Fix setting the new reminder component to null after adding a new date Add "close on change" event which only fires if the component closed and the value actually changed Hide the "today" option after 21:00 Add "confirm" button to close the component Use disabled in reminders Add a disabled property to the datepicker Cleanup workarounds for flatpickr Use the new datepicker for end dates Use the new datepicker for start date Use the new datepicker for due dates Mobile styling Format Sync flatpickr when clicking on choose a date Make sure to only hide the popup when not clicked something inside of it Make flatpickr dates work Use datepicker component for reminders Merge branch 'master' into feature/better-reminders Fix bottom padding of inline flatpickr Set time Add method to calculate the neares time Move time helpers in separate folder Remove separate flatpickr date Cleanup Set the flatpickr date when setting changing the date Better formatting of the chosen date Bubble Set date when choosing one Fix test Show correct weekday in preview Change hover background color Make label to show if selected date is null configurable Use a different icon for weekend Ignore test files when linting Add tests to dron Move day interval calculation to separate file and test it Add next date calculation Add basic date picker component Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/308 Co-Authored-By: konrad <konrad@kola-entertainments.de> Co-Committed-By: konrad <konrad@kola-entertainments.de>
This commit is contained in:
parent
ba142c92ef
commit
fb3cf94cba
15 changed files with 2253 additions and 152 deletions
|
@ -19,7 +19,12 @@ steps:
|
||||||
- yarn --frozen-lockfile --network-timeout 100000
|
- yarn --frozen-lockfile --network-timeout 100000
|
||||||
- yarn run lint
|
- yarn run lint
|
||||||
- yarn run build
|
- yarn run build
|
||||||
|
- name: test
|
||||||
|
image: node:13
|
||||||
|
pull: true
|
||||||
|
group: build-static
|
||||||
|
commands:
|
||||||
|
- yarn test
|
||||||
---
|
---
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
name: release-latest
|
name: release-latest
|
||||||
|
|
|
@ -5,7 +5,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "vue-cli-service serve",
|
"serve": "vue-cli-service serve",
|
||||||
"build": "vue-cli-service build --modern",
|
"build": "vue-cli-service build --modern",
|
||||||
"lint": "vue-cli-service lint"
|
"lint": "vue-cli-service lint --ignore-pattern '*.test.*'",
|
||||||
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bulma": "0.9.1",
|
"bulma": "0.9.1",
|
||||||
|
@ -40,6 +41,7 @@
|
||||||
"babel-eslint": "10.1.0",
|
"babel-eslint": "10.1.0",
|
||||||
"eslint": "7.14.0",
|
"eslint": "7.14.0",
|
||||||
"eslint-plugin-vue": "7.1.0",
|
"eslint-plugin-vue": "7.1.0",
|
||||||
|
"jest": "^26.6.3",
|
||||||
"node-sass": "5.0.0",
|
"node-sass": "5.0.0",
|
||||||
"sass-loader": "10.1.0",
|
"sass-loader": "10.1.0",
|
||||||
"vue-flatpickr-component": "8.1.6",
|
"vue-flatpickr-component": "8.1.6",
|
||||||
|
|
246
src/components/input/datepicker.vue
Normal file
246
src/components/input/datepicker.vue
Normal file
|
@ -0,0 +1,246 @@
|
||||||
|
<template>
|
||||||
|
<div class="datepicker" :class="{'disabled': disabled}">
|
||||||
|
<a @click.stop="toggleDatePopup" class="show">
|
||||||
|
<template v-if="date === null">
|
||||||
|
{{ chooseDateLabel }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ formatDateShort(date) }}
|
||||||
|
</template>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="show" class="datepicker-popup" ref="datepickerPopup">
|
||||||
|
|
||||||
|
<a @click.stop="() => setDate('today')" v-if="(new Date()).getHours() < 21">
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['far', 'calendar-alt']"/>
|
||||||
|
</span>
|
||||||
|
<span class="text">
|
||||||
|
<span>
|
||||||
|
Today
|
||||||
|
</span>
|
||||||
|
<span class="weekday">
|
||||||
|
{{ getWeekdayFromStringInterval('today') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a @click.stop="() => setDate('tomorrow')">
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['far', 'sun']"/>
|
||||||
|
</span>
|
||||||
|
<span class="text">
|
||||||
|
<span>
|
||||||
|
Tomorrow
|
||||||
|
</span>
|
||||||
|
<span class="weekday">
|
||||||
|
{{ getWeekdayFromStringInterval('tomorrow') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a @click.stop="() => setDate('nextMonday')">
|
||||||
|
<span class="icon">
|
||||||
|
<icon icon="coffee"/>
|
||||||
|
</span>
|
||||||
|
<span class="text">
|
||||||
|
<span>
|
||||||
|
Next Monday
|
||||||
|
</span>
|
||||||
|
<span class="weekday">
|
||||||
|
{{ getWeekdayFromStringInterval('nextMonday') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a @click.stop="() => setDate('thisWeekend')">
|
||||||
|
<span class="icon">
|
||||||
|
<icon icon="cocktail"/>
|
||||||
|
</span>
|
||||||
|
<span class="text">
|
||||||
|
<span>
|
||||||
|
This Weekend
|
||||||
|
</span>
|
||||||
|
<span class="weekday">
|
||||||
|
{{ getWeekdayFromStringInterval('thisWeekend') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a @click.stop="() => setDate('laterThisWeek')">
|
||||||
|
<span class="icon">
|
||||||
|
<icon icon="chess-knight"/>
|
||||||
|
</span>
|
||||||
|
<span class="text">
|
||||||
|
<span>
|
||||||
|
Later This Week
|
||||||
|
</span>
|
||||||
|
<span class="weekday">
|
||||||
|
{{ getWeekdayFromStringInterval('laterThisWeek') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a @click.stop="() => setDate('nextWeek')">
|
||||||
|
<span class="icon">
|
||||||
|
<icon icon="forward"/>
|
||||||
|
</span>
|
||||||
|
<span class="text">
|
||||||
|
<span>
|
||||||
|
Next Week
|
||||||
|
</span>
|
||||||
|
<span class="weekday">
|
||||||
|
{{ getWeekdayFromStringInterval('nextWeek') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<flat-pickr
|
||||||
|
:config="flatPickerConfig"
|
||||||
|
class="input"
|
||||||
|
v-model="flatPickrDate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="button is-outlined is-primary has-no-shadow is-fullwidth"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import flatPickr from 'vue-flatpickr-component'
|
||||||
|
import 'flatpickr/dist/flatpickr.css'
|
||||||
|
|
||||||
|
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
|
||||||
|
import {format} from 'date-fns'
|
||||||
|
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'datepicker',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
date: null,
|
||||||
|
show: false,
|
||||||
|
changed: false,
|
||||||
|
|
||||||
|
flatPickerConfig: {
|
||||||
|
altFormat: 'j M Y H:i',
|
||||||
|
altInput: true,
|
||||||
|
dateFormat: 'Y-m-d H:i',
|
||||||
|
enableTime: true,
|
||||||
|
time_24hr: true,
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
// Since flatpickr dates are strings, we need to convert them to native date objects.
|
||||||
|
// To make that work, we need a separate variable since flatpickr does not have a change event.
|
||||||
|
flatPickrDate: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
flatPickr,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string'
|
||||||
|
},
|
||||||
|
chooseDateLabel: {
|
||||||
|
type: String,
|
||||||
|
default: 'Choose a date'
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.date = this.value
|
||||||
|
document.addEventListener('click', this.hideDatePopup)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
document.removeEventListener('click', this.hideDatePopup)
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value(newVal) {
|
||||||
|
if(newVal === null) {
|
||||||
|
this.date = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.date = new Date(newVal)
|
||||||
|
},
|
||||||
|
flatPickrDate(newVal) {
|
||||||
|
this.date = new Date(newVal)
|
||||||
|
this.updateData()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateData() {
|
||||||
|
this.changed = true
|
||||||
|
this.$emit('input', this.date)
|
||||||
|
this.$emit('change', this.date)
|
||||||
|
},
|
||||||
|
toggleDatePopup() {
|
||||||
|
if(this.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.show = !this.show
|
||||||
|
},
|
||||||
|
hideDatePopup(e) {
|
||||||
|
if (this.show) {
|
||||||
|
|
||||||
|
// We walk up the tree to see if any parent of the clicked element is the datepicker element.
|
||||||
|
// If it is not, we hide the popup. We're doing all this hassle to prevent the popup from closing when
|
||||||
|
// clicking an element of flatpickr.
|
||||||
|
let parent = e.target.parentElement
|
||||||
|
while (parent !== this.$refs.datepickerPopup) {
|
||||||
|
if (parent.parentElement === null) {
|
||||||
|
parent = null
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
parent = parent.parentElement
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent === this.$refs.datepickerPopup) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
this.show = false
|
||||||
|
this.$emit('close', this.changed)
|
||||||
|
if(this.changed) {
|
||||||
|
this.changed = false
|
||||||
|
this.$emit('close-on-change', this.changed)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setDate(date) {
|
||||||
|
if (this.date === null) {
|
||||||
|
this.date = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = calculateDayInterval(date)
|
||||||
|
const newDate = new Date()
|
||||||
|
newDate.setDate(newDate.getDate() + interval)
|
||||||
|
newDate.setHours(calculateNearestHours(newDate))
|
||||||
|
newDate.setMinutes(0)
|
||||||
|
newDate.setSeconds(0)
|
||||||
|
this.date = newDate
|
||||||
|
this.flatPickrDate = newDate
|
||||||
|
this.updateData()
|
||||||
|
},
|
||||||
|
getDayIntervalFromString(date) {
|
||||||
|
return calculateDayInterval(date)
|
||||||
|
},
|
||||||
|
getWeekdayFromStringInterval(date) {
|
||||||
|
const interval = calculateDayInterval(date)
|
||||||
|
const newDate = new Date()
|
||||||
|
newDate.setDate(newDate.getDate() + interval)
|
||||||
|
return format(newDate, 'E')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -1,70 +1,79 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="reminders">
|
<div class="reminders">
|
||||||
<div
|
<div
|
||||||
:class="{ 'overdue': (r < nowUnix && index !== (reminders.length - 1))}"
|
v-for="(r, index) in reminders"
|
||||||
:key="index"
|
:key="index"
|
||||||
|
:class="{ 'overdue': r < new Date()}"
|
||||||
class="reminder-input"
|
class="reminder-input"
|
||||||
v-for="(r, index) in reminders">
|
>
|
||||||
<flat-pickr
|
<datepicker
|
||||||
:config="flatPickerConfig"
|
v-model="reminders[index]"
|
||||||
:data-index="index"
|
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:value="r"
|
@close-on-change="() => addReminderDate(index)"
|
||||||
/>
|
/>
|
||||||
<a @click="removeReminderByIndex(index)" v-if="!disabled">
|
<a @click="removeReminderByIndex(index)" v-if="!disabled" class="remove">
|
||||||
<icon icon="times"></icon>
|
<icon icon="times"></icon>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="reminder-input" v-if="showNewReminder">
|
<div class="reminder-input" v-if="!disabled">
|
||||||
<flat-pickr
|
<datepicker
|
||||||
:config="flatPickerConfig"
|
v-model="newReminder"
|
||||||
:disabled="disabled"
|
@close-on-change="() => addReminderDate()"
|
||||||
:value="null"
|
choose-date-label="Add a new reminder..."
|
||||||
placeholder="Add a new reminder..."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import flatPickr from 'vue-flatpickr-component'
|
import datepicker from '@/components/input/datepicker'
|
||||||
import 'flatpickr/dist/flatpickr.css'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'reminders',
|
name: 'reminders',
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
newReminder: null,
|
||||||
reminders: [],
|
reminders: [],
|
||||||
lastReminder: 0,
|
|
||||||
nowUnix: new Date(),
|
|
||||||
showNewReminder: true,
|
|
||||||
flatPickerConfig: {
|
|
||||||
altFormat: 'j M Y H:i',
|
|
||||||
altInput: true,
|
|
||||||
dateFormat: 'Y-m-d H:i',
|
|
||||||
enableTime: true,
|
|
||||||
onOpen: this.updateLastReminderDate,
|
|
||||||
onClose: this.addReminderDate,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
value: {
|
value: {
|
||||||
default: () => [],
|
default: () => [],
|
||||||
type: Array,
|
validator: prop => {
|
||||||
|
// This allows arrays of Dates and strings
|
||||||
|
if (!(prop instanceof Array)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of prop) {
|
||||||
|
const isDate = e instanceof Date
|
||||||
|
const isString = typeof e === 'string'
|
||||||
|
if (!isDate && !isString) {
|
||||||
|
console.log('validation failed', e, e instanceof Date)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
},
|
},
|
||||||
disabled: {
|
disabled: {
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
flatPickr,
|
datepicker,
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.reminders = this.value
|
this.reminders = this.value
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
value(newVal) {
|
value(newVal) {
|
||||||
|
for (const i in newVal) {
|
||||||
|
if (typeof newVal[i] === 'string') {
|
||||||
|
newVal[i] = new Date(newVal[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
this.reminders = newVal
|
this.reminders = newVal
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -73,40 +82,22 @@ export default {
|
||||||
this.$emit('input', this.reminders)
|
this.$emit('input', this.reminders)
|
||||||
this.$emit('change')
|
this.$emit('change')
|
||||||
},
|
},
|
||||||
updateLastReminderDate(selectedDates) {
|
addReminderDate(index = null) {
|
||||||
this.lastReminder = +new Date(selectedDates[0])
|
// New Date
|
||||||
},
|
if (index === null) {
|
||||||
addReminderDate(selectedDates, dateStr, instance) {
|
if (this.newReminder === null) {
|
||||||
const newDate = +new Date(selectedDates[0])
|
|
||||||
|
|
||||||
// Don't update if nothing changed
|
|
||||||
if (newDate === this.lastReminder) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
this.reminders.push(new Date(this.newReminder))
|
||||||
// No date selected
|
this.newReminder = null
|
||||||
if (isNaN(newDate)) {
|
} else if(this.reminders[index] === null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = parseInt(instance.input.dataset.index)
|
|
||||||
if (isNaN(index)) {
|
|
||||||
this.reminders.push(newDate)
|
|
||||||
// This is a workaround to recreate the flatpicker instance which essentially resets it.
|
|
||||||
// Even though flatpickr itself has a reset event, the Vue component does not expose it.
|
|
||||||
this.showNewReminder = false
|
|
||||||
this.$nextTick(() => this.showNewReminder = true)
|
|
||||||
} else {
|
|
||||||
this.reminders[index] = newDate
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateData()
|
this.updateData()
|
||||||
},
|
},
|
||||||
removeReminderByIndex(index) {
|
removeReminderByIndex(index) {
|
||||||
this.reminders.splice(index, 1)
|
this.reminders.splice(index, 1)
|
||||||
// Reset the last to 0 to have the "add reminder" button
|
|
||||||
this.reminders[this.reminders.length - 1] = null
|
|
||||||
|
|
||||||
this.updateData()
|
this.updateData()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
26
src/helpers/time/calculateDayInterval.js
Normal file
26
src/helpers/time/calculateDayInterval.js
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
export function calculateDayInterval(date, currentDay = (new Date().getDay())) {
|
||||||
|
switch (date) {
|
||||||
|
case 'today':
|
||||||
|
return 0
|
||||||
|
case 'tomorrow':
|
||||||
|
return 1
|
||||||
|
case 'nextMonday':
|
||||||
|
// Monday is 1, so we calculate the distance to the next 1
|
||||||
|
return (currentDay + (8 - currentDay * 2)) % 7
|
||||||
|
case 'thisWeekend':
|
||||||
|
// Saturday is 6 so we calculate the distance to the next 6
|
||||||
|
return (6 - currentDay) % 6
|
||||||
|
case 'laterThisWeek':
|
||||||
|
if (currentDay === 5 || currentDay === 6 || currentDay === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return 2
|
||||||
|
case 'laterNextWeek':
|
||||||
|
return calculateDayInterval('laterThisWeek', currentDay) + 7
|
||||||
|
case 'nextWeek':
|
||||||
|
return 7
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
93
src/helpers/time/calculateDayInterval.test.js
Normal file
93
src/helpers/time/calculateDayInterval.test.js
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import {calculateDayInterval} from './calculateDayInterval'
|
||||||
|
|
||||||
|
const days = {
|
||||||
|
monday: 1,
|
||||||
|
tuesday: 2,
|
||||||
|
wednesday: 3,
|
||||||
|
thursday: 4,
|
||||||
|
friday: 5,
|
||||||
|
saturday: 6,
|
||||||
|
sunday: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n in days) {
|
||||||
|
test(`today on a ${n}`, () => {
|
||||||
|
expect(calculateDayInterval('today', days[n])).toBe(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n in days) {
|
||||||
|
test(`tomorrow on a ${n}`, () => {
|
||||||
|
expect(calculateDayInterval('tomorrow', days[n])).toBe(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMonday = {
|
||||||
|
monday: 0,
|
||||||
|
tuesday: 6,
|
||||||
|
wednesday: 5,
|
||||||
|
thursday: 4,
|
||||||
|
friday: 3,
|
||||||
|
saturday: 2,
|
||||||
|
sunday: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n in nextMonday) {
|
||||||
|
test(`next monday on a ${n}`, () => {
|
||||||
|
expect(calculateDayInterval('nextMonday', days[n])).toBe(nextMonday[n])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const thisWeekend = {
|
||||||
|
monday: 5,
|
||||||
|
tuesday: 4,
|
||||||
|
wednesday: 3,
|
||||||
|
thursday: 2,
|
||||||
|
friday: 1,
|
||||||
|
saturday: 0,
|
||||||
|
sunday: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n in thisWeekend) {
|
||||||
|
test(`this weekend on a ${n}`, () => {
|
||||||
|
expect(calculateDayInterval('thisWeekend', days[n])).toBe(thisWeekend[n])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const laterThisWeek = {
|
||||||
|
monday: 2,
|
||||||
|
tuesday: 2,
|
||||||
|
wednesday: 2,
|
||||||
|
thursday: 2,
|
||||||
|
friday: 0,
|
||||||
|
saturday: 0,
|
||||||
|
sunday: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n in laterThisWeek) {
|
||||||
|
test(`later this week on a ${n}`, () => {
|
||||||
|
expect(calculateDayInterval('laterThisWeek', days[n])).toBe(laterThisWeek[n])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const laterNextWeek = {
|
||||||
|
monday: 7 + 2,
|
||||||
|
tuesday: 7 + 2,
|
||||||
|
wednesday: 7 + 2,
|
||||||
|
thursday: 7 + 2,
|
||||||
|
friday: 7 + 0,
|
||||||
|
saturday: 7 + 0,
|
||||||
|
sunday: 7 + 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n in laterNextWeek) {
|
||||||
|
test(`later next week on a ${n} (this week)`, () => {
|
||||||
|
expect(calculateDayInterval('laterNextWeek', days[n])).toBe(laterNextWeek[n])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n in days) {
|
||||||
|
test(`next week on a ${n}`, () => {
|
||||||
|
expect(calculateDayInterval('nextWeek', days[n])).toBe(7)
|
||||||
|
})
|
||||||
|
}
|
21
src/helpers/time/calculateNearestHours.js
Normal file
21
src/helpers/time/calculateNearestHours.js
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
export function calculateNearestHours(currentDate = new Date()) {
|
||||||
|
if (currentDate.getHours() <= 9 || currentDate.getHours() > 21) {
|
||||||
|
return 9
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentDate.getHours() <= 12) {
|
||||||
|
return 12
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentDate.getHours() <= 15) {
|
||||||
|
return 15
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentDate.getHours() <= 18) {
|
||||||
|
return 18
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentDate.getHours() <= 21) {
|
||||||
|
return 21
|
||||||
|
}
|
||||||
|
}
|
90
src/helpers/time/calculateNearestTime.test.js
Normal file
90
src/helpers/time/calculateNearestTime.test.js
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import {calculateNearestHours} from './calculateNearestHours'
|
||||||
|
|
||||||
|
test('5:00', () => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(5)
|
||||||
|
expect(calculateNearestHours(date)).toBe(9)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('7:00', () => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(7)
|
||||||
|
expect(calculateNearestHours(date)).toBe(9)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('7:41', () => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(7)
|
||||||
|
date.setMinutes(41)
|
||||||
|
expect(calculateNearestHours(date)).toBe(9)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('9:00', () => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(9)
|
||||||
|
date.setMinutes(0)
|
||||||
|
expect(calculateNearestHours(date)).toBe(9)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('10:00', () => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(10)
|
||||||
|
date.setMinutes(0)
|
||||||
|
expect(calculateNearestHours(date)).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('12:00', () => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(12)
|
||||||
|
date.setMinutes(0)
|
||||||
|
expect(calculateNearestHours(date)).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('13:00', () => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(13)
|
||||||
|
date.setMinutes(0)
|
||||||
|
expect(calculateNearestHours(date)).toBe(15)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('15:00', () => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(15)
|
||||||
|
date.setMinutes(0)
|
||||||
|
expect(calculateNearestHours(date)).toBe(15)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('16:00', () => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(16)
|
||||||
|
date.setMinutes(0)
|
||||||
|
expect(calculateNearestHours(date)).toBe(18)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('18:00', () => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(18)
|
||||||
|
date.setMinutes(0)
|
||||||
|
expect(calculateNearestHours(date)).toBe(18)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('19:00', () => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(19)
|
||||||
|
date.setMinutes(0)
|
||||||
|
expect(calculateNearestHours(date)).toBe(21)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('22:00', () => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(22)
|
||||||
|
date.setMinutes(0)
|
||||||
|
expect(calculateNearestHours(date)).toBe(9)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('22:40', () => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(22)
|
||||||
|
date.setMinutes(0)
|
||||||
|
expect(calculateNearestHours(date)).toBe(9)
|
||||||
|
})
|
21
src/main.js
21
src/main.js
|
@ -53,8 +53,12 @@ import {
|
||||||
faTrashAlt,
|
faTrashAlt,
|
||||||
faUser,
|
faUser,
|
||||||
faUsers,
|
faUsers,
|
||||||
|
faForward,
|
||||||
|
faChessKnight,
|
||||||
|
faCoffee,
|
||||||
|
faCocktail,
|
||||||
} from '@fortawesome/free-solid-svg-icons'
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
import {faCalendarAlt, faClock, faComments, faSave, faStar, faTimesCircle} from '@fortawesome/free-regular-svg-icons'
|
import {faCalendarAlt, faClock, faComments, faSave, faStar, faTimesCircle, faSun} from '@fortawesome/free-regular-svg-icons'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome'
|
||||||
// PWA
|
// PWA
|
||||||
import './registerServiceWorker'
|
import './registerServiceWorker'
|
||||||
|
@ -135,6 +139,11 @@ library.add(faFillDrip)
|
||||||
library.add(faKeyboard)
|
library.add(faKeyboard)
|
||||||
library.add(faSave)
|
library.add(faSave)
|
||||||
library.add(faStarSolid)
|
library.add(faStarSolid)
|
||||||
|
library.add(faForward)
|
||||||
|
library.add(faSun)
|
||||||
|
library.add(faChessKnight)
|
||||||
|
library.add(faCoffee)
|
||||||
|
library.add(faCocktail)
|
||||||
|
|
||||||
Vue.component('icon', FontAwesomeIcon)
|
Vue.component('icon', FontAwesomeIcon)
|
||||||
|
|
||||||
|
@ -146,6 +155,13 @@ Vue.directive('focus', focus)
|
||||||
import tooltip from '@/directives/tooltip'
|
import tooltip from '@/directives/tooltip'
|
||||||
Vue.directive('tooltip', tooltip)
|
Vue.directive('tooltip', tooltip)
|
||||||
|
|
||||||
|
const formatDate = (date, f) => {
|
||||||
|
if (typeof date === 'string') {
|
||||||
|
date = new Date(date)
|
||||||
|
}
|
||||||
|
return date ? format(date, f) : ''
|
||||||
|
}
|
||||||
|
|
||||||
Vue.mixin({
|
Vue.mixin({
|
||||||
methods: {
|
methods: {
|
||||||
formatDateSince: date => {
|
formatDateSince: date => {
|
||||||
|
@ -170,6 +186,9 @@ Vue.mixin({
|
||||||
}
|
}
|
||||||
return date ? format(date, 'PPPPpppp') : ''
|
return date ? format(date, 'PPPPpppp') : ''
|
||||||
},
|
},
|
||||||
|
formatDateShort: date => {
|
||||||
|
return formatDate(date, 'PPpp')
|
||||||
|
},
|
||||||
error: (e, context, actions = []) => message.error(e, context, actions),
|
error: (e, context, actions = []) => message.error(e, context, actions),
|
||||||
success: (s, context, actions = []) => message.success(s, context, actions),
|
success: (s, context, actions = []) => message.success(s, context, actions),
|
||||||
colorIsDark: colorIsDark,
|
colorIsDark: colorIsDark,
|
||||||
|
|
|
@ -22,3 +22,4 @@
|
||||||
@import 'legal';
|
@import 'legal';
|
||||||
@import 'keyboard-shortcuts';
|
@import 'keyboard-shortcuts';
|
||||||
@import 'api-config';
|
@import 'api-config';
|
||||||
|
@import 'datepicker'
|
||||||
|
|
68
src/styles/components/datepicker.scss
Normal file
68
src/styles/components/datepicker.scss
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
.datepicker {
|
||||||
|
input.input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled a {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datepicker-popup {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 99;
|
||||||
|
width: 320px;
|
||||||
|
background: $white;
|
||||||
|
border-radius: $radius;
|
||||||
|
box-shadow: $card-shadow;
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 .5rem;
|
||||||
|
width: 100%;
|
||||||
|
height: 2.25rem;
|
||||||
|
color: $text;
|
||||||
|
transition: all $transition;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-radius: $radius $radius 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
width: 100%;
|
||||||
|
font-size: .85rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-right: .25rem;
|
||||||
|
|
||||||
|
.weekday {
|
||||||
|
color: $text-light;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.button {
|
||||||
|
margin: 1rem;
|
||||||
|
width: calc(100% - 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flatpickr-calendar {
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: ($tablet)) {
|
||||||
|
width: calc(100vw - 4rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,7 +3,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
&.overdue input {
|
&.overdue .datepicker a.show {
|
||||||
color: $red;
|
color: $red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,19 +11,9 @@
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a.remove {
|
||||||
color: $red;
|
color: $red;
|
||||||
padding-left: .5rem;
|
padding-left: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,7 +50,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
a {
|
a.remove {
|
||||||
color: $red;
|
color: $red;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
padding-left: .5em;
|
padding-left: .5em;
|
||||||
|
@ -58,6 +58,27 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.datepicker {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
a.show {
|
||||||
|
color: $text;
|
||||||
|
padding: .25rem .5rem;
|
||||||
|
transition: background-color $transition;
|
||||||
|
border-radius: $radius;
|
||||||
|
display: block;
|
||||||
|
margin: .1rem 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled a.show:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
|
|
||||||
|
|
|
@ -47,18 +47,14 @@
|
||||||
Due Date
|
Due Date
|
||||||
</div>
|
</div>
|
||||||
<div class="date-input">
|
<div class="date-input">
|
||||||
<flat-pickr
|
<datepicker
|
||||||
:class="{ 'disabled': taskService.loading}"
|
v-model="task.dueDate"
|
||||||
:config="flatPickerConfig"
|
@close-on-change="() => saveTask()"
|
||||||
|
choose-date-label="Click here to set a due date"
|
||||||
:disabled="taskService.loading || !canWrite"
|
:disabled="taskService.loading || !canWrite"
|
||||||
@on-close="() => saveTask()"
|
|
||||||
class="input"
|
|
||||||
placeholder="Click here to set a due date"
|
|
||||||
ref="dueDate"
|
ref="dueDate"
|
||||||
v-model="dueDate"
|
/>
|
||||||
>
|
<a @click="() => {task.dueDate = null;saveTask()}" v-if="task.dueDate && canWrite" class="remove">
|
||||||
</flat-pickr>
|
|
||||||
<a @click="() => {dueDate = task.dueDate = null;saveTask()}" v-if="dueDate && canWrite">
|
|
||||||
<span class="icon is-small">
|
<span class="icon is-small">
|
||||||
<icon icon="times"></icon>
|
<icon icon="times"></icon>
|
||||||
</span>
|
</span>
|
||||||
|
@ -84,18 +80,14 @@
|
||||||
Start Date
|
Start Date
|
||||||
</div>
|
</div>
|
||||||
<div class="date-input">
|
<div class="date-input">
|
||||||
<flat-pickr
|
<datepicker
|
||||||
:class="{ 'disabled': taskService.loading}"
|
|
||||||
:config="flatPickerConfig"
|
|
||||||
:disabled="taskService.loading || !canWrite"
|
|
||||||
@on-close="() => saveTask()"
|
|
||||||
class="input"
|
|
||||||
placeholder="Click here to set a start date"
|
|
||||||
ref="startDate"
|
|
||||||
v-model="task.startDate"
|
v-model="task.startDate"
|
||||||
>
|
@close-on-change="() => saveTask()"
|
||||||
</flat-pickr>
|
choose-date-label="Click here to set a start date"
|
||||||
<a @click="() => {task.startDate = null;saveTask()}" v-if="task.startDate && canWrite">
|
:disabled="taskService.loading || !canWrite"
|
||||||
|
ref="startDate"
|
||||||
|
/>
|
||||||
|
<a @click="() => {task.startDate = null;saveTask()}" v-if="task.startDate && canWrite" class="remove">
|
||||||
<span class="icon is-small">
|
<span class="icon is-small">
|
||||||
<icon icon="times"></icon>
|
<icon icon="times"></icon>
|
||||||
</span>
|
</span>
|
||||||
|
@ -109,18 +101,14 @@
|
||||||
End Date
|
End Date
|
||||||
</div>
|
</div>
|
||||||
<div class="date-input">
|
<div class="date-input">
|
||||||
<flat-pickr
|
<datepicker
|
||||||
:class="{ 'disabled': taskService.loading}"
|
|
||||||
:config="flatPickerConfig"
|
|
||||||
:disabled="taskService.loading || !canWrite"
|
|
||||||
@on-close="() => saveTask()"
|
|
||||||
class="input"
|
|
||||||
placeholder="Click here to set an end date"
|
|
||||||
ref="endDate"
|
|
||||||
v-model="task.endDate"
|
v-model="task.endDate"
|
||||||
>
|
@close-on-change="() => saveTask()"
|
||||||
</flat-pickr>
|
choose-date-label="Click here to set an end date"
|
||||||
<a @click="() => {task.endDate = null;saveTask()}" v-if="task.endDate && canWrite">
|
:disabled="taskService.loading || !canWrite"
|
||||||
|
ref="endDate"
|
||||||
|
/>
|
||||||
|
<a @click="() => {task.endDate = null;saveTask()}" v-if="task.endDate && canWrite" class="remove">
|
||||||
<span class="icon is-small">
|
<span class="icon is-small">
|
||||||
<icon icon="times"></icon>
|
<icon icon="times"></icon>
|
||||||
</span>
|
</span>
|
||||||
|
@ -356,9 +344,6 @@ import relationKinds from '../../models/relationKinds.json'
|
||||||
import priorites from '../../models/priorities.json'
|
import priorites from '../../models/priorities.json'
|
||||||
import rights from '../../models/rights.json'
|
import rights from '../../models/rights.json'
|
||||||
|
|
||||||
import flatPickr from 'vue-flatpickr-component'
|
|
||||||
import 'flatpickr/dist/flatpickr.css'
|
|
||||||
|
|
||||||
import PrioritySelect from '../../components/tasks/partials/prioritySelect'
|
import PrioritySelect from '../../components/tasks/partials/prioritySelect'
|
||||||
import PercentDoneSelect from '../../components/tasks/partials/percentDoneSelect'
|
import PercentDoneSelect from '../../components/tasks/partials/percentDoneSelect'
|
||||||
import EditLabels from '../../components/tasks/partials/editLabels'
|
import EditLabels from '../../components/tasks/partials/editLabels'
|
||||||
|
@ -374,10 +359,12 @@ import description from '@/components/tasks/partials/description'
|
||||||
import ColorPicker from '../../components/input/colorPicker'
|
import ColorPicker from '../../components/input/colorPicker'
|
||||||
import attachmentUpload from '../../components/tasks/mixins/attachmentUpload'
|
import attachmentUpload from '../../components/tasks/mixins/attachmentUpload'
|
||||||
import heading from '@/components/tasks/partials/heading'
|
import heading from '@/components/tasks/partials/heading'
|
||||||
|
import Datepicker from '@/components/input/datepicker'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'TaskDetailView',
|
name: 'TaskDetailView',
|
||||||
components: {
|
components: {
|
||||||
|
Datepicker,
|
||||||
ColorPicker,
|
ColorPicker,
|
||||||
ListSearch,
|
ListSearch,
|
||||||
Reminders,
|
Reminders,
|
||||||
|
@ -389,7 +376,6 @@ export default {
|
||||||
PercentDoneSelect,
|
PercentDoneSelect,
|
||||||
PrioritySelect,
|
PrioritySelect,
|
||||||
Comments,
|
Comments,
|
||||||
flatPickr,
|
|
||||||
description,
|
description,
|
||||||
heading,
|
heading,
|
||||||
},
|
},
|
||||||
|
@ -402,9 +388,6 @@ export default {
|
||||||
taskService: TaskService,
|
taskService: TaskService,
|
||||||
task: TaskModel,
|
task: TaskModel,
|
||||||
relationKinds: relationKinds,
|
relationKinds: relationKinds,
|
||||||
// The due date is a seperate property in the task to prevent flatpickr from modifying the task model
|
|
||||||
// in store right after updating it from the api resulting in the wrong due date format being saved in the task.
|
|
||||||
dueDate: null,
|
|
||||||
// We doubled the task color property here because verte does not have a real change property, leading
|
// We doubled the task color property here because verte does not have a real change property, leading
|
||||||
// to the color property change being triggered when the # is removed from it, leading to an update,
|
// to the color property change being triggered when the # is removed from it, leading to an update,
|
||||||
// which leads in turn to a change... This creates an infinite loop in which the task is updated, changed,
|
// which leads in turn to a change... This creates an infinite loop in which the task is updated, changed,
|
||||||
|
@ -421,13 +404,6 @@ export default {
|
||||||
descriptionRecentlySaved: false,
|
descriptionRecentlySaved: false,
|
||||||
|
|
||||||
priorities: priorites,
|
priorities: priorites,
|
||||||
flatPickerConfig: {
|
|
||||||
altFormat: 'j M Y H:i',
|
|
||||||
altInput: true,
|
|
||||||
dateFormat: 'Y-m-d H:i',
|
|
||||||
enableTime: true,
|
|
||||||
time_24hr: true,
|
|
||||||
},
|
|
||||||
activeFields: {
|
activeFields: {
|
||||||
assignees: false,
|
assignees: false,
|
||||||
priority: false,
|
priority: false,
|
||||||
|
@ -504,7 +480,6 @@ export default {
|
||||||
},
|
},
|
||||||
setActiveFields() {
|
setActiveFields() {
|
||||||
|
|
||||||
this.dueDate = this.task.dueDate ? this.task.dueDate : null
|
|
||||||
this.task.startDate = this.task.startDate ? this.task.startDate : null
|
this.task.startDate = this.task.startDate ? this.task.startDate : null
|
||||||
this.task.endDate = this.task.endDate ? this.task.endDate : null
|
this.task.endDate = this.task.endDate ? this.task.endDate : null
|
||||||
|
|
||||||
|
@ -530,7 +505,6 @@ export default {
|
||||||
// We're doing the whole update in a nextTick because sometimes race conditions can occur when
|
// We're doing the whole update in a nextTick because sometimes race conditions can occur when
|
||||||
// setting the due date on mobile which leads to no due date change being saved.
|
// setting the due date on mobile which leads to no due date change being saved.
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.task.dueDate = this.dueDate
|
|
||||||
this.task.hexColor = this.taskColor
|
this.task.hexColor = this.taskColor
|
||||||
|
|
||||||
// If no end date is being set, but a start date and due date,
|
// If no end date is being set, but a start date and due date,
|
||||||
|
|
Loading…
Reference in a new issue