diff --git a/frontend/src/static/js/components/test/webstatus-form-date-range-picker.test.ts b/frontend/src/static/js/components/test/webstatus-form-date-range-picker.test.ts new file mode 100644 index 00000000..678f0ff6 --- /dev/null +++ b/frontend/src/static/js/components/test/webstatus-form-date-range-picker.test.ts @@ -0,0 +1,265 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {expect, fixture, html} from '@open-wc/testing'; +import { + WebstatusFormDateRangePicker, + DateRangeChangeEvent, +} from '../webstatus-form-date-range-picker.js'; +import {customElement, property} from 'lit/decorators.js'; +import {LitElement} from 'lit'; +import '../webstatus-form-date-range-picker.js'; +import '@shoelace-style/shoelace/dist/components/input/input.js'; +import '@shoelace-style/shoelace/dist/components/button/button.js'; +import sinon from 'sinon'; + +// TestComponent to listen for events from WebstatusFormDateRangePicker +@customElement('test-component') +class TestComponent extends LitElement { + @property({type: Object}) startDate: Date | undefined; + @property({type: Object}) endDate: Date | undefined; + + handleDateRangeChange(event: CustomEvent) { + this.startDate = event.detail.startDate; + this.endDate = event.detail.endDate; + } + + render() { + return html` + + `; + } +} + +describe('WebstatusFormDateRangePicker', () => { + let parent: TestComponent; + let el: WebstatusFormDateRangePicker; + + beforeEach(async () => { + // Create the parent component, which now renders the date picker + parent = await fixture( + html``, + ); + el = parent.shadowRoot!.querySelector( + 'webstatus-form-date-range-picker', + )!; + }); + + it('should render the date range picker with default values', () => { + const startDateInput = el.startDateEl!; + const endDateInput = el.endDateEl!; + + expect(startDateInput).to.exist; + expect(endDateInput).to.exist; + expect(el.submitBtn).to.exist; + expect(startDateInput.valueAsDate).to.deep.equal(el.startDate); + expect(endDateInput.valueAsDate).to.deep.equal(el.endDate); + expect(startDateInput.min).to.equal(el.toIsoDate(el.minimumDate)); + expect(startDateInput.max).to.equal(el.toIsoDate(el.endDate)); + expect(endDateInput.min).to.equal(el.toIsoDate(el.startDate)); + expect(endDateInput.max).to.equal(el.toIsoDate(el.maximumDate)); + }); + + describe('Initialization Validation', () => { + it('should throw an error if minimumDate is not provided', async () => { + try { + await fixture( + html``, + ); + throw new Error('Expected an error to be thrown'); + } catch (error) { + expect((error as Error).message).to.eq( + 'WebstatusFormDateRangePicker: minimumDate, maximumDate, startDate, and endDate are required properties.', + ); + } + }); + it('should throw an error if maximumDate is not provided', async () => { + try { + await fixture( + html``, + ); + throw new Error('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.eq( + 'WebstatusFormDateRangePicker: minimumDate, maximumDate, startDate, and endDate are required properties.', + ); + } + }); + + it('should throw an error if startDate is not provided', async () => { + try { + await fixture( + html``, + ); + throw new Error('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.eq( + 'WebstatusFormDateRangePicker: minimumDate, maximumDate, startDate, and endDate are required properties.', + ); + } + }); + + it('should throw an error if endDate is not provided', async () => { + try { + await fixture( + html``, + ); + throw new Error('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.eq( + 'WebstatusFormDateRangePicker: minimumDate, maximumDate, startDate, and endDate are required properties.', + ); + } + }); + }); + + describe('showPicker', () => { + it('should call showPicker on the startDateEl when clicked', async () => { + // Stub showPicker to avoid the "NotAllowedError" in the unit test + // since showPicker requires a user gesture. + const showPickerStub = sinon.stub(el.startDateEl!, 'showPicker'); // Stub showPicker on startDateEl + el.startDateEl?.click(); + expect(showPickerStub.calledOnce).to.be.true; + }); + + it('should call showPicker on the endDateEl when clicked', async () => { + // Stub showPicker to avoid the "NotAllowedError" in the unit test + // since showPicker requires a user gesture. + const showPickerStub = sinon.stub(el.endDateEl!, 'showPicker'); // Stub showPicker on endDateEl + el.endDateEl?.click(); + expect(showPickerStub.calledOnce).to.be.true; + }); + }); + + describe('Date Range Validation and Events', () => { + it('should update both dates and emit a single event when valid dates are entered', async () => { + expect(el.submitBtn?.disabled).to.be.true; + const newStartDate = new Date(2023, 5, 15); + const newEndDate = new Date(2023, 10, 16); + + el.startDateEl!.valueAsDate = newStartDate; + el.endDateEl!.valueAsDate = newEndDate; + await el.updateComplete; + await parent.updateComplete; + el.startDateEl!.dispatchEvent(new Event('sl-change')); + el.endDateEl!.dispatchEvent(new Event('sl-change')); + await el.updateComplete; + await parent.updateComplete; + + // Simulate button click to submit + expect(el.submitBtn?.disabled).to.be.false; + el.submitBtn?.click(); + await el.updateComplete; + await parent.updateComplete; + expect(el.submitBtn?.disabled).to.be.true; + + expect(parent.startDate).to.deep.equal(newStartDate); + expect(parent.endDate).to.deep.equal(newEndDate); + }); + + it('should not emit an event if no changes were made', async () => { + // Button should be disabled. + expect(el.submitBtn?.disabled).to.be.true; + el.submitBtn?.click(); + await el.updateComplete; + await parent.updateComplete; + + // Parent's start and end dates should be undefined + expect(parent.startDate).to.be.undefined; + expect(parent.endDate).to.be.undefined; + }); + + it('should not update if the start date is invalid', async () => { + expect(el.submitBtn?.disabled).to.be.true; + const newStartDate = new Date('invalid'); + + el.startDateEl!.valueAsDate = newStartDate; + await el.updateComplete; + await parent.updateComplete; + el.startDateEl!.dispatchEvent(new Event('sl-change')); + el.endDateEl!.dispatchEvent(new Event('sl-change')); + await el.updateComplete; + await parent.updateComplete; + + // Button should still be disabled + expect(el.submitBtn?.disabled).to.be.true; + + // Parent's start and end dates should be undefined + expect(parent.startDate).to.be.undefined; + expect(parent.endDate).to.be.undefined; + }); + + it('should not update if the end date is invalid', async () => { + expect(el.submitBtn?.disabled).to.be.true; + const newEndDate = new Date('invalid'); + + el.endDateEl!.valueAsDate = newEndDate; + await el.updateComplete; + await parent.updateComplete; + el.startDateEl!.dispatchEvent(new Event('sl-change')); + el.endDateEl!.dispatchEvent(new Event('sl-change')); + await el.updateComplete; + await parent.updateComplete; + + // Button should still be disabled + expect(el.submitBtn?.disabled).to.be.true; + + // Parent's start and end dates should be undefined + expect(parent.startDate).to.be.undefined; + expect(parent.endDate).to.be.undefined; + }); + + it('should not update if the start date is after the end date', async () => { + expect(el.submitBtn?.disabled).to.be.true; + const newStartDate = new Date(2024, 10, 15); + el.startDateEl!.valueAsDate = newStartDate; + await el.updateComplete; + await parent.updateComplete; + el.startDateEl!.dispatchEvent(new Event('sl-change')); + el.endDateEl!.dispatchEvent(new Event('sl-change')); + await el.updateComplete; + await parent.updateComplete; + + expect(el.submitBtn?.disabled).to.be.true; + + // Parent's start and end dates should be undefined + expect(parent.startDate).to.be.undefined; + expect(parent.endDate).to.be.undefined; + }); + }); +}); diff --git a/frontend/src/static/js/components/webstatus-form-date-range-picker.ts b/frontend/src/static/js/components/webstatus-form-date-range-picker.ts new file mode 100644 index 00000000..d4cc295e --- /dev/null +++ b/frontend/src/static/js/components/webstatus-form-date-range-picker.ts @@ -0,0 +1,233 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + SlButton, + SlChangeEvent, + SlInput, + SlInputEvent, +} from '@shoelace-style/shoelace'; +import {CSSResultGroup, LitElement, PropertyValues, css, html} from 'lit'; +import {customElement, property, query, state} from 'lit/decorators.js'; +import {SHARED_STYLES} from '../css/shared-css.js'; + +export interface DateRangeChangeEvent { + startDate: Date; + endDate: Date; +} + +/** + * @summary Date range picker + * @event CustomEvent webstatus-date-range-change - Emitted when the the date range is changed. + * @property {Date} minimumDate - The minimum selectable date. **Required.** + * @property {Date} maximumDate - The maximum selectable date. **Required.** + * @property {Date} startDate - The initial start date for the range. **Required.** + * @property {Date} endDate - The initial end date for the range. **Required.** + */ +@customElement('webstatus-form-date-range-picker') +export class WebstatusFormDateRangePicker extends LitElement { + @property({type: Object}) + minimumDate?: Date; + + @property({type: Object}) + maximumDate?: Date; + + @property({type: Object}) + startDate?: Date; + + @property({type: Object}) + endDate?: Date; + + @query('#start-date') + readonly startDateEl?: SlInput; + + @query('#end-date') + readonly endDateEl?: SlInput; + + @query('#date-range-picker-btn') + readonly submitBtn?: SlButton; + + @state() + private _startHasChanged = false; + + @state() + private _endHasChanged = false; + + updated(changedProperties: PropertyValues) { + if ( + (changedProperties.has('minimumDate') || + changedProperties.has('maximumDate') || + changedProperties.has('startDate') || + changedProperties.has('endDate')) && + (!this.minimumDate || + !this.maximumDate || + !this.startDate || + !this.endDate) + ) { + const errorMessage = + 'WebstatusFormDateRangePicker: minimumDate, maximumDate, startDate, and endDate are required properties.'; + // Print the error and throw an error. + console.error(errorMessage); + throw new Error(errorMessage); + } + } + + isValidDate(d: Date): boolean { + return !isNaN(d.getTime()); + } + + toIsoDate(date?: Date): string { + return date?.toISOString().slice(0, 10) ?? ''; + } + + static get styles(): CSSResultGroup { + return [ + SHARED_STYLES, + css` + .hbox, + .vbox { + gap: var(--content-padding-large); + } + #date-range-picker-btn { + justify-content: center; + margin-top: var(--sl-input-label-font-size-medium); + } + `, + ]; + } + + showPicker(input?: SlInput) { + input?.showPicker(); + } + + async handleStartDateChange(_: SlChangeEvent) { + const newStartDate = new Date(this.startDateEl?.valueAsDate || ''); + if ( + !this.isValidDate(newStartDate) || + (this.minimumDate && this.minimumDate > newStartDate) || + (this.endDate && this.endDate < newStartDate) + ) { + this.startDateEl?.setCustomValidity( + `Date range should be ${this.toIsoDate(this.minimumDate)} to ${this.toIsoDate(this.endDate)} inclusive`, + ); + this.startDateEl?.reportValidity(); + this._startHasChanged = false; + return; + } + + const currentStartDate = this.startDate; + if (newStartDate.getTime() !== currentStartDate?.getTime()) { + this.startDateEl?.setCustomValidity(''); + this.startDateEl?.reportValidity(); + this.startDate = newStartDate; + this._startHasChanged = true; + } + } + + async handleEndDateChange(_: SlInputEvent) { + const newEndDate = new Date(this.endDateEl?.valueAsDate || ''); + if ( + !this.isValidDate(newEndDate) || + (this.startDate && this.startDate > newEndDate) || + (this.maximumDate && this.maximumDate < newEndDate) + ) { + this.endDateEl?.setCustomValidity( + `Date range should be ${this.toIsoDate(this.startDate)} to ${this.toIsoDate(this.maximumDate)} inclusive`, + ); + this.endDateEl?.reportValidity(); + this._endHasChanged = false; + return; + } + + const currentEndDate = this.endDate; + if (newEndDate.getTime() !== currentEndDate?.getTime()) { + this.endDateEl?.setCustomValidity(''); + this.endDateEl?.reportValidity(); + this.endDate = newEndDate; + this._endHasChanged = true; + } + } + + handleSubmit() { + // Reset pending flags + this._startHasChanged = false; + this._endHasChanged = false; + + if (!this.startDate || !this.endDate) return; + + // Dispatch a single event with both dates + const event = new CustomEvent( + 'webstatus-date-range-change', + { + detail: { + startDate: this.startDate, + endDate: this.endDate, + }, + }, + ); + this.dispatchEvent(event); + } + + isSubmitButtonEnabled() { + // Only enable the button if there component has validated new date(s) that + // are ready to be emitted. + return this._startHasChanged || this._endHasChanged; + } + render() { + return html` +
+ + + + + +
+ `; + } +} diff --git a/frontend/web-test-runner.config.mjs b/frontend/web-test-runner.config.mjs index e4a6d8bf..264fa646 100644 --- a/frontend/web-test-runner.config.mjs +++ b/frontend/web-test-runner.config.mjs @@ -19,6 +19,8 @@ const filteredLogs = [ 'Lit is in dev mode', // sl-tree-item has its own reactivity that we cannot control. Ignore for now. 'Element sl-tree-item scheduled an update', + // From the date range picker + 'WebstatusFormDateRangePicker: minimumDate, maximumDate, startDate, and endDate are required properties.', ]; export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({