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

fix(datetime): values are adjusted to be in bounds #26125

Merged
merged 13 commits into from
Oct 17, 2022
4 changes: 2 additions & 2 deletions core/src/components/datetime/datetime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,7 @@ export class Datetime implements ComponentInterface {
};

private setActiveParts = (parts: DatetimeParts, removeDate = false) => {
const { multiple, activePartsClone } = this;
const { multiple, minParts, maxParts, activePartsClone } = this;

/**
* When setting the active parts, it is possible
Expand All @@ -597,7 +597,7 @@ export class Datetime implements ComponentInterface {
* Additionally, we need to update the working parts
* too in the event that the validated parts are different.
*/
const validatedParts = validateParts(parts);
const validatedParts = validateParts(parts, minParts, maxParts);
this.setWorkingParts(validatedParts);

if (multiple) {
Expand Down
70 changes: 70 additions & 0 deletions core/src/components/datetime/test/manipulation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
calculateHourFromAMPM,
subtractDays,
addDays,
validateParts,
} from '../utils/manipulation';

describe('addDays()', () => {
Expand Down Expand Up @@ -487,3 +488,72 @@ describe('getPreviousYear()', () => {
});
});
});

describe('validateParts()', () => {
it('should move day in bounds', () => {
expect(validateParts({ month: 2, day: 31, year: 2022, hour: 8, minute: 0 })).toEqual({
month: 2,
day: 28,
year: 2022,
hour: 8,
minute: 0,
});
});
it('should move the hour back in bounds according to the min', () => {
expect(
validateParts(
{ month: 1, day: 1, year: 2022, hour: 8, minute: 0 },
{ month: 1, day: 1, year: 2022, hour: 9, minute: 0 }
)
).toEqual({ month: 1, day: 1, year: 2022, hour: 9, minute: 0 });
});
it('should move the minute back in bounds according to the min', () => {
expect(
validateParts(
{ month: 1, day: 1, year: 2022, hour: 9, minute: 20 },
{ month: 1, day: 1, year: 2022, hour: 9, minute: 30 }
)
).toEqual({ month: 1, day: 1, year: 2022, hour: 9, minute: 30 });
});
it('should move the hour and minute back in bounds according to the min', () => {
expect(
validateParts(
{ month: 1, day: 1, year: 2022, hour: 8, minute: 30 },
{ month: 1, day: 1, year: 2022, hour: 9, minute: 0 }
)
).toEqual({ month: 1, day: 1, year: 2022, hour: 9, minute: 0 });
});
it('should move the hour back in bounds according to the max', () => {
expect(
validateParts({ month: 1, day: 1, year: 2022, hour: 10, minute: 0 }, undefined, {
month: 1,
day: 1,
year: 2022,
hour: 9,
minute: 0,
})
).toEqual({ month: 1, day: 1, year: 2022, hour: 9, minute: 0 });
});
it('should move the minute back in bounds according to the max', () => {
expect(
validateParts({ month: 1, day: 1, year: 2022, hour: 9, minute: 40 }, undefined, {
month: 1,
day: 1,
year: 2022,
hour: 9,
minute: 30,
})
).toEqual({ month: 1, day: 1, year: 2022, hour: 9, minute: 30 });
});
it('should move the hour and minute back in bounds according to the max', () => {
expect(
validateParts({ month: 1, day: 1, year: 2022, hour: 10, minute: 20 }, undefined, {
month: 1,
day: 1,
year: 2022,
hour: 9,
minute: 30,
})
).toEqual({ month: 1, day: 1, year: 2022, hour: 9, minute: 30 });
});
});
49 changes: 49 additions & 0 deletions core/src/components/datetime/test/minmax/datetime.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,4 +234,53 @@ test.describe('datetime: minmax', () => {
);
await expect(hourPickerItems).toHaveText(['12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']);
});

test.describe('minmax value adjustment when out of bounds', () => {
test.beforeEach(({ skip }) => {
skip.rtl();
skip.mode('ios', 'This implementation is the same across modes.');
});
test('should reset to min time if out of bounds', async ({ page }) => {
await page.setContent(`
<ion-datetime
min="2022-10-10T08:00"
value="2022-10-11T06:00"
></ion-datetime>
`);

await page.waitForSelector('.datetime-ready');

const datetime = page.locator('ion-datetime');
const ionChange = await page.spyOnEvent('ionChange');
const dayButton = page.locator('ion-datetime .calendar-day[data-day="10"][data-month="10"][data-year="2022"]');
await dayButton.click();

await ionChange.next();

const value = await datetime.evaluate((el: HTMLIonDatetimeElement) => el.value);
await expect(typeof value).toBe('string');
await expect(value!.includes('2022-10-10T08:00')).toBe(true);
});
test('should reset to max time if out of bounds', async ({ page }) => {
await page.setContent(`
<ion-datetime
max="2022-10-10T08:00"
value="2022-10-11T09:00"
></ion-datetime>
`);

await page.waitForSelector('.datetime-ready');

const datetime = page.locator('ion-datetime');
const ionChange = await page.spyOnEvent('ionChange');
const dayButton = page.locator('ion-datetime .calendar-day[data-day="10"][data-month="10"][data-year="2022"]');
await dayButton.click();

await ionChange.next();

const value = await datetime.evaluate((el: HTMLIonDatetimeElement) => el.value);
await expect(typeof value).toBe('string');
await expect(value!.includes('2022-10-10T08:00')).toBe(true);
});
});
});
68 changes: 67 additions & 1 deletion core/src/components/datetime/utils/manipulation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { DatetimeParts } from '../datetime-interface';

