Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/DateField/DateField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function DateField({className, ...props}: DateFieldProps) {
value={state.value}
toStringValue={(value) => value?.toISOString() ?? ''}
onReset={(value) => {
state.setDate(value);
state.setValue(value);
}}
disabled={state.disabled}
form={props.form}
Expand Down
135 changes: 135 additions & 0 deletions src/components/DateField/IncompleteDate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type {DateTime} from '@gravity-ui/date-utils';

import type {AvailableSections} from './types';
import {getDurationUnitFromSectionType} from './utils';

const dateFields = [
'year',
'quarter',
'month',
'day',
'weekday',
'hour',
'minute',
'second',
'dayPeriod',
] as const;

type Field = (typeof dateFields)[number];

export class IncompleteDate {
year: number | null;
month: number | null;
day: number | null;
weekday: number | null;
dayPeriod: number | null;
hour: number | null;
minute: number | null;
second: number | null;

constructor(date?: DateTime | null) {
this.year = date?.year() ?? null;
this.month = date ? date.month() + 1 : null;
this.day = date?.date() ?? null;
this.weekday = date?.day() ?? null;
this.hour = date?.hour() ?? null;
this.minute = date?.minute() ?? null;
this.second = date?.second() ?? null;
if (date) {
this.dayPeriod = date.hour() >= 12 ? 1 : 0;
} else {
this.dayPeriod = null;
}
}

get quarter() {
return this.month === null ? null : Math.ceil(this.month / 3);
}

set quarter(v: number | null) {
this.month =
v === null
? null
: (v - 1) * 3 + (this.month === null ? 1 : ((this.month - 1) % 3) + 13);
}

copy() {
const copy = new IncompleteDate();
for (const field of dateFields) {
copy[field] = this[field];
}
return copy;
}

isComplete(availableUnits: AvailableSections): boolean {
return dateFields.every((field) => !availableUnits[field] || this[field] !== null);
}

validate(date: DateTime, availableUnits: AvailableSections): boolean {
return dateFields.every((field) => {
if (!availableUnits[field]) {
return true;
}

if (field === 'dayPeriod') {
return this.dayPeriod === (date.hour() >= 12 ? 1 : 0);
}

if (field === 'month') {
return date.month() + 1 === this.month;
}

return date[getDurationUnitFromSectionType(field)]() === this[field];
});
}

isCleared(availableUnits: AvailableSections): boolean {
return dateFields.every((field) => !availableUnits[field] || this[field] === null);
}

set(field: Field, value: number): IncompleteDate {
const copy = this.copy();
copy[field] = value;
if (field === 'hour') {
copy.dayPeriod = (copy.hour ?? 0) >= 12 ? 1 : 0;
}

return copy;
}

clear(field: Field): IncompleteDate {
const copy = this.copy();
copy[field] = null;
return copy;
}

toDateTime(
baseValue: DateTime,
{setDate, setTime}: {setDate: boolean; setTime: boolean},
): DateTime {
let nextValue = baseValue;
if (setDate) {
nextValue = nextValue
.set({
year: this.year ?? baseValue.year(),
month: 0, // set January to not overflow day value
date: this.day ?? baseValue.date(),
})
.set({month: this.month === null ? baseValue.month() : this.month - 1});
if (this.day === null && this.weekday !== null) {
nextValue = nextValue.set({day: this.weekday});
}
}
if (setTime) {
nextValue = nextValue
.set({
hour: this.hour ?? baseValue.hour(),
minute: this.minute ?? baseValue.minute(),
second: this.second ?? baseValue.second(),
})
.timeZone(nextValue.timeZone());
}

return nextValue;
}
}
102 changes: 102 additions & 0 deletions src/components/DateField/__tests__/DateField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {dateTime} from '@gravity-ui/date-utils';
import {describe, expect, it, vi} from 'vitest';
import {userEvent} from 'vitest/browser';

import {render} from '#test-utils/utils';

import {DateField} from '../DateField';
import {cleanString} from '../utils';

describe('DateField', () => {
describe('format rendering', () => {
it('renders two-digit year correctly for YY format', async () => {
const value = dateTime({input: '2024-01-15T00:00:00'});
const screen = await render(<DateField value={value} format="YY" />);

const input = screen.getByRole('textbox').element() as HTMLInputElement;
expect(cleanString(input.value)).toBe(value.format('YY'));
});

it('renders ordinal day token correctly for Do format', async () => {
const value = dateTime({input: '2024-04-01T00:00:00'});
const screen = await render(<DateField value={value} format="Do MMMM YYYY" />);

const input = screen.getByRole('textbox').element() as HTMLInputElement;
expect(cleanString(input.value)).toBe(value.format('Do MMMM YYYY'));
});

it('renders ordinal quarter token correctly for Qo format', async () => {
const value = dateTime({input: '2024-05-01T00:00:00'});
const screen = await render(<DateField value={value} format="Qo YYYY" />);

const input = screen.getByRole('textbox').element() as HTMLInputElement;
expect(cleanString(input.value)).toBe(value.format('Qo YYYY'));
});

it('keeps entered textual month during intermediate invalid state', async () => {
const timeZone = 'Europe/Amsterdam';
const screen = await render(
<DateField
format="DD MMMM YYYY"
placeholderValue={dateTime({input: '2024-01-15', timeZone})}
/>,
);

await userEvent.keyboard('{Tab}');
await userEvent.keyboard('31042024');

const input = screen.getByRole('textbox').element() as HTMLInputElement;
expect(cleanString(input.value)).toBe('31 April 2024');
});
});

describe('invalid date', () => {
it('should allow to enter 31 april and constrains the date on blur', async () => {
const onUpdate = vi.fn();
const timeZone = 'Europe/Amsterdam';
const screen = await render(
<DateField
format="DD.MM.YYYY"
placeholderValue={dateTime({input: '2024-04-15', timeZone})}
onUpdate={onUpdate}
/>,
);
await userEvent.keyboard('{Tab}');
await userEvent.keyboard('31042024');

const input = screen.getByRole('textbox').element() as HTMLInputElement;

expect(onUpdate).not.toHaveBeenCalled();
expect(cleanString(input.value)).toBe('31.04.2024');

await userEvent.keyboard('{Tab}');

expect(onUpdate).toHaveBeenCalledWith(dateTime({input: '2024-04-30', timeZone}));
expect(cleanString(input.value)).toBe('30.04.2024');
});

it('should allow to enter 2am during a forward DST transition', async () => {
const onUpdate = vi.fn();
const timeZone = 'Europe/Amsterdam';
const screen = await render(
<DateField
format="DD.MM.YYYY HH:mm"
placeholderValue={dateTime({input: '2024-03-31T00:00', timeZone})}
onUpdate={onUpdate}
/>,
);
await userEvent.keyboard('{Tab}');
await userEvent.keyboard('310320240200');

const input = screen.getByRole('textbox').element() as HTMLInputElement;

expect(onUpdate).not.toHaveBeenCalled();
expect(cleanString(input.value)).toBe('31.03.2024 02:00');

await userEvent.keyboard('{Tab}');

expect(onUpdate).toHaveBeenCalledWith(dateTime({input: '2024-03-31T01:00Z', timeZone}));
expect(cleanString(input.value)).toBe('31.03.2024 03:00');
});
});
});
89 changes: 89 additions & 0 deletions src/components/DateField/__tests__/useDateFieldState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {dateTime} from '@gravity-ui/date-utils';
import {describe, expect, it, vi} from 'vitest';

import {renderHook} from '#test-utils/utils';

import {useDateFieldState} from '../hooks/useDateFieldState';
import {cleanString} from '../utils';

describe('invalid date entry', () => {
it('allows entering day 31 even if placeholder month has 30 days', async () => {
const {result, act} = await renderHook(() =>
useDateFieldState({
format: 'DD.MM.YYYY',
placeholderValue: dateTime({input: '2024-06-15', format: 'YYYY-MM-DD'}),
}),
);

const dayIndex = result.current.sections.findIndex((section) => section.type === 'day');

act(() => {
result.current.setSelectedSections(dayIndex);
result.current.onInput('3');
result.current.onInput('1');
});

const daySection = result.current.sections[dayIndex];
expect(daySection.value).toBe(31);
expect(cleanString(daySection.textValue)).toBe('31');
});

it('suppresses updates, and constrains on blur', async () => {
const onUpdate = vi.fn();
const {result, act} = await renderHook(() =>
useDateFieldState({
format: 'DD.MM.YYYY',
onUpdate,
}),
);

const dayIndex = result.current.sections.findIndex((section) => section.type === 'day');
const monthIndex = result.current.sections.findIndex((section) => section.type === 'month');
const yearIndex = result.current.sections.findIndex((section) => section.type === 'year');

act(() => {
result.current.setSelectedSections(dayIndex);
});
act(() => {
result.current.onInput('3');
});
act(() => {
result.current.onInput('1');
});
act(() => {
result.current.setSelectedSections(monthIndex);
});
act(() => {
result.current.onInput('0');
});
act(() => {
result.current.onInput('4');
});
act(() => {
result.current.setSelectedSections(yearIndex);
});
act(() => {
result.current.onInput('2');
});
act(() => {
result.current.onInput('0');
});
act(() => {
result.current.onInput('2');
});
act(() => {
result.current.onInput('4');
});

expect(result.current.value).toBeNull();
expect(onUpdate).not.toHaveBeenCalled();

act(() => {
result.current.confirmPlaceholder();
});

expect(result.current.validationState).toBeUndefined();
expect(result.current.value?.format('DD.MM.YYYY')).toBe('30.04.2024');
expect(onUpdate).toHaveBeenCalled();
});
});
Loading
Loading