From 91b21878ef3dad423f0efff4c786ca4453899a27 Mon Sep 17 00:00:00 2001 From: Tobias Kuhnert <tobias.kuhnert@ufz.de> Date: Fri, 4 Jun 2021 07:37:27 +0000 Subject: [PATCH] Implemented DateTimePicker component and tests --- components/DateTimePicker.vue | 296 ++++++++++++++++++++ test/DateTimePicker.test.ts | 496 ++++++++++++++++++++++++++++++++++ 2 files changed, 792 insertions(+) create mode 100644 components/DateTimePicker.vue create mode 100644 test/DateTimePicker.test.ts diff --git a/components/DateTimePicker.vue b/components/DateTimePicker.vue new file mode 100644 index 000000000..c9a1c7515 --- /dev/null +++ b/components/DateTimePicker.vue @@ -0,0 +1,296 @@ +<!-- +Web client of the Sensor Management System software developed within the +Helmholtz DataHub Initiative by GFZ and UFZ. + +Copyright (C) 2020, 2021 +- Nils Brinckmann (GFZ, nils.brinckmann@gfz-potsdam.de) +- Marc Hanisch (GFZ, marc.hanisch@gfz-potsdam.de) +- Tobias Kuhnert (UFZ, tobias.kuhnert@ufz.de) +- Helmholtz Centre Potsdam - GFZ German Research Centre for + Geosciences (GFZ, https://www.gfz-potsdam.de) + +Parts of this program were developed within the context of the +following publicly funded projects or measures: +- Helmholtz Earth and Environment DataHub + (https://www.helmholtz.de/en/research/earth_and_environment/initiatives/#h51095) + +Licensed under the HEESIL, Version 1.0 or - as soon they will be +approved by the "Community" - subsequent versions of the HEESIL +(the "Licence"). + +You may not use this work except in compliance with the Licence. + +You may obtain a copy of the Licence at: +https://gitext.gfz-potsdam.de/software/heesil + +Unless required by applicable law or agreed to in writing, software +distributed under the Licence is distributed on an "AS IS" basis, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the Licence for the specific language governing +permissions and limitations under the Licence. +--> +<template> + <v-text-field + :value="valueAsDateTimeString" + :label="label" + :rules="textInputRules" + @input="updateByTextfield" + > + <template #append-outer> + <v-btn icon @click="initPicker"> + <v-icon>mdi-calendar-range</v-icon> + </v-btn> + <v-dialog + v-model="dialog" + :width="dialogWidth" + @click:outside="closePicker" + > + <v-card> + <v-card-text class="text-center"> + <v-tabs v-model="activeTab" fixed-tabs> + <v-tab v-if="isDateUsed" key="calendar"> + <slot name="dateIcon"> + <v-icon>mdi-calendar-outline</v-icon> + </slot> + </v-tab> + <v-tab v-if="isTimeUsed" key="time"> + <slot name="timeIcon"> + <v-icon>mdi-clock-outline</v-icon> + </slot> + </v-tab> + <v-tab-item v-if="isDateUsed" key="calendar"> + <v-date-picker + :value="datePickerValue" + class="height-adjustment" + @input="setDatePickerValue" + /> + </v-tab-item> + <v-tab-item v-if="isTimeUsed" key="time"> + <v-time-picker + :value="timePickerValue" + format="24hr" + class="height-adjustment" + @input="setTimePickerValue" + /> + </v-tab-item> + </v-tabs> + </v-card-text> + <v-card-actions> + <v-btn + small + text + @click="resetPicker" + > + Cancel + </v-btn> + <v-spacer /> + <v-btn + color="green" + small + @click="applyPickerValue" + > + Apply + </v-btn> + </v-card-actions> + </v-card> + </v-dialog> + </template> + </v-text-field> +</template> + +<script lang="ts"> + +import { Vue, Component, Prop } from 'nuxt-property-decorator' +import { DateTime } from 'luxon' + +const DEFAULT_DIALOG_WIDTH = 340 +const DEFAULT_DATETIME_FORMAT = 'yyyy-MM-dd HH:mm' +const DEFAULT_DATE_FORMAT = 'yyyy-MM-dd' +const DEFAULT_TIME_FORMAT = 'HH:mm' +@Component +// @ts-ignore +export default class DateTimePicker extends Vue { + @Prop({ type: Object, default: null }) value!: DateTime | null; + @Prop({ type: String, required: true }) label!: string + + @Prop({ type: Number, default: DEFAULT_DIALOG_WIDTH }) dialogWidth?: number; + + @Prop({ type: Boolean, default: false }) useDate!: boolean; + @Prop({ type: Boolean, default: false }) useTime!: boolean; + + @Prop({ default: () => [], type: Array }) readonly rules!: []; + + private isDatetimeUsed: boolean = true; + private usesDate: boolean = false; + private usesTime: boolean = false; + + private dialog: boolean = false; + private activeTab: number = 0; + + private optsZone = { zone: 'UTC' }; + + private textInput: string = ''; + + private currentFormat: string = DEFAULT_DATETIME_FORMAT; + + private datePickerValue: string = ''; + private timePickerValue: string = ''; + + created () { + this.usesDate = this.useDate + this.usesTime = this.useTime + + if (this.usesDate || this.usesTime) { + this.isDatetimeUsed = false + } + + if (this.usesDate && this.usesTime) { + this.isDatetimeUsed = true + this.usesDate = false + this.usesTime = false + } + + if (this.usesDate) { + this.currentFormat = DEFAULT_DATE_FORMAT + } + if (this.usesTime) { + this.currentFormat = DEFAULT_TIME_FORMAT + } + } + + get isTimeUsed () { + return this.isDatetimeUsed || this.usesTime + } + + get isDateUsed () { + return this.isDatetimeUsed || this.usesDate + } + + get valueAsDateTimeString (): string { + if (this.value) { + this.setTextInputByValue(this.value) + } + return this.textInput + } + + get datePart (): string { + if (this.isValueValidByCurrentFormat(this.textInput)) { + return this.parseToCurrentFormat().toFormat(DEFAULT_DATE_FORMAT) + } + return new Date().toISOString().substr(0, 10) + } + + get timePart (): string { + if (this.isValueValidByCurrentFormat(this.textInput)) { + return this.parseToCurrentFormat().toFormat(DEFAULT_TIME_FORMAT) + } + return '00:00' + } + + setTextInputByValue (datetimeValue: DateTime | null) { + if (datetimeValue) { + this.textInput = datetimeValue.setZone('UTC').toFormat(this.currentFormat) + } else { + this.textInput = '' + } + } + + setDatePickerValue (value: string) { + this.datePickerValue = value + } + + setTimePickerValue (value: string) { + this.timePickerValue = value + } + + resetPickerValues () { + this.setTimePickerValue('') + this.setDatePickerValue('') + } + + initPicker () { + this.dialog = true + this.initDateAndTimePickerValues() + } + + initDateAndTimePickerValues () { + this.datePickerValue = this.datePart + this.timePickerValue = this.timePart + } + + resetPicker () { + this.resetPickerValues() + this.closePicker() + } + + closePicker () { + this.dialog = false + this.activeTab = 0 + } + + getPickerValue ():string { + if (this.isDatetimeUsed) { + return this.datePickerValue + ' ' + this.timePickerValue + } + if (this.usesDate) { + return this.datePickerValue + } + if (this.usesTime) { + return this.timePickerValue + } + return '' + } + + applyPickerValue () { + const value = this.getPickerValue() + this.updateByTextfield(value) + this.closePicker() + this.resetPickerValues() + } + + parseToCurrentFormat () { + return DateTime.fromFormat(this.textInput, this.currentFormat, this.optsZone) + } + + updateByTextfield (newTextValue: string) { + this.textInput = newTextValue + if (this.isValueValidByCurrentFormat(this.textInput)) { + this.emitDateTimeObject() + } else { + this.emitValue(null) + } + } + + emitDateTimeObject () { + const newValue = this.parseToCurrentFormat() + this.emitValue(newValue) + } + + emitValue (newValue: DateTime | null) { + this.$emit('input', newValue) + } + + isValueValidByCurrentFormat (value: string): boolean { + return DateTime.fromFormat(value, this.currentFormat).isValid + } + + get textInputRules () { + let rulesList: ((value: string) => string | boolean)[] = [] + if (this.rules.length > 0) { + rulesList = rulesList.concat(this.rules) + } + + const textInputRule = (v: string) => { + return this.isValueValidByCurrentFormat(v) || `Please use the format: ${this.currentFormat}` + } + rulesList.push(textInputRule) + return rulesList + } +} +</script> + +<style> +.height-adjustment { + min-height: 392px; +} +</style> diff --git a/test/DateTimePicker.test.ts b/test/DateTimePicker.test.ts new file mode 100644 index 000000000..ed72053f2 --- /dev/null +++ b/test/DateTimePicker.test.ts @@ -0,0 +1,496 @@ +import Vue from 'vue' + +import { mount } from '@vue/test-utils' + +// @ts-ignore +import DateTimePicker from '@/components/DateTimePicker' +import { DateTime } from 'luxon' +import Vuetify from 'vuetify' + +Vue.use(Vuetify) + +const factory = (options = {}) => { + const vuetify = new Vuetify() + return mount(DateTimePicker, { + vuetify, + ...options + }) +} + +describe('DatetimePicker.vue', () => { + let wrapper: any + + it('renders a vue instance', () => { + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null + } + }) + + expect(wrapper).toBeTruthy() + }) + + it('textfield displays the correct label', () => { + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null + } + }) + + const textfieldLabel = wrapper.find('.v-text-field__slot > label') + + expect(textfieldLabel.text()).toBe('Testlabel') + }) + + it('textfield displays nothing when value is null', () => { + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null + } + }) + expect(wrapper.find('input[type="text"]').element.value).toBe('') + }) + + it('textfield displays the correct date and time passed as DateTime-Object', () => { + const testDateTime = DateTime.fromISO('2021-01-20T20:12:00.000Z', { zone: 'UTC' }) + + wrapper = factory({ + propsData: { + label: 'Display date and time', + value: testDateTime + } + }) + + expect(wrapper.find('input[type="text"]').element.value).toBe('2021-01-20 20:12') + }) + + it('textfield displays the correct date passed as DateTime-Object', () => { + const testDateTime = DateTime.fromISO('2021-01-20T20:12:00.000Z', { zone: 'UTC' }) + + wrapper = factory({ + propsData: { + label: 'Display date and time', + value: testDateTime, + 'use-date': true + } + }) + + expect(wrapper.find('input[type="text"]').element.value).toBe('2021-01-20') + }) + + it('textfield displays the correct time passed as DateTime-Object', () => { + const testDateTime = DateTime.fromISO('2021-01-20T20:12:00.000Z', { zone: 'UTC' }) + + wrapper = factory({ + propsData: { + label: 'Display date and time', + value: testDateTime, + 'use-time': true + } + }) + + expect(wrapper.find('input[type="text"]').element.value).toBe('20:12') + }) + + it('displays correct error message for datetime format, when textfield contains value with wrong format', async () => { + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null + } + }) + + const textFieldInput = wrapper.find('input[type="text"]') + + await textFieldInput.setValue('2021-05-01') + + expect(wrapper.find('input[type="text"]').element.value).toBe('2021-05-01') + + const validationMessage = wrapper.find('div.v-messages__message') + + expect(validationMessage.text()).toBe('Please use the format: yyyy-MM-dd HH:mm') + }) + + it('displays correct error message for date format, when textfield contains value with wrong format', async () => { + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null, + 'use-date': true + } + }) + + const textFieldInput = wrapper.find('input[type="text"]') + + await textFieldInput.setValue('2021-05-011') + + expect(wrapper.find('input[type="text"]').element.value).toBe('2021-05-011') + + const validationMessage = wrapper.find('div.v-messages__message') + + expect(validationMessage.text()).toBe('Please use the format: yyyy-MM-dd') + }) + + it('displays correct error message for time format, when textfield contains value with wrong format', async () => { + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null, + 'use-time': true + } + }) + + const textFieldInput = wrapper.find('input[type="text"]') + + await textFieldInput.setValue('2021-05-01') + + expect(wrapper.find('input[type="text"]').element.value).toBe('2021-05-01') + + const validationMessage = wrapper.find('div.v-messages__message') + + expect(validationMessage.text()).toBe('Please use the format: HH:mm') + }) + + it('uses datetime when use-date and use-datime are both WRONGLY passed as props', () => { + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null, + 'use-time': true, + 'use-date': true + } + }) + + expect(wrapper.vm.isDatetimeUsed).toBeTruthy() + expect(wrapper.vm.usesDate).toBeFalsy() + expect(wrapper.vm.usesTime).toBeFalsy() + }) + + it('emits correct dateTime object when textInput is updated', async () => { + const expectedDateTime = DateTime.fromFormat('2020-05-01 12:12', 'yyyy-MM-dd HH:mm', { zone: 'UTC' }) + + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null + } + }) + + wrapper.vm.updateByTextfield('2020-05-01 12:12') + expect(wrapper.emitted('input')).toBeTruthy() + await wrapper.vm.$nextTick() + expect(wrapper.emitted().input[0]).toEqual([expectedDateTime]) + }) + + it('emits correct dateTime object when updated by date picker when value was null', async () => { + const expectedDateTime = DateTime.fromFormat('2020-05-01 00:00', 'yyyy-MM-dd HH:mm', { zone: 'UTC' }) + + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null + } + }) + + wrapper.vm.initDateAndTimePickerValues() + + wrapper.vm.setDatePickerValue('2020-05-01') + wrapper.vm.applyPickerValue() + expect(wrapper.emitted('input')).toBeTruthy() + await wrapper.vm.$nextTick() + expect(wrapper.emitted().input[0]).toEqual([expectedDateTime]) + }) + + it('emits correct dateTime object when updated by date picker when value was passed', async () => { + const testDateTime = DateTime.fromISO('2021-01-20T20:12:00.000Z', { zone: 'UTC' }) + const expectedDateTime = DateTime.fromFormat('2020-05-01 20:12', 'yyyy-MM-dd HH:mm', { zone: 'UTC' }) + + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: testDateTime + } + }) + + wrapper.vm.initDateAndTimePickerValues() + + wrapper.vm.setDatePickerValue('2020-05-01') + wrapper.vm.applyPickerValue() + expect(wrapper.emitted('input')).toBeTruthy() + await wrapper.vm.$nextTick() + expect(wrapper.emitted().input[0]).toEqual([expectedDateTime]) + }) + + it('emits null when updated by date picker and the date is not valid', async () => { + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null + } + }) + + wrapper.vm.initDateAndTimePickerValues() + + wrapper.vm.setDatePickerValue('2020-05-88') + wrapper.vm.applyPickerValue() + expect(wrapper.emitted('input')).toBeTruthy() + await wrapper.vm.$nextTick() + expect(wrapper.emitted().input[0]).toEqual([null]) + }) + + it('emits correct dateTime object when updated by time picker when value was null', async () => { + const expectedTime = '12:13' + + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null + } + }) + + wrapper.vm.initDateAndTimePickerValues() + + wrapper.vm.setTimePickerValue(expectedTime) + wrapper.vm.applyPickerValue() + expect(wrapper.emitted('input')).toBeTruthy() + await wrapper.vm.$nextTick() + + const actual = wrapper.emitted().input[0][0] + expect(actual.hour + ':' + actual.minute).toBe(expectedTime) + }) + + it('emits correct dateTime object when updated by time picker when value was passed', async () => { + const testDateTime = DateTime.fromISO('2021-01-20T20:12:00.000Z', { zone: 'UTC' }) + const expectedTime = '12:13' + + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: testDateTime + } + }) + + wrapper.vm.initDateAndTimePickerValues() + + wrapper.vm.setTimePickerValue(expectedTime) + wrapper.vm.applyPickerValue() + expect(wrapper.emitted('input')).toBeTruthy() + await wrapper.vm.$nextTick() + + const actual = wrapper.emitted().input[0][0] + expect(actual.hour + ':' + actual.minute).toBe(expectedTime) + }) + + it('emits null when updated by time picker and the time is not valid', async () => { + const invalidTime = '12:88' + + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null + } + }) + + wrapper.vm.initDateAndTimePickerValues() + + wrapper.vm.setTimePickerValue(invalidTime) + wrapper.vm.applyPickerValue() + + expect(wrapper.emitted('input')).toBeTruthy() + + await wrapper.vm.$nextTick() + expect(wrapper.emitted().input[0]).toEqual([null]) + }) + + it('setTextInputByValue sets textInput to empty string when null is passed', () => { + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null + } + }) + + wrapper.vm.setTextInputByValue(null) + expect(wrapper.vm.textInput).toBe('') + }) + + it('sets the correct values when picker is closed', () => { + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null + } + }) + + wrapper.vm.activeTab = 1 + wrapper.vm.display = true + + wrapper.vm.closePicker() + + expect(wrapper.vm.activeTab).toBe(0) + expect(wrapper.vm.dialog).toBe(false) + }) + + it('includes the provided rules', () => { + const testRuleNameRequired = (v:any) => !!v || 'Name is required' + + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null, + rules: [ + testRuleNameRequired + ] + } + }) + + expect(wrapper.vm.textInputRules).toContain(testRuleNameRequired) + }) + + it('picker initializes correct when value was null', () => { + // Fix to avoid following warning-------------------------- + // console.warn + // [Vuetify] Unable to locate target [data-app] + const app = document.createElement('div') + app.setAttribute('data-app', 'true') + document.body.append(app) + // -------------------------------------------------------- + + const expectedDate = new Date().toISOString().substr(0, 10) + const expectedTime = '00:00' + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null + } + }) + + wrapper.vm.initPicker() + + expect(wrapper.vm.dialog).toBeTruthy() + expect(wrapper.vm.datePickerValue).toBe(expectedDate) + expect(wrapper.vm.timePickerValue).toBe(expectedTime) + }) + + it('picker initializes correct when value was passed', () => { + // Fix to avoid following warning-------------------------- + // console.warn + // [Vuetify] Unable to locate target [data-app] + const app = document.createElement('div') + app.setAttribute('data-app', 'true') + document.body.append(app) + // -------------------------------------------------------- + + const testDateTime = DateTime.fromISO('2021-01-20T20:12:00.000Z', { zone: 'UTC' }) + + const expectedDate = '2021-01-20' + const expectedTime = '20:12' + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: testDateTime + } + }) + + wrapper.vm.initPicker() + + expect(wrapper.vm.dialog).toBeTruthy() + expect(wrapper.vm.datePickerValue).toBe(expectedDate) + expect(wrapper.vm.timePickerValue).toBe(expectedTime) + }) + + it('resetPicker sets the correct values', () => { + // Fix to avoid following warning-------------------------- + // console.warn + // [Vuetify] Unable to locate target [data-app] + const app = document.createElement('div') + app.setAttribute('data-app', 'true') + document.body.append(app) + // -------------------------------------------------------- + + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null + } + }) + + wrapper.vm.dialog = true + wrapper.vm.datePickerValue = '2021-05-05' + wrapper.vm.timePickerValue = '10:10' + wrapper.vm.activeTab = 1 + + expect(wrapper.vm.dialog).toBeTruthy() + expect(wrapper.vm.datePickerValue).toBe('2021-05-05') + expect(wrapper.vm.timePickerValue).toBe('10:10') + expect(wrapper.vm.activeTab).toBe(1) + + wrapper.vm.resetPicker() + + expect(wrapper.vm.dialog).toBeFalsy() + expect(wrapper.vm.datePickerValue).toBe('') + expect(wrapper.vm.timePickerValue).toBe('') + expect(wrapper.vm.activeTab).toBe(0) + }) + + it('emits correct dateTime object when using use-date and updated by date picker when value was null', async () => { + const expectedDateTime = DateTime.fromFormat('2020-05-01 00:00', 'yyyy-MM-dd HH:mm', { zone: 'UTC' }) + + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null, + 'use-date': true + } + }) + + wrapper.vm.initDateAndTimePickerValues() + + wrapper.vm.setDatePickerValue('2020-05-01') + wrapper.vm.applyPickerValue() + expect(wrapper.emitted('input')).toBeTruthy() + await wrapper.vm.$nextTick() + expect(wrapper.emitted().input[0]).toEqual([expectedDateTime]) + }) + + it('emits correct dateTime object when using use-time and updated by time picker when value was null', async () => { + const expectedTime = '12:13' + + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null, + 'use-time': true + } + }) + + wrapper.vm.initDateAndTimePickerValues() + + wrapper.vm.setTimePickerValue(expectedTime) + wrapper.vm.applyPickerValue() + expect(wrapper.emitted('input')).toBeTruthy() + await wrapper.vm.$nextTick() + + const actual = wrapper.emitted().input[0][0] + expect(actual.hour + ':' + actual.minute).toBe(expectedTime) + }) + + it('getPickerValue returns empty string when neither date, time nor datetime are true', () => { + wrapper = factory({ + propsData: { + label: 'Testlabel', + value: null, + 'use-time': true + } + }) + + wrapper.vm.isUseDatetime = false + wrapper.vm.isUseTime = false + wrapper.vm.isUseDateTime = false + + expect(wrapper.vm.getPickerValue()).toBe('') + }) +}) -- GitLab