refactor(date-converter): improved ux and layout

This commit is contained in:
Corentin Thomasset 2023-04-12 23:01:21 +02:00 committed by Corentin THOMASSET
parent 5fa811a583
commit fd9ab59172
7 changed files with 346 additions and 76 deletions

View file

@ -25,7 +25,15 @@ export type ValidationAttrs = {
validationStatus: string | undefined;
};
export function useValidation<T>({ source, rules }: { source: Ref<T>; rules: UseValidationRule<T>[] }) {
export function useValidation<T>({
source,
rules,
watch: watchRefs = [],
}: {
source: Ref<T>;
rules: UseValidationRule<T>[];
watch?: Ref<unknown>[];
}) {
const state = reactive<{
message: string;
status: undefined | 'error';
@ -42,7 +50,7 @@ export function useValidation<T>({ source, rules }: { source: Ref<T>; rules: Use
});
watch(
[source],
[source, ...watchRefs],
() => {
state.message = '';
state.status = undefined;

View file

@ -0,0 +1,33 @@
import { test, expect } from '@playwright/test';
test.describe('Date time converter - json to yaml', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/date-converter');
});
test('Has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Date-time converter - IT Tools');
});
test('Format is auto detected from a date and the date is correctly converted', async ({ page }) => {
const initialFormat = await page.getByTestId('date-time-converter-format-select').innerText();
expect(initialFormat.trim()).toEqual('Timestamp');
await page.getByTestId('date-time-converter-input').fill('2023-04-12T23:10:24+02:00');
const detectedFormat = await page.getByTestId('date-time-converter-format-select').innerText();
expect(detectedFormat.trim()).toEqual('ISO 8601');
expect((await page.getByTestId('JS locale date string').inputValue()).trim()).toEqual(
'Wed Apr 12 2023 23:10:24 GMT+0200 (Central European Summer Time)',
);
expect((await page.getByTestId('ISO 8601').inputValue()).trim()).toEqual('2023-04-12T23:10:24+02:00');
expect((await page.getByTestId('ISO 9075').inputValue()).trim()).toEqual('2023-04-12 23:10:24');
expect((await page.getByTestId('Unix timestamp').inputValue()).trim()).toEqual('1681333824');
expect((await page.getByTestId('RFC 7231').inputValue()).trim()).toEqual('Wed, 12 Apr 2023 21:10:24 GMT');
expect((await page.getByTestId('RFC 3339').inputValue()).trim()).toEqual('2023-04-12T23:10:24+02:00');
expect((await page.getByTestId('Timestamp').inputValue()).trim()).toEqual('1681333824000');
expect((await page.getByTestId('UTC format').inputValue()).trim()).toEqual('Wed, 12 Apr 2023 21:10:24 GMT');
expect((await page.getByTestId('Mongo ObjectID').inputValue()).trim()).toEqual('64371e400000000000000000');
});
});

View file

