Skip to content

Commit b9e031f

Browse files
authored
Add webstatus-form-date-range-picker component (#1056)
* Add webstatus-form-date-range-picker component This component will be a reusable component for date range picking. This takes mostly the implementation used on the feature detail page and puts it into this component. Notable differences: - Add max and min constraints native <input> HTML constraints. - Displaying messages for invalid input. - Reset invalid input back to a sane default. - Changed sl-input listener from sl-change to sl-blur. This is mostly for users that want to type in the date instead of using the date picker. If a user types in the date, the validation logic might happen too soon and reset it back to a default value. We need a combination debouncing. Now all users will need to click outside of the box once they are done. Future PRs: - Replace the individual implementation of date pickers on the feature and detail page with this. - Re-add sl-change event listener with debounce. Similar to [chromestatus](https://github.com/GoogleChrome/chromium-dashboard/blob/6419c03bac0ae71af722ab6e986952ea1e843c9e/client-src/js-src/features-page.js#L24-L34) * like gcp * more changes to date range picker * check required properties * rename start & end change detection variables
1 parent 6c1dc81 commit b9e031f

File tree

3 files changed

+500
-0
lines changed

3 files changed

+500
-0
lines changed
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/**
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {expect, fixture, html} from '@open-wc/testing';
18+
import {
19+
WebstatusFormDateRangePicker,
20+
DateRangeChangeEvent,
21+
} from '../webstatus-form-date-range-picker.js';
22+
import {customElement, property} from 'lit/decorators.js';
23+
import {LitElement} from 'lit';
24+
import '../webstatus-form-date-range-picker.js';
25+
import '@shoelace-style/shoelace/dist/components/input/input.js';
26+
import '@shoelace-style/shoelace/dist/components/button/button.js';
27+
import sinon from 'sinon';
28+
29+
// TestComponent to listen for events from WebstatusFormDateRangePicker
30+
@customElement('test-component')
31+
class TestComponent extends LitElement {
32+
@property({type: Object}) startDate: Date | undefined;
33+
@property({type: Object}) endDate: Date | undefined;
34+
35+
handleDateRangeChange(event: CustomEvent<DateRangeChangeEvent>) {
36+
this.startDate = event.detail.startDate;
37+
this.endDate = event.detail.endDate;
38+
}
39+
40+
render() {
41+
return html`
42+
<webstatus-form-date-range-picker
43+
.minimumDate=${new Date(2023, 0, 1)}
44+
.maximumDate=${new Date(2024, 11, 31)}
45+
.startDate=${new Date(2023, 5, 1)}
46+
.endDate=${new Date(2023, 10, 31)}
47+
@webstatus-date-range-change=${this.handleDateRangeChange}
48+
></webstatus-form-date-range-picker>
49+
`;
50+
}
51+
}
52+
53+
describe('WebstatusFormDateRangePicker', () => {
54+
let parent: TestComponent;
55+
let el: WebstatusFormDateRangePicker;
56+
57+
beforeEach(async () => {
58+
// Create the parent component, which now renders the date picker
59+
parent = await fixture<TestComponent>(
60+
html`<test-component></test-component>`,
61+
);
62+
el = parent.shadowRoot!.querySelector<WebstatusFormDateRangePicker>(
63+
'webstatus-form-date-range-picker',
64+
)!;
65+
});
66+
67+
it('should render the date range picker with default values', () => {
68+
const startDateInput = el.startDateEl!;
69+
const endDateInput = el.endDateEl!;
70+
71+
expect(startDateInput).to.exist;
72+
expect(endDateInput).to.exist;
73+
expect(el.submitBtn).to.exist;
74+
expect(startDateInput.valueAsDate).to.deep.equal(el.startDate);
75+
expect(endDateInput.valueAsDate).to.deep.equal(el.endDate);
76+
expect(startDateInput.min).to.equal(el.toIsoDate(el.minimumDate));
77+
expect(startDateInput.max).to.equal(el.toIsoDate(el.endDate));
78+
expect(endDateInput.min).to.equal(el.toIsoDate(el.startDate));
79+
expect(endDateInput.max).to.equal(el.toIsoDate(el.maximumDate));
80+
});
81+
82+
describe('Initialization Validation', () => {
83+
it('should throw an error if minimumDate is not provided', async () => {
84+
try {
85+
await fixture(
86+
html`<webstatus-form-date-range-picker
87+
.maximumDate="${new Date('2024-01-01')}"
88+
.startDate="${new Date('2023-01-01')}"
89+
.endDate="${new Date('2023-12-31')}"
90+
></webstatus-form-date-range-picker>`,
91+
);
92+
throw new Error('Expected an error to be thrown');
93+
} catch (error) {
94+
expect((error as Error).message).to.eq(
95+
'WebstatusFormDateRangePicker: minimumDate, maximumDate, startDate, and endDate are required properties.',
96+
);
97+
}
98+
});
99+
it('should throw an error if maximumDate is not provided', async () => {
100+
try {
101+
await fixture(
102+
html`<webstatus-form-date-range-picker
103+
.minimumDate="${new Date('2023-01-01')}"
104+
.startDate="${new Date('2023-01-01')}"
105+
.endDate="${new Date('2023-12-31')}"
106+
></webstatus-form-date-range-picker>`,
107+
);
108+
throw new Error('Expected an error to be thrown');
109+
} catch (error: unknown) {
110+
expect((error as Error).message).to.eq(
111+
'WebstatusFormDateRangePicker: minimumDate, maximumDate, startDate, and endDate are required properties.',
112+
);
113+
}
114+
});
115+
116+
it('should throw an error if startDate is not provided', async () => {
117+
try {
118+
await fixture(
119+
html`<webstatus-form-date-range-picker
120+
.minimumDate="${new Date('2023-01-01')}"
121+
.maximumDate="${new Date('2024-01-01')}"
122+
.endDate="${new Date('2023-12-31')}"
123+
></webstatus-form-date-range-picker>`,
124+
);
125+
throw new Error('Expected an error to be thrown');
126+
} catch (error: unknown) {
127+
expect((error as Error).message).to.eq(
128+
'WebstatusFormDateRangePicker: minimumDate, maximumDate, startDate, and endDate are required properties.',
129+
);
130+
}
131+
});
132+
133+
it('should throw an error if endDate is not provided', async () => {
134+
try {
135+
await fixture(
136+
html`<webstatus-form-date-range-picker
137+
.minimumDate="${new Date('2023-01-01')}"
138+
.maximumDate="${new Date('2024-01-01')}"
139+
.startDate="${new Date('2023-01-01')}"
140+
></webstatus-form-date-range-picker>`,
141+
);
142+
throw new Error('Expected an error to be thrown');
143+
} catch (error: unknown) {
144+
expect((error as Error).message).to.eq(
145+
'WebstatusFormDateRangePicker: minimumDate, maximumDate, startDate, and endDate are required properties.',
146+
);
147+
}
148+
});
149+
});
150+
151+
describe('showPicker', () => {
152+
it('should call showPicker on the startDateEl when clicked', async () => {
153+
// Stub showPicker to avoid the "NotAllowedError" in the unit test
154+
// since showPicker requires a user gesture.
155+
const showPickerStub = sinon.stub(el.startDateEl!, 'showPicker'); // Stub showPicker on startDateEl
156+
el.startDateEl?.click();
157+
expect(showPickerStub.calledOnce).to.be.true;
158+
});
159+
160+
it('should call showPicker on the endDateEl when clicked', async () => {
161+
// Stub showPicker to avoid the "NotAllowedError" in the unit test
162+
// since showPicker requires a user gesture.
163+
const showPickerStub = sinon.stub(el.endDateEl!, 'showPicker'); // Stub showPicker on endDateEl
164+
el.endDateEl?.click();
165+
expect(showPickerStub.calledOnce).to.be.true;
166+
});
167+
});
168+
169+
describe('Date Range Validation and Events', () => {
170+
it('should update both dates and emit a single event when valid dates are entered', async () => {
171+
expect(el.submitBtn?.disabled).to.be.true;
172+
const newStartDate = new Date(2023, 5, 15);
173+
const newEndDate = new Date(2023, 10, 16);
174+
175+
el.startDateEl!.valueAsDate = newStartDate;
176+
el.endDateEl!.valueAsDate = newEndDate;
177+
await el.updateComplete;
178+
await parent.updateComplete;
179+
el.startDateEl!.dispatchEvent(new Event('sl-change'));
180+
el.endDateEl!.dispatchEvent(new Event('sl-change'));
181+
await el.updateComplete;
182+
await parent.updateComplete;
183+
184+
// Simulate button click to submit
185+
expect(el.submitBtn?.disabled).to.be.false;
186+
el.submitBtn?.click();
187+
await el.updateComplete;
188+
await parent.updateComplete;
189+
expect(el.submitBtn?.disabled).to.be.true;
190+
191+
expect(parent.startDate).to.deep.equal(newStartDate);
192+
expect(parent.endDate).to.deep.equal(newEndDate);
193+
});
194+
195+
it('should not emit an event if no changes were made', async () => {
196+
// Button should be disabled.
197+
expect(el.submitBtn?.disabled).to.be.true;
198+
el.submitBtn?.click();
199+
await el.updateComplete;
200+
await parent.updateComplete;
201+
202+
// Parent's start and end dates should be undefined
203+
expect(parent.startDate).to.be.undefined;
204+
expect(parent.endDate).to.be.undefined;
205+
});
206+
207+
it('should not update if the start date is invalid', async () => {
208+
expect(el.submitBtn?.disabled).to.be.true;
209+
const newStartDate = new Date('invalid');
210+
211+
el.startDateEl!.valueAsDate = newStartDate;
212+
await el.updateComplete;
213+
await parent.updateComplete;
214+
el.startDateEl!.dispatchEvent(new Event('sl-change'));
215+
el.endDateEl!.dispatchEvent(new Event('sl-change'));
216+
await el.updateComplete;
217+
await parent.updateComplete;
218+
219+
// Button should still be disabled
220+
expect(el.submitBtn?.disabled).to.be.true;
221+
222+
// Parent's start and end dates should be undefined
223+
expect(parent.startDate).to.be.undefined;
224+
expect(parent.endDate).to.be.undefined;
225+
});
226+
227+
it('should not update if the end date is invalid', async () => {
228+
expect(el.submitBtn?.disabled).to.be.true;
229+
const newEndDate = new Date('invalid');
230+
231+
el.endDateEl!.valueAsDate = newEndDate;
232+
await el.updateComplete;
233+
await parent.updateComplete;
234+
el.startDateEl!.dispatchEvent(new Event('sl-change'));
235+
el.endDateEl!.dispatchEvent(new Event('sl-change'));
236+
await el.updateComplete;
237+
await parent.updateComplete;
238+
239+
// Button should still be disabled
240+
expect(el.submitBtn?.disabled).to.be.true;
241+
242+
// Parent's start and end dates should be undefined
243+
expect(parent.startDate).to.be.undefined;
244+
expect(parent.endDate).to.be.undefined;
245+
});
246+
247+
it('should not update if the start date is after the end date', async () => {
248+
expect(el.submitBtn?.disabled).to.be.true;
249+
const newStartDate = new Date(2024, 10, 15);
250+
el.startDateEl!.valueAsDate = newStartDate;
251+
await el.updateComplete;
252+
await parent.updateComplete;
253+
el.startDateEl!.dispatchEvent(new Event('sl-change'));
254+
el.endDateEl!.dispatchEvent(new Event('sl-change'));
255+
await el.updateComplete;
256+
await parent.updateComplete;
257+
258+
expect(el.submitBtn?.disabled).to.be.true;
259+
260+
// Parent's start and end dates should be undefined
261+
expect(parent.startDate).to.be.undefined;
262+
expect(parent.endDate).to.be.undefined;
263+
});
264+
});
265+
});

0 commit comments

Comments
 (0)