From d1b12f8654f6eb725fb42b0c1e1e4a42de2866d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Hornych?= Date: Tue, 29 Oct 2024 23:11:22 +0100 Subject: [PATCH] fix(#219): cannot specify specific time for catalog backup --- .../components/BackupCatalogDialog.vue | 56 ++-- src/modules/base/component/VDateTimeInput.vue | 275 ++++++++++++++++++ .../base/component/VTimeOffsetPicker.vue | 72 +++++ .../driver/grpc/EvitaDBDriverGrpc.ts | 4 +- .../grpc/service/EvitaValueConverter.ts | 14 +- .../grpc/service/ServerFileConverter.ts | 4 +- .../grpc/service/TaskStatusConverter.ts | 8 +- .../model/data-type/OffsetDateTime.ts | 21 +- src/modules/i18n/en.json | 18 ++ src/styles/settings.scss | 4 + src/vue-plugins/vuetify.ts | 18 ++ 11 files changed, 447 insertions(+), 47 deletions(-) create mode 100644 src/modules/base/component/VDateTimeInput.vue create mode 100644 src/modules/base/component/VTimeOffsetPicker.vue diff --git a/src/modules/backup-viewer/components/BackupCatalogDialog.vue b/src/modules/backup-viewer/components/BackupCatalogDialog.vue index c499a44..3c8a221 100644 --- a/src/modules/backup-viewer/components/BackupCatalogDialog.vue +++ b/src/modules/backup-viewer/components/BackupCatalogDialog.vue @@ -4,13 +4,13 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import VFormDialog from '@/modules/base/component/VFormDialog.vue' import { DateTime } from 'luxon' -import { Timestamp } from '@bufbuild/protobuf' import { BackupViewerService, useBackupViewerService } from '@/modules/backup-viewer/service/BackupViewerService' import { Connection } from '@/modules/connection/model/Connection' import { Toaster, useToaster } from '@/modules/notification/service/Toaster' import { CatalogVersionAtResponse } from '@/modules/connection/model/CatalogVersionAtResponse' import Immutable from 'immutable' import { Catalog } from '@/modules/connection/model/Catalog' +import VDateTimeInput from '@/modules/base/component/VDateTimeInput.vue' const backupViewerService: BackupViewerService = useBackupViewerService() const toaster: Toaster = useToaster() @@ -36,20 +36,24 @@ watch( const availableCatalogs = ref([]) const availableCatalogsLoaded = ref(false) -const minimalDate = ref() -const minimalDateLoaded = ref(false) +const minDate = ref() +const minDateLoaded = ref(false) +const maxDate = ref() +const maxDateLoaded = ref(false) +const defaultTimeOffset = ref('+00:00') +const defaultTimeOffsetLoaded = ref(false) const catalogName = ref(undefined) watch(catalogName, async () => { - minimalDateLoaded.value = false + minDateLoaded.value = false pastMoment.value = undefined if (catalogName.value != undefined && catalogName.value.trim().length > 0) { await loadMinimalDate() } else { - minimalDate.value = undefined + minDate.value = undefined } }) -const pastMoment = ref() +const pastMoment = ref() const includeWal = ref(false) const changed = computed(() => @@ -89,8 +93,15 @@ async function loadMinimalDate(): Promise { props.connection, catalogName.value! ) - minimalDate.value = minimalBackupDate.introducedAt.toString() - minimalDateLoaded.value = true + + minDate.value = minimalBackupDate.introducedAt.toDateTime() + minDateLoaded.value = true + + maxDate.value = DateTime.now() + maxDateLoaded.value = true + + defaultTimeOffset.value = minimalBackupDate.introducedAt.offset + defaultTimeOffsetLoaded.value = true } catch (e: any) { toaster.error(t( 'backupViewer.backup.notification.couldNotLoadMinimalDate', @@ -100,30 +111,20 @@ async function loadMinimalDate(): Promise { } function reset(): void { - catalogName.value = '' - pastMoment.value = '' + catalogName.value = undefined + pastMoment.value = undefined includeWal.value = false } -function convertPastMoment(): OffsetDateTime | undefined { - if (pastMoment.value === undefined) { - return undefined - } - // todo lho simplify - // todo lho verify date data type - const jsDate = new Date(pastMoment.value!) - const offsetDateTime: DateTime = DateTime.fromJSDate(jsDate) - const timestamp: Timestamp = Timestamp.fromDate(jsDate) - return new OffsetDateTime(timestamp, offsetDateTime.toFormat('ZZ')) -} - async function backup(): Promise { try { await backupViewerService.backupCatalog( props.connection, catalogName.value!, includeWal.value, - convertPastMoment() + pastMoment.value != undefined + ? OffsetDateTime.fromDateTime(pastMoment.value) + : undefined ) toaster.success(t( 'backupViewer.backup.notification.backupRequested', @@ -174,12 +175,13 @@ async function backup(): Promise { :disabled="!availableCatalogsLoaded" required /> - + +import { useI18n } from 'vue-i18n' +import { computed, ref, watch } from 'vue' +import { DateTime } from 'luxon' +import VTimeOffsetPicker from '@/modules/base/component/VTimeOffsetPicker.vue' +import { Toaster, useToaster } from '@/modules/notification/service/Toaster' + +enum Step { + Date = 0, + Time = 1, + TimeOffset = 2 +} + +const toaster: Toaster = useToaster() +const { t } = useI18n() + +const props = withDefaults( + defineProps<{ + modelValue?: DateTime, + label?: string, + disabled?: boolean, + defaultTimeOffset?: string, + min?: DateTime, + max?: DateTime + }>(), + { + modelValue: undefined, + label: undefined, + disabled: false, + defaultTimeOffset: '+00:00' + } +) +const emit = defineEmits<{ + (e: 'update:modelValue', value: DateTime): void +}>() + +const showMenu = ref(false) +watch(showMenu, (newValue) => { + if (!newValue) { + currentStep.value = Step.Date + } +}) + +const currentStep = ref(Step.Date) +const canGoNextStep = computed(() => { + switch (currentStep.value) { + case Step.Date: return date.value != undefined + case Step.Time: return time.value != undefined && time.value.length > 0 + default: return false + } +}) + +function goToPreviousStep(): void { + if (currentStep.value > Step.Date) { + currentStep.value-- + } +} + +function goToNextStep(): void { + if (currentStep.value < Step.TimeOffset) { + currentStep.value++ + } +} + +const timeOffset = ref(props.defaultTimeOffset) +watch( + () => props.defaultTimeOffset, + () => timeOffset.value = props.defaultTimeOffset, + { immediate: true } +) + +const date = ref() +const isoDate = computed(() => { + if (date.value == undefined) { + return undefined + } + return `${date.value.getFullYear()}-${String(date.value.getMonth() + 1).padStart(2, '0')}-${String(date.value.getDate()).padStart(2, '0')}` +}) +watch(date, (newValue) => { + if (newValue != undefined) { + currentStep.value = Step.Time + } +}) +const minDate = computed(() => { + if (props.min == undefined) { + return undefined + } + return props.min + .setZone(timeOffset.value) // we need the date in picker's offset, not in the inputted one + .toISODate()! +}) +const maxDate = computed(() => { + if (props.max == undefined) { + return undefined + } + return props.max + .setZone(timeOffset.value) // we need the date in picker's offset, not in the inputted one + .toISODate()! +}) + +const time = ref('') +watch(time, (newValue) => { + if (newValue != undefined && newValue.length > 0) { + currentStep.value = Step.TimeOffset + } +}) +const minTime = computed(() => { + if (isoDate.value == undefined) { + return undefined + } + if (props.min == undefined) { + return undefined + } + if (isoDate.value !== minDate.value) { + return undefined + } + return props.min + .setZone(timeOffset.value) + .toISOTime({ + suppressMilliseconds: true, + includeOffset: false + })! +}) +const maxTime = computed(() => { + if (isoDate.value == undefined) { + return undefined + } + if (props.max == undefined) { + return undefined + } + if (isoDate.value !== maxDate.value) { + return undefined + } + return props.max + .setZone(timeOffset.value) + .toISOTime({ + suppressMilliseconds: true, + includeOffset: false + })! +}) + +const computedOffsetDateTime = computed(() => { + if (isoDate.value == undefined) { + return undefined + } + if (time.value == undefined || time.value.length === 0) { + return undefined + } + if (timeOffset.value == undefined || timeOffset.value.length === 0) { + return undefined + } + + const datePart: string = isoDate.value + const timePart: string = time.value + const timeOffsetPart: string = timeOffset.value + const rawOffsetDateTime: string = `${datePart}T${timePart}${timeOffsetPart}` + + return DateTime.fromISO(rawOffsetDateTime) + .setZone(timeOffset.value) // a little bit of hack to not use default device locale +}) +const displayedOffsetDateTime = ref('') + +function confirm(): void { + if (computedOffsetDateTime.value == undefined) { + throw new Error('Missing offset date time.') + } + + const offsetDateTime: DateTime = computedOffsetDateTime.value + if (props.min != undefined && offsetDateTime < props.min) { + toaster.error(t('common.input.dateTime.error.olderThanMin')) + currentStep.value = Step.Date + return + } + if (props.max != undefined && offsetDateTime > props.max) { + toaster.error(t('common.input.dateTime.error.newerThanMax')) + currentStep.value = Step.Date + return + } + + displayedOffsetDateTime.value = computedOffsetDateTime.value + .toLocaleString(DateTime.DATETIME_FULL) + showMenu.value = false + emit('update:modelValue', offsetDateTime) +} + + + + + diff --git a/src/modules/base/component/VTimeOffsetPicker.vue b/src/modules/base/component/VTimeOffsetPicker.vue new file mode 100644 index 0000000..02b10b0 --- /dev/null +++ b/src/modules/base/component/VTimeOffsetPicker.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/src/modules/connection/driver/grpc/EvitaDBDriverGrpc.ts b/src/modules/connection/driver/grpc/EvitaDBDriverGrpc.ts index 0bef255..d77cbd2 100644 --- a/src/modules/connection/driver/grpc/EvitaDBDriverGrpc.ts +++ b/src/modules/connection/driver/grpc/EvitaDBDriverGrpc.ts @@ -514,8 +514,8 @@ export class EvitaDBDriverGrpc implements EvitaDBDriver { return new CatalogVersionAtResponse( result.version, new OffsetDateTime( - result.introducedAt?.timestamp, - result.introducedAt?.offset + result.introducedAt!.timestamp!, + result.introducedAt!.offset ) ) } diff --git a/src/modules/connection/driver/grpc/service/EvitaValueConverter.ts b/src/modules/connection/driver/grpc/service/EvitaValueConverter.ts index ab3e582..d640478 100644 --- a/src/modules/connection/driver/grpc/service/EvitaValueConverter.ts +++ b/src/modules/connection/driver/grpc/service/EvitaValueConverter.ts @@ -216,8 +216,8 @@ export class EvitaValueConverter { throw new Error('DateTimeRange has undefined prop from and to') else return new DateTimeRange( - new OffsetDateTime(value.from?.timestamp, value.from?.offset), - new OffsetDateTime(value.to?.timestamp, value.to?.offset) + new OffsetDateTime(value.from!.timestamp!, value.from!.offset), + new OffsetDateTime(value.to!.timestamp!, value.to!.offset) ) } @@ -319,7 +319,7 @@ export class EvitaValueConverter { ): Immutable.List { const offsetDateTimeArray: OffsetDateTime[] = [] for (const grpcDateTime of value.value) { - offsetDateTimeArray.push(this.convertLocalDateTime(grpcDateTime)) + offsetDateTimeArray.push(this.convertOffsetDateTime(grpcDateTime)) } return Immutable.List(offsetDateTimeArray) } @@ -369,12 +369,12 @@ export class EvitaValueConverter { dateTimeRange.push( new DateTimeRange( new OffsetDateTime( - grpcDateTimeRange.from?.timestamp, - grpcDateTimeRange.from?.offset + grpcDateTimeRange.from!.timestamp!, + grpcDateTimeRange.from!.offset ), new OffsetDateTime( - grpcDateTimeRange.to?.timestamp, - grpcDateTimeRange.to?.offset + grpcDateTimeRange.to!.timestamp!, + grpcDateTimeRange.to!.offset ) ) ) diff --git a/src/modules/connection/driver/grpc/service/ServerFileConverter.ts b/src/modules/connection/driver/grpc/service/ServerFileConverter.ts index 95cd084..0dadf41 100644 --- a/src/modules/connection/driver/grpc/service/ServerFileConverter.ts +++ b/src/modules/connection/driver/grpc/service/ServerFileConverter.ts @@ -36,8 +36,8 @@ export class ServerFileConverter { grpcFile.contentType, grpcFile.totalSizeInBytes, new OffsetDateTime( - grpcFile.created?.timestamp, - grpcFile.created?.offset + grpcFile.created!.timestamp!, + grpcFile.created!.offset ), grpcFile.origin! ) diff --git a/src/modules/connection/driver/grpc/service/TaskStatusConverter.ts b/src/modules/connection/driver/grpc/service/TaskStatusConverter.ts index e91c57d..0009c9f 100644 --- a/src/modules/connection/driver/grpc/service/TaskStatusConverter.ts +++ b/src/modules/connection/driver/grpc/service/TaskStatusConverter.ts @@ -59,24 +59,24 @@ export class TaskStatusConverter { ), grpcTaskStatus.catalogName, new OffsetDateTime( - grpcTaskStatus.created!.timestamp, + grpcTaskStatus.created!.timestamp!, grpcTaskStatus.created!.offset ), grpcTaskStatus.issued != undefined ? new OffsetDateTime( - grpcTaskStatus.issued!.timestamp, + grpcTaskStatus.issued!.timestamp!, grpcTaskStatus.issued!.offset ) : undefined, grpcTaskStatus.started != undefined ? new OffsetDateTime( - grpcTaskStatus.started.timestamp, + grpcTaskStatus.started.timestamp!, grpcTaskStatus.started.offset ) : undefined, grpcTaskStatus.finished != undefined ? new OffsetDateTime( - grpcTaskStatus.finished?.timestamp, + grpcTaskStatus.finished?.timestamp!, grpcTaskStatus.finished?.offset ) : undefined, diff --git a/src/modules/connection/model/data-type/OffsetDateTime.ts b/src/modules/connection/model/data-type/OffsetDateTime.ts index 4c5039e..83e2b8f 100644 --- a/src/modules/connection/model/data-type/OffsetDateTime.ts +++ b/src/modules/connection/model/data-type/OffsetDateTime.ts @@ -8,16 +8,22 @@ const offsetDateTimeFormatter = new Intl.DateTimeFormat([], { }) //TODO add doc -// todo lho we should try to replace this with luxon's DateTime export class OffsetDateTime implements PrettyPrintable { - readonly timestamp?: Timestamp - readonly offset?: string + readonly timestamp: Timestamp + readonly offset: string - // todo lho this is not optional - constructor(timestamp?: Timestamp, offset?: string) { + // todo lho refactor usages to single convert method? + constructor(timestamp: Timestamp, offset: string) { this.timestamp = timestamp this.offset = offset } + + static fromDateTime(dateTime: DateTime): OffsetDateTime { + const timestamp: Timestamp = Timestamp.fromDate(dateTime.toJSDate()) + const offset: string = dateTime.zoneName! + return new OffsetDateTime(timestamp, offset) + } + getPrettyPrintableString(): string { // todo lho verify this, i think the final date should contain the offset as well return `${offsetDateTimeFormatter.format(this.timestamp?.toDate())}` @@ -28,6 +34,11 @@ export class OffsetDateTime implements PrettyPrintable { return new OffsetDateTime(timestamp, offset) } + toDateTime(): DateTime { + const dateTime: DateTime = DateTime.fromSeconds(Number(this.timestamp.seconds)) + return dateTime.setZone(this.offset) + } + toString():string{ return DateTime.fromSeconds(Number(this.timestamp?.seconds), {zone: this.offset }).toISO({includeOffset: true}) } diff --git a/src/modules/i18n/en.json b/src/modules/i18n/en.json index 82176b2..5c021f2 100644 --- a/src/modules/i18n/en.json +++ b/src/modules/i18n/en.json @@ -11,6 +11,8 @@ "no": "No" }, "button": { + "previous": "Previous", + "next": "Next", "close": "Close", "cancel": "Cancel", "remove": "Remove", @@ -47,6 +49,22 @@ "title": "Dangerous operation", "message": "Warning! You are about to do dangerous operation. Are you sure you want to proceed?" } + }, + "input": { + "dateTime": { + "timeOffset": { + "title": "Select time offset", + "hours": "Hours", + "minutes": "Minutes" + }, + "help": { + "timeOffset": "in time offset of {offset}" + }, + "error": { + "olderThanMin": "Date time is older than the minimum allowed.", + "newerThanMax": "Date time is newer than the maximum allowed." + } + } } }, "panel": { diff --git a/src/styles/settings.scss b/src/styles/settings.scss index 73ca81e..b433499 100644 --- a/src/styles/settings.scss +++ b/src/styles/settings.scss @@ -253,3 +253,7 @@ body::after { color: var(--el-color-primary-lightest); } } + +.v-time-picker-clock { + background-color: $gray-dark; +} \ No newline at end of file diff --git a/src/vue-plugins/vuetify.ts b/src/vue-plugins/vuetify.ts index f801473..abcd4fa 100644 --- a/src/vue-plugins/vuetify.ts +++ b/src/vue-plugins/vuetify.ts @@ -10,12 +10,18 @@ import 'vuetify/styles' // Composables import { VDateInput } from 'vuetify/labs/VDateInput' +import { VTimePicker } from 'vuetify/labs/VTimePicker' +import { VNumberInput } from 'vuetify/labs/VNumberInput' +import { VPicker } from 'vuetify/labs/VPicker' import { createVuetify } from 'vuetify' // https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides export default createVuetify({ components: { VDateInput, + VTimePicker, + VNumberInput, + VPicker }, theme: { defaultTheme: 'dark', @@ -84,6 +90,18 @@ export default createVuetify({ variant: 'solo-filled', density: 'compact' }, + VDateInput: { + variant: 'solo-filled', + density: 'compact', + elevation: 6 + }, + VNumberInput: { + variant: 'solo-filled', + density: 'compact', + VBtn: { + variant: 'flat' + } + }, VList: { density: 'compact' },