Skip to content

Commit

Permalink
refactor: support string-based property values for date components (#…
Browse files Browse the repository at this point in the history
…1470)

Adds support for passing in string based date values to date-bound
properties of calendar, date-time input and date picker components.
The component will now try to parse the passed in value without directly
throwing an exception. Unified date-bound component type declarations.
  • Loading branch information
rkaraivanov authored Nov 19, 2024
1 parent 6c0897d commit 1fa3976
Show file tree
Hide file tree
Showing 12 changed files with 345 additions and 126 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]
### Changed
- Calendar - allow passing a string value to the backing `value`, `values` and `activeDate` properties [#1467](https://github.com/IgniteUI/igniteui-webcomponents/issues/1467)
- Date-time input - allow passing a string value to the backing `value`, `min` and `max` properties [#1467](https://github.com/IgniteUI/igniteui-webcomponents/issues/1467)
- Date picker - allow passing a string value to the backing `value`, `min`, `max` and `activeDate` properties [#1467](https://github.com/IgniteUI/igniteui-webcomponents/issues/1467)

## [5.1.2] - 2024-11-04
### Added
- Carousel component select method overload accepting index [#1457](https://github.com/IgniteUI/igniteui-webcomponents/issues/1457)
Expand Down
54 changes: 28 additions & 26 deletions src/components/calendar/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@ import { property, state } from 'lit/decorators.js';
import { blazorDeepImport } from '../common/decorators/blazorDeepImport.js';
import { blazorIndirectRender } from '../common/decorators/blazorIndirectRender.js';
import { watch } from '../common/decorators/watch.js';
import {
dateFromISOString,
datesFromISOStrings,
getWeekDayNumber,
} from './helpers.js';
import { first } from '../common/util.js';
import { convertToDate, convertToDates, getWeekDayNumber } from './helpers.js';
import { CalendarDay } from './model.js';
import type { DateRangeDescriptor, WeekDays } from './types.js';

Expand All @@ -18,7 +15,7 @@ export class IgcCalendarBaseComponent extends LitElement {
private _initialActiveDateSet = false;

protected get _hasValues() {
return this._values.length > 0;
return this._values && this._values.length > 0;
}

protected get _isSingle() {
Expand All @@ -43,7 +40,7 @@ export class IgcCalendarBaseComponent extends LitElement {
protected _activeDate = CalendarDay.today;

@state()
protected _value?: CalendarDay;
protected _value: CalendarDay | null = null;

@state()
protected _values: CalendarDay[] = [];
Expand All @@ -54,24 +51,21 @@ export class IgcCalendarBaseComponent extends LitElement {
@state()
protected _disabledDates: DateRangeDescriptor[] = [];

public get value(): Date | undefined {
return this._value ? this._value.native : undefined;
}

/* blazorSuppress */
/**
* The current value of the calendar.
* Used when selection is set to single
*
* @attr value
*/
@property({ converter: dateFromISOString })
public set value(value) {
this._value = value ? CalendarDay.from(value) : undefined;
@property({ converter: convertToDate })
public set value(value: Date | string | null | undefined) {
const converted = convertToDate(value);
this._value = converted ? CalendarDay.from(converted) : null;
}

public get values(): Date[] {
return this._values ? this._values.map((v) => v.native) : [];
public get value(): Date | null {
return this._value ? this._value.native : null;
}

/* blazorSuppress */
Expand All @@ -81,21 +75,29 @@ export class IgcCalendarBaseComponent extends LitElement {
*
* @attr values
*/
@property({ converter: datesFromISOStrings })
public set values(values) {
this._values = values ? values.map((v) => CalendarDay.from(v)) : [];
@property({ converter: convertToDates })
public set values(values: (Date | string)[] | string | null | undefined) {
const converted = convertToDates(values);
this._values = converted ? converted.map((v) => CalendarDay.from(v)) : [];
}

public get activeDate(): Date {
return this._activeDate.native;
public get values(): Date[] {
return this._values ? this._values.map((v) => v.native) : [];
}

/* blazorSuppress */
/** Get/Set the date which is shown in view and is highlighted. By default it is the current date. */
@property({ attribute: 'active-date', converter: dateFromISOString })
public set activeDate(value) {
@property({ attribute: 'active-date', converter: convertToDate })
public set activeDate(value: Date | string | null | undefined) {
this._initialActiveDateSet = true;
this._activeDate = value ? CalendarDay.from(value) : CalendarDay.today;
const converted = convertToDate(value);
this._activeDate = converted
? CalendarDay.from(converted)
: CalendarDay.today;
}

public get activeDate(): Date {
return this._activeDate.native;
}

/**
Expand Down Expand Up @@ -154,7 +156,7 @@ export class IgcCalendarBaseComponent extends LitElement {
@watch('selection', { waitUntilFirstUpdate: true })
protected selectionChanged() {
this._rangePreviewDate = undefined;
this._value = undefined;
this._value = null;
this._values = [];
}

Expand All @@ -166,7 +168,7 @@ export class IgcCalendarBaseComponent extends LitElement {
if (this._isSingle) {
this.activeDate = this.value ?? this.activeDate;
} else {
this.activeDate = this.values[0] ?? this.activeDate;
this.activeDate = first(this.values) ?? this.activeDate;
}
}
}
64 changes: 64 additions & 0 deletions src/components/calendar/calendar.interaction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ describe('Calendar interactions', () => {
expect(date.equalTo(calendar.value!)).to.be.true;
});

it('setting `value` - string property binding', async () => {
const date = new CalendarDay({ year: 2022, month: 0, date: 19 });
calendar.value = date.native.toISOString();

expect(date.equalTo(calendar.value!)).to.be.true;

// Invalid date
for (const each of [new Date('s'), '', null, undefined]) {
calendar.value = each;
expect(calendar.value).to.be.null;
}
});

it('setting `values` attribute', async () => {
const date_1 = new CalendarDay({ year: 2022, month: 0, date: 19 });
const date_2 = date_1.set({ date: 22 });
Expand All @@ -61,6 +74,57 @@ describe('Calendar interactions', () => {
expect(date_2.equalTo(last(calendar.values))).to.be.true;
});

it('setting `values` - string property binding', async () => {
const date_1 = new CalendarDay({ year: 2022, month: 0, date: 19 });
const date_2 = date_1.set({ date: 22 });

const date_1_str = date_1.native.toISOString();
const date_2_str = date_2.native.toISOString();

calendar.selection = 'multiple';
calendar.values = `${date_1_str}, ${date_2_str}`;

expect(calendar.values).lengthOf(2);
expect(date_1.equalTo(first(calendar.values))).to.be.true;
expect(date_2.equalTo(last(calendar.values))).to.be.true;

// Valid date combinations
const validDates = [
[date_1_str, date_2_str],
[date_1.native, date_2.native],
[date_1_str, date_2.native],
];

for (const each of validDates) {
calendar.values = each;
expect(calendar.values).lengthOf(2);
expect(date_1.equalTo(first(calendar.values))).to.be.true;
expect(date_2.equalTo(last(calendar.values))).to.be.true;
}

// Mixed date combinations
calendar.values = [date_1.native, new Date(), new Date('s'), date_1_str];
expect(calendar.values).lengthOf(3);

calendar.values = ['invalid', date_1_str, date_2_str, date_2.native];
expect(calendar.values).lengthOf(3);

// Invalid date combinations
const invalidDates = [
'',
null,
undefined,
[new Date('s'), 'abc'],
'abcde, abcde',
['a', 'b', 'c', new Date('invalid')],
];

for (const each of invalidDates) {
calendar.values = each;
expect(calendar.values).is.empty;
}
});

it('clicking previous/next buttons in days view', async () => {
const { previous, next } = getCalendarDOM(calendar).navigation;

Expand Down
2 changes: 1 addition & 1 deletion src/components/calendar/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,7 @@ export default class IgcCalendarComponent extends EventEmitterMixin<
}

this.emitEvent('igcChange', {
detail: this._isSingle ? this.value : this.values,
detail: this._isSingle ? (this.value as Date) : this.values,
});
}

Expand Down
79 changes: 68 additions & 11 deletions src/components/calendar/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
asNumber,
findElementFromEventPath,
first,
isString,
last,
modulo,
} from '../common/util.js';
Expand Down Expand Up @@ -36,23 +37,79 @@ const DaysMap = {

/* Converter functions */

export function dateFromISOString(value: string | null) {
return value ? new Date(value) : null;
export function isValidDate(date: Date) {
return Number.isNaN(date.valueOf()) ? null : date;
}

export function datesFromISOStrings(value: string | null) {
return value
? value
.split(',')
.map((v) => v.trim())
.filter((v) => v)
.map((v) => new Date(v))
: null;
export function parseISODate(string: string) {
if (/^\d{4}/.test(string)) {
const time = !string.includes('T') ? 'T00:00:00' : '';
return isValidDate(new Date(`${string}${time}`));
}

if (/^\d{2}/.test(string)) {
const date = first(new Date().toISOString().split('T'));
return isValidDate(new Date(`${date}T${string}`));
}

return null;
}

/**
* Returns the value of the selected/activated element (day/month/year) in the calendar view.
* Converts the given value to a Date object.
*
* If the value is already a valid Date object, it is returned directly.
* If the value is a string, it is parsed into a Date object.
* If the value is null or undefined, null is returned.
* If the parsing fails, null is returned.
*/
export function convertToDate(value?: Date | string | null): Date | null {
if (!value) {
return null;
}

return isString(value) ? parseISODate(value) : isValidDate(value);
}

/**
* Converts a Date object to an ISO 8601 string.
*
* If the `value` is a `Date` object, it is converted to an ISO 8601 string.
* If the `value` is null or undefined, null is returned.
*/
export function getDateFormValue(value: Date | null) {
return value ? value.toISOString() : null;
}

/**
* Converts a comma-separated string of ISO 8601 dates or an array of Date objects | ISO 8601 strings into
* an array of Date objects.
*
* If the `value` is null or undefined, null is returned.
* If the `value` is an array of `Date` objects, a filtered array of valid `Date` objects is returned.
* If the `value` is a string, it is split by commas and each part is parsed into a `Date` object.
* If the parsing fails for any date, it is skipped.
*/
export function convertToDates(value?: (Date | string)[] | string | null) {
if (!value) {
return null;
}

const values: Date[] = [];
const iterator = isString(value) ? value.split(',') : value;

for (const each of iterator) {
const date = convertToDate(isString(each) ? each.trim() : each);
if (date) {
values.push(date);
}
}

return values;
}

/**
* Returns the value of the selected/activated element (day/month/year) in the calendar view.
*/
export function getViewElement(event: Event) {
const element = findElementFromEventPath<HTMLElement>('[data-value]', event);
Expand Down
54 changes: 52 additions & 2 deletions src/components/date-picker/date-picker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,31 @@ describe('Date picker', () => {
checkDatesEqual(dateTimeInput.value!, expectedValue);
});

it('should be successfully initialized with a string property binding - issue 1467', async () => {
const value = new CalendarDay({ year: 2000, month: 0, date: 25 });
picker = await fixture<IgcDatePickerComponent>(html`
<igc-date-picker .value=${value.native.toISOString()}></igc-date-picker>
`);

expect(CalendarDay.from(picker.value!).equalTo(value)).to.be.true;
});

it('should not set an invalid date object as a value', async () => {
picker = await fixture<IgcDatePickerComponent>(html`
<igc-date-picker value="invalid date"></igc-date-picker>
`);

expect(picker.value).to.be.null;
});

it('should not set an invalid date object as a value through property binding', async () => {
picker = await fixture<IgcDatePickerComponent>(html`
<igc-date-picker .value=${new Date('s')}></igc-date-picker>
`);

expect(picker.value).to.be.null;
});

it('should be successfully initialized in open state in dropdown mode', async () => {
picker = await fixture<IgcDatePickerComponent>(
html`<igc-date-picker open></igc-date-picker>`
Expand Down Expand Up @@ -261,7 +286,7 @@ describe('Date picker', () => {
it('should set the value trough attribute correctly', async () => {
expect(picker.value).to.be.null;
const expectedValue = new CalendarDay({ year: 2024, month: 2, date: 1 });
picker.setAttribute('value', expectedValue.native.toDateString());
picker.setAttribute('value', expectedValue.native.toISOString());
await elementUpdated(picker);

checkDatesEqual(picker.value!, expectedValue);
Expand Down Expand Up @@ -455,7 +480,7 @@ describe('Date picker', () => {
checkDatesEqual(picker.activeDate, currentDate);
expect(picker.value).to.be.null;
checkDatesEqual(calendar.activeDate, currentDate);
expect(calendar.value).to.be.undefined;
expect(calendar.value).to.be.null;
});

it('should initialize activeDate = value when it is not set, but value is', async () => {
Expand Down Expand Up @@ -959,6 +984,31 @@ describe('Date picker', () => {
spec.submitValidates();
});

it('should enforce min value constraint with string property', async () => {
spec.element.min = new Date(2025, 0, 1).toISOString();
await elementUpdated(spec.element);
spec.submitFails();

spec.element.value = new Date(2022, 0, 1).toISOString();
await elementUpdated(spec.element);
spec.submitFails();

spec.element.value = new Date(2025, 0, 2).toISOString();
await elementUpdated(spec.element);
spec.submitValidates();
});

it('should enforce max value constraint with string property', async () => {
spec.element.max = new Date(2020, 0, 1).toISOString();
spec.element.value = today.native;
await elementUpdated(spec.element);
spec.submitFails();

spec.element.value = new Date(2020, 0, 1).toISOString();
await elementUpdated(spec.element);
spec.submitValidates();
});

it('should invalidate the component if a disabled date is typed in the input', async () => {
const minDate = new Date(2024, 1, 1);
const maxDate = new Date(2024, 1, 28);
Expand Down
Loading

0 comments on commit 1fa3976

Please sign in to comment.