WIP: REFERENCE datepicker script setup alternative #1972

Closed
dpschen wants to merge 1 commits from dpschen/frontend:feat-datepicker-script-setup into main
6 changed files with 229 additions and 241 deletions

View File

@ -1,71 +1,22 @@
<template>
<div class="datepicker">
<BaseButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined">
{{ date === null ? chooseDateLabel : formatDateShort(date) }}
<div class="datepicker" ref="datepicker">
<BaseButton @click="toggleDatePopup" class="show" :disabled="disabled || undefined">
{{ state.date === null ? chooseDateLabel : formatDateShort(state.date) }}
</BaseButton>
<transition name="fade">
<div v-if="show" class="datepicker-popup" ref="datepickerPopup">
<div v-if="show" class="datepicker-popup">
<BaseButton
v-if="(new Date()).getHours() < 21"
v-for="({dayInterval, label, icon}) of selectDateOptions"
:key="dayInterval"
class="datepicker__quick-select-date"
@click.stop="setDate('today')"
@click="setDate(dayInterval)"
>
<span class="icon"><icon :icon="['far', 'calendar-alt']"/></span>
<span class="icon"><icon :icon="icon"/></span>
<span class="text">
<span>{{ $t('input.datepicker.today') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('tomorrow')"
>
<span class="icon"><icon :icon="['far', 'sun']"/></span>
<span class="text">
<span>{{ $t('input.datepicker.tomorrow') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('nextMonday')"
>
<span class="icon"><icon icon="coffee"/></span>
<span class="text">
<span>{{ $t('input.datepicker.nextMonday') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('thisWeekend')"
>
<span class="icon"><icon icon="cocktail"/></span>
<span class="text">
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('laterThisWeek')"
>
<span class="icon"><icon icon="chess-knight"/></span>
<span class="text">
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
</span>
</BaseButton>
<BaseButton
class="datepicker__quick-select-date"
@click.stop="setDate('nextWeek')"
>
<span class="icon"><icon icon="forward"/></span>
<span class="text">
<span>{{ $t('input.datepicker.nextWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
<span>{{ label }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval(dayInterval) }}</span>
</span>
</BaseButton>
@ -78,7 +29,7 @@
<x-button
class="datepicker__close-button"
:shadow="false"
@click="close"
@click="closeDatePopup"
v-cy="'closeDatepicker'"
>
{{ $t('misc.confirm') }}
@ -88,155 +39,197 @@
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue'
<script lang="ts" setup>
import {ref, reactive, computed, watch, type PropType } from 'vue'
import {useI18n} from 'vue-i18n'
import {useStore} from 'vuex'
import {useNow, onClickOutside} from '@vueuse/core'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {i18n} from '@/i18n'
import BaseButton from '@/components/base/BaseButton.vue'
import {format} from 'date-fns'
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {formatDateShort} from '@/helpers/time/formatDate'
export default defineComponent({
name: 'datepicker',
data() {
return {
date: null,
show: false,
changed: false,
}
},
components: {
flatPickr,
BaseButton,
},
props: {
modelValue: {
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
},
chooseDateLabel: {
type: String,
default() {
return i18n.global.t('input.datepicker.chooseDate')
},
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue', 'change', 'close', 'close-on-change'],
mounted() {
document.addEventListener('click', this.hideDatePopup)
},
beforeUnmount() {
document.removeEventListener('click', this.hideDatePopup)
},
watch: {
modelValue: {
handler: 'setDateValue',
immediate: true,
},
},
computed: {
flatPickerConfig() {
return {
altFormat: this.$t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
},
}
},
// 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: {
set(newValue) {
this.date = createDateFromString(newValue)
this.updateData()
},
get() {
if (!this.date) {
return ''
}
function getDateFromDayInterval(dayInterval: string) {
const interval = calculateDayInterval(dayInterval)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
newDate.setHours(calculateNearestHours(newDate))
newDate.setMinutes(0)
newDate.setSeconds(0)
return newDate
}
return format(this.date, 'yyy-LL-dd H:mm')
},
function getWeekdayFromStringInterval(dateInterval: string) {
dpschen marked this conversation as resolved Outdated

Why don't you add the useI18n before the props declaration?

Why don't you add the `useI18n` before the props declaration?

That wouldn't work. Because afaik the props, events declaration and imports are extracted from script setup and the rest will be wrapped by the setup function. Meaning that if I would use use18n before defineProps it would still be inside the setup block after beeing compiled => no access inside the props.

That wouldn't work. Because afaik the props, events declaration and imports are extracted from script setup and the rest will be wrapped by the setup function. Meaning that if I would use `use18n` before `defineProps` it would still be inside the setup block after beeing compiled => no access inside the props.
const interval = calculateDayInterval(dateInterval)
const newDate = new Date(now.value)
newDate.setDate(newDate.getDate() + interval)
return format(newDate, 'E')
}
const props = defineProps({
// FIXME: should only accept Date objects. Then we wouldn't need all the conversion
modelValue: {
type: [Date, null, String] as PropType<Date | null | string>,
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
default: null,
},
chooseDateLabel: {
type: String as PropType<string>,
default() {
const {t} = useI18n({useScope: 'global'})
return t('input.datepicker.chooseDate')
},
},
methods: {
setDateValue(newVal) {
if (newVal === null) {
this.date = null
return
}
this.date = createDateFromString(newVal)
},
updateData() {
this.changed = true
this.$emit('update:modelValue', this.date)
this.$emit('change', this.date)
},
toggleDatePopup() {
if (this.disabled) {
return
}
this.show = !this.show
},
hideDatePopup(e) {
if (this.show) {
closeWhenClickedOutside(e, this.$refs.datepickerPopup, this.close)
}
},
close() {
// Kind of dirty, but the timeout allows us to enter a time and click on "confirm" without
// having to click on another input field before it is actually used.
setTimeout(() => {
this.show = false
this.$emit('close', this.changed)
if (this.changed) {
this.changed = false
this.$emit('close-on-change', this.changed)
}
}, 200)
},
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')
},
disabled: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const {t} = useI18n({useScope: 'global'})
const store = useStore()
const show = ref(false)
// FIXME: replace with popup
const datepicker = ref<HTMLElement | null>(null)
onClickOutside(datepicker, closeDatePopup)
function toggleDatePopup() {
if (props.disabled) {
return
}
show.value = !show.value
}
function closeDatePopup() {
show.value = false
}
const state : {
date: Date | null,
initialDate: Date | null,
}= reactive({
date: null,
initialDate: null,
})
// const date = ref<Date | null>(null)
// const initialDate = ref<Date | null>(null)
watch(
() => props.modelValue,
(value: null | Date | string) => {
const newDate = value === null ? null : createDateFromString(value)
Object.assign(state, {
date: newDate,
initialDate: newDate,
})
},
{ immediate: true },
)
function isSameDate(dateA: Date | null, dateB: Date | null) {
return (dateA !== null ? new Date(dateA).toISOString() : null) ===
(dateB !== null ? new Date(dateB).toISOString() : null)
}
watch(show, (newIsShown) => {
newIsShown && console.log(state.date, state.initialDate)
if (
newIsShown === false &&
!isSameDate(state.initialDate, state.date)
) {
console.log('initialDate', state.initialDate !== null ? new Date(state.initialDate).toISOString() : null)
console.log('date', state.date !== null ? new Date(state.date).toISOString() : null)
// make copy of date
emit('update:modelValue', state.date !== null ? new Date(state.date) : null)
}
})
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: store.state.auth.settings.weekStart,
},
}))
dpschen marked this conversation as resolved Outdated

Please remove this.

Please remove this.
// 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.
const flatPickrDate = computed({
get() {
if (!state.date) {
return ''
}
return format(state.date, 'yyy-LL-dd H:mm')
},
set(newValue: string) {
state.date = createDateFromString(newValue)
},
})
const now = useNow()
const selectDateOptions = computed(() => {
const options = [
{
condition: now.value.getHours() < 21,
dayInterval: 'today',
icon: ['far', 'calendar-alt'],
label: t('input.datepicker.today'),
},
{
dayInterval: 'tomorrow',
icon: ['far', 'sun'],
label: t('input.datepicker.tomorrow'),
},
{
dayInterval: 'nextMonday',
dpschen marked this conversation as resolved Outdated

I think this doesn't actually work? But that doesn't really work right now either, more of a mental note to properly fix this some time.

I think this doesn't actually work? But that doesn't really work right now either, more of a mental note to properly fix this some time.
icon: 'coffee',
label: t('input.datepicker.nextMonday'),
},
{
dayInterval: 'thisWeekend',
icon: 'cocktail',
label: t('input.datepicker.thisWeekend'),
},
{
dayInterval: 'laterThisWeek',
icon: 'chess-knight',
label: t('input.datepicker.laterThisWeek'),
},
{
dayInterval: 'nextWeek',
icon: 'forward',
label: t('input.datepicker.nextWeek'),
},
]
return options.filter((option) => option.condition || option.condition === undefined)
})
function setDate(dayInterval: string) {
state.date = getDateFromDayInterval(dayInterval)
}
</script>
<style lang="scss" scoped>

View File

@ -36,8 +36,8 @@
<strong>{{ $t('task.attributes.reminders') }}</strong>
<reminders
@change="editTaskSubmit()"
v-model="taskEditTask.reminderDates"
@update:model-value="editTaskSubmit()"
/>
<div class="field">

View File

@ -3,22 +3,25 @@
<div
v-for="(r, index) in reminders"
:key="index"
:class="{ 'overdue': r < new Date()}"
class="reminder-input"
:class="{ 'overdue': r < new Date()}"
>
<Datepicker
v-model="reminders[index]"
@update:model-value="addReminderDate(index)"
:disabled="disabled"
@close-on-change="() => addReminderDate(index)"
/>
<BaseButton @click="removeReminderByIndex(index)" v-if="!disabled" class="remove">
<icon icon="times"></icon>
<BaseButton
v-if="!disabled" class="remove"
@click="removeReminderByIndex(index)"
>
<icon icon="times" />
</BaseButton>
</div>
<div class="reminder-input" v-if="!disabled">
<Datepicker
v-model="newReminder"
@close-on-change="() => addReminderDate()"
@update:model-value="addReminderDate"
:choose-date-label="$t('task.addReminder')"
/>
</div>
@ -26,15 +29,13 @@
</template>
<script setup lang="ts">
import {PropType, ref, onMounted, watch} from 'vue'
import {type PropType, ref, toRef, watch} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import Datepicker from '@/components/input/datepicker.vue'
type Reminder = Date | string
const props = defineProps({
modelValue: {
type: Array as PropType<Reminder[]>,
@ -67,26 +68,21 @@ const emit = defineEmits(['update:modelValue', 'change'])
const reminders = ref<Reminder[]>([])
onMounted(() => {
reminders.value = [...props.modelValue]
})
watch(
() => props.modelValue,
toRef(props, 'modelValue'),
(newVal) => {
for (const i in newVal) {
if (typeof newVal[i] === 'string') {
newVal[i] = new Date(newVal[i])
}
}
reminders.value = newVal
reminders.value = newVal.map(item => {
return typeof item === 'string'
? new Date(item)
: item
})
},
{ immediate: true },
)
function updateData() {
emit('update:modelValue', reminders.value)
emit('change')
}
const newReminder = ref(null)
@ -98,7 +94,7 @@ function addReminderDate(index : number | null = null) {
}
reminders.value.push(new Date(newReminder.value))
newReminder.value = null
} else if(reminders.value[index] === null) {
} else if (reminders.value[index] === null) {
return
}
@ -112,23 +108,21 @@ function removeReminderByIndex(index: number) {
</script>
<style lang="scss" scoped>
.reminders {
.reminder-input {
display: flex;
align-items: center;
.reminder-input {
display: flex;
align-items: center;
&.overdue :deep(.datepicker .show) {
color: var(--danger);
}
&.overdue :deep(.datepicker .show) {
color: var(--danger);
}
&:last-child {
margin-bottom: 0.75rem;
}
&:last-child {
margin-bottom: 0.75rem;
}
}
.remove {
color: var(--danger);
padding-left: .5rem;
}
}
.remove {
color: var(--danger);
padding-left: .5rem;
}
</style>

View File

@ -1,4 +1,4 @@
export function calculateDayInterval(date, currentDay = (new Date().getDay())) {
export function calculateDayInterval(date, currentDay = (new Date().getDay())): number {
switch (date) {
case 'today':
return 0

View File

@ -6,7 +6,7 @@
* @param dateString
* @returns {Date}
*/
export const createDateFromString = dateString => {
export function createDateFromString(dateString: Date | string) : Date {
if (dateString instanceof Date) {
return dateString
}

View File

@ -54,7 +54,7 @@
<div class="date-input">
<datepicker
v-model="task.dueDate"
@close-on-change="() => saveTask()"
@update:model-value="saveTask"
:choose-date-label="$t('task.detail.chooseDueDate')"
:disabled="taskService.loading || !canWrite"
ref="dueDate"
@ -94,7 +94,7 @@
<div class="date-input">
<datepicker
v-model="task.startDate"
@close-on-change="() => saveTask()"
@update:model-value="saveTask"
:choose-date-label="$t('task.detail.chooseStartDate')"
:disabled="taskService.loading || !canWrite"
ref="startDate"
@ -121,7 +121,7 @@
<div class="date-input">
<datepicker
v-model="task.endDate"
@close-on-change="() => saveTask()"
@update:model-value="saveTask"
:choose-date-label="$t('task.detail.chooseEndDate')"
:disabled="taskService.loading || !canWrite"
ref="endDate"
@ -146,9 +146,10 @@
</div>
<reminders
:disabled="!canWrite"
@change="saveTask"
ref="reminders"
v-model="task.reminderDates"/>
v-model="task.reminderDates"
@update:model-value="saveTask"
/>
</div>
</transition>
<transition name="flash-background" appear>
@ -781,10 +782,10 @@ $flash-background-duration: 750ms;
}
.remove {
color: var(--danger);
vertical-align: middle;
padding-left: .5rem;
line-height: 1;
color: var(--danger);
vertical-align: middle;
padding-left: .5rem;
line-height: 1;
}
:deep(.datepicker) {