Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Added support for string-based property values for date based components #1470

Merged
merged 9 commits into from
Nov 19, 2024
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