@ -0,0 +1,142 @@
import { describe, test, expect } from 'vitest';
import {
isISO8601DateTimeString,
isISO9075DateString,
isRFC3339DateString,
isRFC7231DateString,
isUnixTimestamp,
isTimestamp,
isUTCDateString,
isMongoObjectId,
} from './date-time-converter.models';
describe('date-time-converter models', () => {
describe('isISO8601DateTimeString', () => {
test('should return true for valid ISO 8601 date strings', () => {
expect(isISO8601DateTimeString('2021-01-01T00:00:00.000Z')).toBe(true);
expect(isISO8601DateTimeString('2023-04-12T14:56:00+01:00')).toBe(true);
expect(isISO8601DateTimeString('20230412T145600+0100')).toBe(true);
expect(isISO8601DateTimeString('20230412T145600Z')).toBe(true);
expect(isISO8601DateTimeString('2016-02-01')).toBe(true);
expect(isISO8601DateTimeString('2016')).toBe(true);
});
test('should return false for invalid ISO 8601 date strings', () => {
expect(isISO8601DateTimeString()).toBe(false);
expect(isISO8601DateTimeString('')).toBe(false);
expect(isISO8601DateTimeString('qsdqsd')).toBe(false);
expect(isISO8601DateTimeString('2016-02-01-')).toBe(false);
expect(isISO8601DateTimeString('2021-01-01T00:00:00.')).toBe(false);
});
});
describe('isISO9075DateString', () => {
test('should return true for valid ISO 9075 date strings', () => {
expect(isISO9075DateString('2022-01-01 12:00:00Z')).toBe(true);
expect(isISO9075DateString('2022-01-01 12:00:00.123456Z')).toBe(true);
expect(isISO9075DateString('2022-01-01 12:00:00+01:00')).toBe(true);
expect(isISO9075DateString('2022-01-01 12:00:00-05:00')).toBe(true);
});
test('should return false for invalid ISO 9075 date strings', () => {
expect(isISO9075DateString('2022/01/01T12:00:00Z')).toBe(false);
expect(isISO9075DateString('2022-01-01 12:00:00.123456789Z')).toBe(false);
expect(isISO9075DateString('2022-01-01 12:00:00+1:00')).toBe(false);
expect(isISO9075DateString('2022-01-01 12:00:00-05:')).toBe(false);
expect(isISO9075DateString('2022-01-01 12:00:00-05:00:00')).toBe(false);
expect(isISO9075DateString('2022-01-01')).toBe(false);
expect(isISO9075DateString('12:00:00Z')).toBe(false);
expect(isISO9075DateString('2022-01-01T12:00:00Zfoo')).toBe(false);
});
});
describe('isRFC3339DateString', () => {
test('should return true for valid RFC 3339 date strings', () => {
expect(isRFC3339DateString('2022-01-01T12:00:00Z')).toBe(true);
expect(isRFC3339DateString('2022-01-01T12:00:00.123456789Z')).toBe(true);
expect(isRFC3339DateString('2022-01-01T12:00:00.123456789+01:00')).toBe(true);
expect(isRFC3339DateString('2022-01-01T12:00:00-05:00')).toBe(true);
});
test('should return false for invalid RFC 3339 date strings', () => {
expect(isRFC3339DateString('2022/01/01T12:00:00Z')).toBe(false);
expect(isRFC3339DateString('2022-01-01T12:00:00.123456789+1:00')).toBe(false);
expect(isRFC3339DateString('2022-01-01T12:00:00-05:')).toBe(false);
expect(isRFC3339DateString('2022-01-01T12:00:00-05:00:00')).toBe(false);
expect(isRFC3339DateString('2022-01-01')).toBe(false);
expect(isRFC3339DateString('12:00:00Z')).toBe(false);
expect(isRFC3339DateString('2022-01-01T12:00:00Zfoo')).toBe(false);
});
});
describe('isRFC7231DateString', () => {
test('should return true for valid RFC 7231 date strings', () => {
expect(isRFC7231DateString('Sun, 06 Nov 1994 08:49:37 GMT')).toBe(true);
expect(isRFC7231DateString('Tue, 22 Apr 2014 07:00:00 GMT')).toBe(true);
});
test('should return false for invalid RFC 7231 date strings', () => {
expect(isRFC7231DateString('06 Nov 1994 08:49:37 GMT')).toBe(false);
expect(isRFC7231DateString('Sun, 06 Nov 94 08:49:37 GMT')).toBe(false);
expect(isRFC7231DateString('Sun, 06 Nov 1994 8:49:37 GMT')).toBe(false);
expect(isRFC7231DateString('Sun, 06 Nov 1994 08:49:37 GMT-0500')).toBe(false);
expect(isRFC7231DateString('Sun, 06 November 1994 08:49:37 GMT')).toBe(false);
expect(isRFC7231DateString('Sunday, 06 Nov 1994 08:49:37 GMT')).toBe(false);
expect(isRFC7231DateString('06 Nov 1994')).toBe(false);
});
});
describe('isUnixTimestamp', () => {
test('should return true for valid Unix timestamps', () => {
expect(isUnixTimestamp('1649789394')).toBe(true);
expect(isUnixTimestamp('1234567890')).toBe(true);
expect(isUnixTimestamp('0')).toBe(true);
});
test('should return false for invalid Unix timestamps', () => {
expect(isUnixTimestamp('foo')).toBe(false);
expect(isUnixTimestamp('')).toBe(false);
});
});
describe('isTimestamp', () => {
test('should return true for valid Unix timestamps in milliseconds', () => {
expect(isTimestamp('1649792026123')).toBe(true);
expect(isTimestamp('1234567890000')).toBe(true);
expect(isTimestamp('0')).toBe(true);
});
test('should return false for invalid Unix timestamps in milliseconds', () => {
expect(isTimestamp('foo')).toBe(false);
expect(isTimestamp('')).toBe(false);
});
});
describe('isUTCDateString', () => {
test('should return true for valid UTC date strings', () => {
expect(isUTCDateString('Sun, 06 Nov 1994 08:49:37 GMT')).toBe(true);
expect(isUTCDateString('Tue, 22 Apr 2014 07:00:00 GMT')).toBe(true);
});
test('should return false for invalid UTC date strings', () => {
expect(isUTCDateString('06 Nov 1994 08:49:37 GMT')).toBe(false);
expect(isUTCDateString('16497920261')).toBe(false);
expect(isUTCDateString('foo')).toBe(false);
expect(isUTCDateString('')).toBe(false);
});
});
describe('isMongoObjectId', () => {
test('should return true for valid Mongo ObjectIds', () => {
expect(isMongoObjectId('507f1f77bcf86cd799439011')).toBe(true);
expect(isMongoObjectId('507f1f77bcf86cd799439012')).toBe(true);
});
test('should return false for invalid Mongo ObjectIds', () => {
expect(isMongoObjectId('507f1f77bcf86cd79943901')).toBe(false);
expect(isMongoObjectId('507f1f77bcf86cd79943901z')).toBe(false);
expect(isMongoObjectId('foo')).toBe(false);
expect(isMongoObjectId('')).toBe(false);
});
});
});