import { isSameDay } from './comparison';
import { getNumDaysInMonth } from './helpers';

const twoDigit = (val: number | undefined): string => {
Expand Down Expand Up @@ -345,7 +346,11 @@ export const calculateHourFromAMPM = (currentParts: DatetimeParts, newAMPM: 'am'
* values are valid. For days that do not exist,
* the closest valid day is used.
*/
export const validateParts = (parts: DatetimeParts): DatetimeParts => {
export const validateParts = (
parts: DatetimeParts,
minParts?: DatetimeParts,
maxParts?: DatetimeParts
): DatetimeParts => {
const { month, day, year } = parts;
const partsCopy = { ...parts };

Expand All @@ -361,5 +366,66 @@ export const validateParts = (parts: DatetimeParts): DatetimeParts => {
partsCopy.day = numDays;
}

/**
* If value is same day as min day,
* make sure the time value is in bounds.
*/
if (minParts !== undefined && isSameDay(partsCopy, minParts)) {
/**
* If the hour is out of bounds,
* update both the hour and minute.
* This is done so that the new time
* is closest to what the user selected.
*/
if (partsCopy.hour !== undefined && minParts.hour !== undefined) {
if (partsCopy.hour < minParts.hour) {
partsCopy.hour = minParts.hour;
partsCopy.minute = minParts.minute;

/**
* If only the minute is out of bounds,
* set it to the min minute.
*/
} else if (
partsCopy.hour === minParts.hour &&
partsCopy.minute !== undefined &&
minParts.minute !== undefined &&
partsCopy.minute < minParts.minute
) {
partsCopy.minute = minParts.minute;
}
}
}

/**
* If value is same day as max day,
* make sure the time value is in bounds.
*/
if (maxParts !== undefined && isSameDay(parts, maxParts)) {
/**
* If the hour is out of bounds,
* update both the hour and minute.
* This is done so that the new time
* is closest to what the user selected.
*/
if (partsCopy.hour !== undefined && maxParts.hour !== undefined) {
if (partsCopy.hour > maxParts.hour) {
partsCopy.hour = maxParts.hour;
partsCopy.minute = maxParts.minute;
/**
* If only the minute is out of bounds,
* set it to the max minute.
*/
} else if (
partsCopy.hour === maxParts.hour &&
partsCopy.minute !== undefined &&
maxParts.minute !== undefined &&
partsCopy.minute > maxParts.minute
) {
partsCopy.minute = maxParts.minute;
}
}
}

return partsCopy;
};
Original file line number Diff line number Diff line change
Expand Up @@ -36,70 +36,6 @@ export class PickerColumnInternal implements ComponentInterface {
* A list of options to be displayed in the picker
*/
@Prop() items: PickerColumnItem[] = [];
@Watch('items')
itemsChange(currentItems: PickerColumnItem[], previousItems: PickerColumnItem[]) {
const { value } = this;

/**
* When the items change, it is possible for the item
* that was selected to no longer exist. In that case, we need
* to automatically select the nearest item. If we do not,
* then the scroll position will be reset to zero and it will
* look like the first item was automatically selected.
*
* If we cannot find a closest item then we do nothing, and
* the browser will reset the scroll position to 0.
*/
const findCurrentItem = currentItems.find((item) => item.value === value);
if (!findCurrentItem) {
/**
* The default behavior is to assume
* that the new set of data is similar to the old
* set of data, just with some items filtered out.
* We walk backwards through the data to find the
* closest enabled picker item and select it.
*
* Developers can also swap the items out for an entirely
* new set of data. In that case, the value we select
* here likely will not make much sense. For this use case,
* developers should update the `value` prop themselves
* when swapping out the data.
*/
const findPreviousItemIndex = previousItems.findIndex((item) => item.value === value);
if (findPreviousItemIndex === -1) {
return;
}

/**
* Step through the current items backwards
* until we find a neighbor we can select.
* We start at the last known location of the
* current selected item in order to
* account for data that has been added. This
* search prioritizes stability in that it
* tries to keep the scroll position as close
* to where it was before the update.
* Before Items: ['a', 'b', 'c'], Selected Value: 'b'
* After Items: ['a', 'dog', 'c']
* Even though 'dog' is a different item than 'b',
* it is the closest item we can select while
* preserving the scroll position.
*/
let nearestItem;
for (let i = findPreviousItemIndex; i >= 0; i--) {
const item = currentItems[i];
if (item !== undefined && item.disabled !== true) {
nearestItem = item;
break;
}
}

if (nearestItem) {
this.setValue(nearestItem.value);
return;
}
}
}

/**
* The selected option in the picker.
Expand Down
Loading