feat: edit relative reminders (#3248)
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/3248
This commit is contained in:
269
src/components/tasks/partials/reminder-detail.vue
Normal file
269
src/components/tasks/partials/reminder-detail.vue
Normal file
@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div>
|
||||
<Popup @close="showFormSwitch = null">
|
||||
<template #trigger="{toggle}">
|
||||
<SimpleButton
|
||||
@click.prevent.stop="toggle()"
|
||||
>
|
||||
{{ reminderText }}
|
||||
</SimpleButton>
|
||||
</template>
|
||||
<template #content="{isOpen, toggle}">
|
||||
<Card class="reminder-options-popup" :class="{'is-open': isOpen}" :padding="false">
|
||||
<div class="options" v-if="showFormSwitch === null">
|
||||
<SimpleButton
|
||||
v-for="(p, k) in presets"
|
||||
:key="k"
|
||||
class="option-button"
|
||||
:class="{'currently-active': p.relativePeriod === modelValue?.relativePeriod && modelValue?.relativeTo === p.relativeTo}"
|
||||
@click="setReminderFromPreset(p, toggle)"
|
||||
>
|
||||
{{ formatReminder(p) }}
|
||||
</SimpleButton>
|
||||
<SimpleButton
|
||||
@click="showFormSwitch = 'relative'"
|
||||
class="option-button"
|
||||
:class="{'currently-active': typeof modelValue !== 'undefined' && modelValue?.relativeTo !== null && presets.find(p => p.relativePeriod === modelValue?.relativePeriod && modelValue?.relativeTo === p.relativeTo) === undefined}"
|
||||
>
|
||||
{{ $t('task.reminder.custom') }}
|
||||
</SimpleButton>
|
||||
<SimpleButton
|
||||
@click="showFormSwitch = 'absolute'"
|
||||
class="option-button"
|
||||
:class="{'currently-active': modelValue?.relativeTo === null}"
|
||||
>
|
||||
{{ $t('task.reminder.dateAndTime') }}
|
||||
</SimpleButton>
|
||||
</div>
|
||||
|
||||
<ReminderPeriod
|
||||
v-if="showFormSwitch === 'relative'"
|
||||
v-model="reminder"
|
||||
@update:modelValue="updateDataAndMaybeClose(toggle)"
|
||||
/>
|
||||
|
||||
<DatepickerInline
|
||||
v-if="showFormSwitch === 'absolute'"
|
||||
v-model="reminderDate"
|
||||
@update:modelValue="setReminderDate"
|
||||
/>
|
||||
|
||||
<x-button
|
||||
v-if="showFormSwitch !== null"
|
||||
class="reminder__close-button"
|
||||
:shadow="false"
|
||||
@click="toggle"
|
||||
>
|
||||
{{ $t('misc.confirm') }}
|
||||
</x-button>
|
||||
</Card>
|
||||
</template>
|
||||
</Popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch, type PropType} from 'vue'
|
||||
import {toRef} from '@vueuse/core'
|
||||
import {SECONDS_A_DAY} from '@/constants/date'
|
||||
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {PeriodUnit, secondsToPeriod} from '@/helpers/time/period'
|
||||
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
|
||||
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||
|
||||
import DatepickerInline from '@/components/input/datepickerInline.vue'
|
||||
import ReminderPeriod from '@/components/tasks/partials/reminder-period.vue'
|
||||
import Popup from '@/components/misc/popup.vue'
|
||||
|
||||
import TaskReminderModel from '@/models/taskReminder'
|
||||
import Card from '@/components/misc/card.vue'
|
||||
import SimpleButton from '@/components/input/SimpleButton.vue'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object as PropType<ITaskReminder>,
|
||||
required: false,
|
||||
},
|
||||
disabled: {
|
||||
default: false,
|
||||
},
|
||||
clearAfterUpdate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const reminder = ref<ITaskReminder>(new TaskReminderModel())
|
||||
|
||||
const presets: TaskReminderModel[] = [
|
||||
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY, relativeTo: REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE},
|
||||
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY * 3, relativeTo: REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE},
|
||||
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY * 7, relativeTo: REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE},
|
||||
{reminder: null, relativePeriod: -1 * SECONDS_A_DAY * 30, relativeTo: REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE},
|
||||
]
|
||||
const reminderDate = ref(null)
|
||||
|
||||
const showFormSwitch = ref<null | 'relative' | 'absolute'>(null)
|
||||
|
||||
const reminderText = computed(() => {
|
||||
|
||||
if (reminder.value.relativeTo !== null) {
|
||||
return formatReminder(reminder.value)
|
||||
}
|
||||
|
||||
if (reminder.value.reminder !== null) {
|
||||
return formatDateShort(reminder.value.reminder)
|
||||
}
|
||||
|
||||
return t('task.addReminder')
|
||||
})
|
||||
|
||||
const modelValue = toRef(props, 'modelValue')
|
||||
watch(
|
||||
modelValue,
|
||||
(newReminder) => {
|
||||
reminder.value = newReminder || new TaskReminderModel()
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function updateData() {
|
||||
emit('update:modelValue', reminder.value)
|
||||
|
||||
if (props.clearAfterUpdate) {
|
||||
reminder.value = new TaskReminderModel()
|
||||
}
|
||||
}
|
||||
|
||||
function setReminderDate() {
|
||||
reminder.value.reminder = reminderDate.value === null
|
||||
? null
|
||||
: new Date(reminderDate.value)
|
||||
reminder.value.relativeTo = null
|
||||
reminder.value.relativePeriod = 0
|
||||
updateData()
|
||||
}
|
||||
|
||||
function setReminderFromPreset(preset, toggle) {
|
||||
reminder.value = preset
|
||||
updateData()
|
||||
toggle()
|
||||
}
|
||||
|
||||
function updateDataAndMaybeClose(toggle) {
|
||||
updateData()
|
||||
if (props.clearAfterUpdate) {
|
||||
toggle()
|
||||
}
|
||||
}
|
||||
|
||||
function formatReminder(reminder: TaskReminderModel) {
|
||||
const period = secondsToPeriod(reminder.relativePeriod)
|
||||
|
||||
if (period.amount === 0) {
|
||||
switch (reminder.relativeTo) {
|
||||
case REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE:
|
||||
return t('task.reminder.onDueDate')
|
||||
case REMINDER_PERIOD_RELATIVE_TO_TYPES.STARTDATE:
|
||||
return t('task.reminder.onStartDate')
|
||||
case REMINDER_PERIOD_RELATIVE_TO_TYPES.ENDDATE:
|
||||
return t('task.reminder.onEndDate')
|
||||
}
|
||||
}
|
||||
|
||||
const amountAbs = Math.abs(period.amount)
|
||||
|
||||
let relativeTo = ''
|
||||
switch (reminder.relativeTo) {
|
||||
case REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE:
|
||||
relativeTo = t('task.attributes.dueDate')
|
||||
break
|
||||
case REMINDER_PERIOD_RELATIVE_TO_TYPES.STARTDATE:
|
||||
relativeTo = t('task.attributes.startDate')
|
||||
break
|
||||
case REMINDER_PERIOD_RELATIVE_TO_TYPES.ENDDATE:
|
||||
relativeTo = t('task.attributes.endDate')
|
||||
break
|
||||
}
|
||||
|
||||
if (reminder.relativePeriod <= 0) {
|
||||
return t('task.reminder.before', {
|
||||
amount: amountAbs,
|
||||
unit: translateUnit(amountAbs, period.unit),
|
||||
type: relativeTo,
|
||||
})
|
||||
}
|
||||
|
||||
return t('task.reminder.after', {
|
||||
amount: amountAbs,
|
||||
unit: translateUnit(amountAbs, period.unit),
|
||||
type: relativeTo,
|
||||
})
|
||||
}
|
||||
|
||||
function translateUnit(amount: number, unit: PeriodUnit): string {
|
||||
switch (unit) {
|
||||
case 'seconds':
|
||||
return t('time.units.seconds', amount)
|
||||
case 'minutes':
|
||||
return t('time.units.minutes', amount)
|
||||
case 'hours':
|
||||
return t('time.units.hours', amount)
|
||||
case 'days':
|
||||
return t('time.units.days', amount)
|
||||
case 'weeks':
|
||||
return t('time.units.weeks', amount)
|
||||
case 'months':
|
||||
return t('time.units.months', amount)
|
||||
case 'years':
|
||||
return t('time.units.years', amount)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
:deep(.popup) {
|
||||
top: unset;
|
||||
}
|
||||
|
||||
.reminder-options-popup {
|
||||
width: 310px;
|
||||
z-index: 99;
|
||||
|
||||
@media screen and (max-width: ($tablet)) {
|
||||
width: calc(100vw - 5rem);
|
||||
}
|
||||
|
||||
.option-button {
|
||||
font-size: .85rem;
|
||||
border-radius: 0;
|
||||
padding: .5rem;
|
||||
margin: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--grey-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reminder__close-button {
|
||||
margin: .5rem;
|
||||
width: calc(100% - 1rem);
|
||||
}
|
||||
|
||||
.currently-active {
|
||||
color: var(--primary);
|
||||
}
|
||||
</style>
|
122
src/components/tasks/partials/reminder-period.vue
Normal file
122
src/components/tasks/partials/reminder-period.vue
Normal file
@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div
|
||||
class="reminder-period control"
|
||||
>
|
||||
<input
|
||||
class="input"
|
||||
v-model.number="period.duration"
|
||||
type="number"
|
||||
min="0"
|
||||
@change="updateData"
|
||||
/>
|
||||
|
||||
<div class="select">
|
||||
<select v-model="period.durationUnit" @change="updateData">
|
||||
<option value="minutes">{{ $t('time.units.minutes', period.duration) }}</option>
|
||||
<option value="hours">{{ $t('time.units.hours', period.duration) }}</option>
|
||||
<option value="days">{{ $t('time.units.days', period.duration) }}</option>
|
||||
<option value="weeks">{{ $t('time.units.weeks', period.duration) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="select">
|
||||
<select v-model.number="period.sign" @change="updateData">
|
||||
<option value="-1">
|
||||
{{ $t('task.reminder.beforeShort') }}
|
||||
</option>
|
||||
<option value="1">
|
||||
{{ $t('task.reminder.afterShort') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="select">
|
||||
<select v-model="period.relativeTo" @change="updateData">
|
||||
<option :value="REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE">
|
||||
{{ $t('task.attributes.dueDate') }}
|
||||
</option>
|
||||
<option :value="REMINDER_PERIOD_RELATIVE_TO_TYPES.STARTDATE">
|
||||
{{ $t('task.attributes.startDate') }}
|
||||
</option>
|
||||
<option :value="REMINDER_PERIOD_RELATIVE_TO_TYPES.ENDDATE">
|
||||
{{ $t('task.attributes.endDate') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, type PropType} from 'vue'
|
||||
import {toRef} from '@vueuse/core'
|
||||
|
||||
import {periodToSeconds, PeriodUnit, secondsToPeriod} from '@/helpers/time/period'
|
||||
|
||||
import TaskReminderModel from '@/models/taskReminder'
|
||||
|
||||
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
|
||||
import {REMINDER_PERIOD_RELATIVE_TO_TYPES, type IReminderPeriodRelativeTo} from '@/types/IReminderPeriodRelativeTo'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object as PropType<ITaskReminder>,
|
||||
required: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const reminder = ref<ITaskReminder>(new TaskReminderModel())
|
||||
|
||||
interface PeriodInput {
|
||||
duration: number,
|
||||
durationUnit: PeriodUnit,
|
||||
relativeTo: IReminderPeriodRelativeTo,
|
||||
sign: -1 | 1,
|
||||
}
|
||||
|
||||
const period = ref<PeriodInput>({
|
||||
duration: 0,
|
||||
durationUnit: 'hours',
|
||||
relativeTo: REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE,
|
||||
sign: -1,
|
||||
})
|
||||
|
||||
const modelValue = toRef(props, 'modelValue')
|
||||
watch(
|
||||
modelValue,
|
||||
(value) => {
|
||||
const p = secondsToPeriod(value?.relativePeriod)
|
||||
period.value.durationUnit = p.unit
|
||||
period.value.duration = p.amount
|
||||
period.value.relativeTo = value?.relativeTo || REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function updateData() {
|
||||
reminder.value.relativePeriod = period.value.sign * periodToSeconds(period.value.duration, period.value.durationUnit)
|
||||
reminder.value.relativeTo = period.value.relativeTo
|
||||
reminder.value.reminder = null
|
||||
|
||||
emit('update:modelValue', reminder.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.reminder-period {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .25rem;
|
||||
padding: .5rem .5rem 0;
|
||||
|
||||
.input, .select select {
|
||||
width: 100% !important;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
26
src/components/tasks/partials/reminders.story.vue
Normal file
26
src/components/tasks/partials/reminders.story.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import reminders from './reminders.vue'
|
||||
import {ref} from 'vue'
|
||||
import ReminderDetail from '@/components/tasks/partials/reminder-detail.vue'
|
||||
|
||||
const reminderNow = ref({reminder: new Date(), relativePeriod: 0, relativeTo: null } )
|
||||
const relativeReminder = ref({reminder: null, relativePeriod: 1, relativeTo: 'due_date' } )
|
||||
const newReminder = ref(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story>
|
||||
<Variant title="Default">
|
||||
<reminders/>
|
||||
</Variant>
|
||||
<Variant title="Reminder Detail with fixed date">
|
||||
<reminder-detail v-model="reminderNow"/>
|
||||
</Variant>
|
||||
<Variant title="Reminder Detail with relative date">
|
||||
<reminder-detail v-model="relativeReminder"/>
|
||||
</Variant>
|
||||
<Variant title="New Reminder Detail">
|
||||
<reminder-detail v-model="newReminder"/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
@ -3,104 +3,70 @@
|
||||
<div
|
||||
v-for="(r, index) in reminders"
|
||||
:key="index"
|
||||
:class="{ 'overdue': r < new Date()}"
|
||||
:class="{ 'overdue': r.reminder < new Date() }"
|
||||
class="reminder-input"
|
||||
>
|
||||
<Datepicker
|
||||
v-model="reminders[index]"
|
||||
<ReminderDetail
|
||||
class="reminder-detail"
|
||||
:disabled="disabled"
|
||||
@close-on-change="() => addReminderDate(index)"
|
||||
/>
|
||||
<BaseButton @click="removeReminderByIndex(index)" v-if="!disabled" class="remove">
|
||||
<icon icon="times"></icon>
|
||||
v-model="reminders[index]"
|
||||
@update:model-value="updateData"/>
|
||||
<BaseButton
|
||||
v-if="!disabled"
|
||||
@click="removeReminderByIndex(index)"
|
||||
class="remove"
|
||||
>
|
||||
<icon icon="times"/>
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div class="reminder-input" v-if="!disabled">
|
||||
<Datepicker
|
||||
v-model="newReminder"
|
||||
@close-on-change="() => addReminderDate()"
|
||||
:choose-date-label="$t('task.addReminder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ReminderDetail
|
||||
:disabled="disabled"
|
||||
@update:modelValue="addNewReminder"
|
||||
:clear-after-update="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {type PropType, ref, onMounted, watch} from 'vue'
|
||||
import {ref, watch, type PropType} from 'vue'
|
||||
|
||||
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Datepicker from '@/components/input/datepicker.vue'
|
||||
|
||||
type Reminder = Date | string
|
||||
|
||||
|
||||
import ReminderDetail from '@/components/tasks/partials/reminder-detail.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<Reminder[]>,
|
||||
type: Array as PropType<ITaskReminder[]>,
|
||||
default: () => [],
|
||||
validator(prop) {
|
||||
// This allows arrays of Dates and strings
|
||||
if (!(prop instanceof Array)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isDate = (e: unknown) => e instanceof Date
|
||||
const isString = (e: unknown) => typeof e === 'string'
|
||||
|
||||
for (const e of prop) {
|
||||
if (!isDate(e) && !isString(e)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const reminders = ref<Reminder[]>([])
|
||||
|
||||
onMounted(() => {
|
||||
reminders.value = [...props.modelValue]
|
||||
})
|
||||
const reminders = ref<ITaskReminder[]>([])
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
props.modelValue,
|
||||
(newVal) => {
|
||||
for (const i in newVal) {
|
||||
if (typeof newVal[i] === 'string') {
|
||||
newVal[i] = new Date(newVal[i])
|
||||
}
|
||||
}
|
||||
reminders.value = newVal
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
|
||||
function updateData() {
|
||||
emit('update:modelValue', reminders.value)
|
||||
}
|
||||
|
||||
const newReminder = ref(null)
|
||||
function addReminderDate(index : number | null = null) {
|
||||
// New Date
|
||||
if (index === null) {
|
||||
if (newReminder.value === null) {
|
||||
return
|
||||
}
|
||||
reminders.value.push(new Date(newReminder.value))
|
||||
newReminder.value = null
|
||||
} else if(reminders.value[index] === null) {
|
||||
function addNewReminder(newReminder: ITaskReminder) {
|
||||
if (newReminder === null) {
|
||||
return
|
||||
}
|
||||
|
||||
reminders.value.push(newReminder)
|
||||
updateData()
|
||||
}
|
||||
|
||||
@ -111,23 +77,27 @@ 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;
|
||||
}
|
||||
}
|
||||
.reminder-detail {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.remove {
|
||||
color: var(--danger);
|
||||
vertical-align: top;
|
||||
padding-left: .5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user