View file

@ -0,0 +1,46 @@
import _ from 'lodash';
export {
isISO8601DateTimeString,
isISO9075DateString,
isRFC3339DateString,
isRFC7231DateString,
isUnixTimestamp,
isTimestamp,
isUTCDateString,
isMongoObjectId,
};
const ISO8601_REGEX =
/^([+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24:?00)([.,]\d+(?!:))?)?(\17[0-5]\d([.,]\d+)?)?([zZ]|([+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/;
const ISO9075_REGEX =
/^([0-9]{4})-([0-9]{2})-([0-9]{2}) ([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]{1,6})?(([+-])([0-9]{2}):([0-9]{2})|Z)?$/;
const RFC3339_REGEX =
/^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]{1,9})?(([+-])([0-9]{2}):([0-9]{2})|Z)$/;
const RFC7231_REGEX = /^[A-Za-z]{3},\s[0-9]{2}\s[A-Za-z]{3}\s[0-9]{4}\s[0-9]{2}:[0-9]{2}:[0-9]{2}\sGMT$/;
function createRegexMatcher(regex: RegExp) {
return (date?: string) => !_.isNil(date) && regex.test(date);
}
const isISO8601DateTimeString = createRegexMatcher(ISO8601_REGEX);
const isISO9075DateString = createRegexMatcher(ISO9075_REGEX);
const isRFC3339DateString = createRegexMatcher(RFC3339_REGEX);
const isRFC7231DateString = createRegexMatcher(RFC7231_REGEX);
const isUnixTimestamp = createRegexMatcher(/^[0-9]{1,10}$/);
const isTimestamp = createRegexMatcher(/^[0-9]{1,13}$/);
const isMongoObjectId = createRegexMatcher(/^[0-9a-fA-F]{24}$/);
function isUTCDateString(date?: string) {
if (_.isNil(date)) {
return false;
}
try {
return new Date(date).toUTCString() === date;
} catch (_ignored) {
return false;
}
}

View file

@ -0,0 +1,8 @@
export type ToDateMapper = (value: string) => Date;
export type DateFormat = {
name: string;
fromDate: (date: Date) => string;
toDate: (value: string) => Date;
formatMatcher: (dateString: string) => boolean;
};

View file

@ -1,44 +1,38 @@
<template>
<div>
<n-card>
<n-space justify="center">
<n-form-item label="Use current date-time ?" label-placement="left" :show-feedback="false">
<n-switch v-model:value="useCurrentDate" />
</n-form-item>
</n-space>
<n-form-item
:feedback="inputInvalid ? 'Invalid date for the current format' : ''"
:validation-status="inputInvalid ? 'error' : undefined"
>
<n-input-group style="flex-grow: 1">
<n-select
v-model:value="inputFormat"
style="width: 200px"
:options="formats.map(({ name }, i) => ({ label: name, value: i }))"
:disabled="useCurrentDate"
/>
<n-form-item :show-label="false" v-bind="validation.attrs">
<n-input-group>
<n-input
v-model:value="inputDate"
:on-input="onDateInputChanged"
placeholder="Put you date string here..."
clearable
:input-props="{ 'data-test-id': 'date-time-converter-input' }"
/>
<n-input
v-model:value="inputDate"
:on-input="onDateInputChanged"
:disabled="useCurrentDate"
placeholder="Your date string..."
/>
</n-input-group>
</n-form-item>
<n-divider style="margin-top: 0" />
<div v-for="{ name, fromDate } in formats" :key="name" style="margin: 5px 0">
<n-input-group>
<n-input-group-label style="flex: 0 0 170px"> {{ name }}: </n-input-group-label>
<input-copyable :value="fromDate(baseDate)" />
</n-input-group>
</div>
</n-card>
<n-select
v-model:value="formatIndex"
style="flex: 0 0 170px"
:options="formats.map(({ name }, i) => ({ label: name, value: i }))"
data-test-id="date-time-converter-format-select"
/>
</n-input-group>
</n-form-item>
<n-divider style="margin-top: 0" />
<div v-for="{ name, fromDate } in formats" :key="name" mt-1>
<n-input-group>
<n-input-group-label style="flex: 0 0 170px"> {{ name }}: </n-input-group-label>
<input-copyable
:value="formatDateUsingFormatter(fromDate, normalizedDate)"
placeholder="Invalid date..."
:input-props="{ 'data-test-id': name }"
/>
</n-input-group>
</div>
</div>
</template>
<script setup lang="ts">
import { useRafFn } from '@vueuse/core';
import {
formatISO,
formatISO9075,
@ -47,95 +41,132 @@ import {
fromUnixTime,
getTime,
getUnixTime,
isDate,
parseISO,
parseJSON,
isDate,
isValid,
} from 'date-fns';
import { ref } from 'vue';
import InputCopyable from '../../components/InputCopyable.vue';
import { withDefaultOnError } from '@/utils/defaults';
import { useValidation } from '@/composable/validation';
import type { DateFormat, ToDateMapper } from './date-time-converter.types';
import {
isISO8601DateTimeString,
isISO9075DateString,
isRFC3339DateString,
isRFC7231DateString,
isTimestamp,
isUTCDateString,
isUnixTimestamp,
isMongoObjectId,
} from './date-time-converter.models';
const useCurrentDate = ref(true);
const inputDate = ref('');
const inputFormat = ref(6);
const inputInvalid = ref(false);
const baseDate = ref(new Date());
useRafFn(() => {
if (useCurrentDate.value) {
baseDate.value = new Date();
const toDate: ToDateMapper = (date) => new Date(date);
function formatDateUsingFormatter(formatter: (date: Date) => string, date?: Date) {
if (!date || !validation.isValid) {
return '';
}
});
function onDateInputChanged(value: string) {
const { toDate } = formats[inputFormat.value];
inputInvalid.value = false;
try {
const formatted: Date | string = toDate(value);
if (!isDate(formatted) || isNaN(formatted.getTime())) {
throw new Error('Invalid date');
}
baseDate.value = formatted;
} catch (_) {
inputInvalid.value = true;
}
return withDefaultOnError(() => formatter(date), '');
}
type Format = {
name: string;
fromDate: (date: Date) => string;
toDate: (value: string) => Date;
};
const toDate: Format['toDate'] = (date) => new Date(date);
const formats: Format[] = [
const formats: DateFormat[] = [
{
name: 'JS locale date string',
fromDate: (date) => date.toString(),
toDate,
formatMatcher: () => false,
},
{
name: 'ISO 8601',
fromDate: formatISO,
toDate: parseISO,
formatMatcher: (date) => isISO8601DateTimeString(date),
},
{
name: 'ISO 9075',
fromDate: formatISO9075,
toDate: parseISO,
formatMatcher: (date) => isISO9075DateString(date),
},
{
name: 'RFC 3339',
fromDate: formatRFC3339,
toDate,
formatMatcher: (date) => isRFC3339DateString(date),
},
{
name: 'RFC 7231',
fromDate: formatRFC7231,
toDate,
},
{
name: 'Timestamp',
fromDate: (date) => String(getTime(date)),
toDate: (ms) => parseJSON(+ms),
formatMatcher: (date) => isRFC7231DateString(date),
},
{
name: 'Unix timestamp',
fromDate: (date) => String(getUnixTime(date)),
toDate: (sec) => fromUnixTime(+sec),
formatMatcher: (date) => isUnixTimestamp(date),
},
{
name: 'Timestamp',
fromDate: (date) => String(getTime(date)),
toDate: (ms) => parseJSON(+ms),
formatMatcher: (date) => isTimestamp(date),
},
{
name: 'UTC format',
fromDate: (date) => date.toUTCString(),
toDate,
formatMatcher: (date) => isUTCDateString(date),
},
{
name: 'Mongo ObjectID',
fromDate: (date) => Math.floor(date.getTime() / 1000).toString(16) + '0000000000000000',
toDate: (objectId) => new Date(parseInt(objectId.substring(0, 8), 16) * 1000),
formatMatcher: (date) => isMongoObjectId(date),
},
];
const formatIndex = ref(6);
const now = useNow();
const normalizedDate = computed(() => {
if (!inputDate.value) {
return now.value;
}
const { toDate } = formats[formatIndex.value];
try {
return toDate(inputDate.value);
} catch (_ignored) {
return undefined;
}
});
function onDateInputChanged(value: string) {
const matchingIndex = formats.findIndex(({ formatMatcher }) => formatMatcher(value));
if (matchingIndex !== -1) {
formatIndex.value = matchingIndex;
}
}
const validation = useValidation({
source: inputDate,
watch: [formatIndex],
rules: [
{
message: 'This date is invalid for this format',
validator: (value) =>
withDefaultOnError(() => {
if (value === '') return true;
const maybeDate = formats[formatIndex.value].toDate(value);
return isDate(maybeDate) && isValid(maybeDate);
}, false),
},
],
});
</script>