• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IgniteUI / igniteui-angular / 17265029097

27 Aug 2025 11:10AM UTC coverage: 91.587% (+0.01%) from 91.577%
17265029097

Pull #16115

github

web-flow
Merge ffd43f858 into 308e0a11e
Pull Request #16115: Clear icon enhancements for IgxDateRangePicker

13640 of 15980 branches covered (85.36%)

23 of 24 new or added lines in 2 files covered. (95.83%)

5 existing lines in 1 file now uncovered.

27478 of 30002 relevant lines covered (91.59%)

34319.53 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.95
/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.ts
1
import {
2
    AfterViewInit, booleanAttribute, ChangeDetectorRef, Component, ContentChild, ContentChildren, ElementRef,
3
    EventEmitter, HostBinding, HostListener, Inject, Injector, Input, LOCALE_ID,
4
    OnChanges, OnDestroy, OnInit, Optional, Output, QueryList,
5
    SimpleChanges, TemplateRef, ViewChild, ViewContainerRef
6
} from '@angular/core';
7
import { NgTemplateOutlet, getLocaleFirstDayOfWeek } from '@angular/common';
8
import {
9
    AbstractControl, ControlValueAccessor, NgControl,
10
    NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator
11
} from '@angular/forms';
12

13
import { fromEvent, merge, MonoTypeOperatorFunction, noop, Subscription } from 'rxjs';
14
import { filter, takeUntil } from 'rxjs/operators';
15

16
import { CalendarSelection, IgxCalendarComponent } from '../calendar/public_api';
17
import { DateRangeType } from '../core/dates';
18
import { DateRangePickerResourceStringsEN, IDateRangePickerResourceStrings } from '../core/i18n/date-range-picker-resources';
19
import { IBaseCancelableBrowserEventArgs, isDate, parseDate, PlatformUtil } from '../core/utils';
20
import { IgxCalendarContainerComponent } from '../date-common/calendar-container/calendar-container.component';
21
import { PickerBaseDirective } from '../date-common/picker-base.directive';
22
import { IgxPickerActionsDirective } from '../date-common/picker-icons.common';
23
import { DateTimeUtil } from '../date-common/util/date-time.util';
24
import { IgxOverlayOutletDirective } from '../directives/toggle/toggle.directive';
25
import {
26
    IgxInputDirective, IgxInputGroupComponent, IgxInputGroupType, IgxInputState,
27
    IgxLabelDirective, IGX_INPUT_GROUP_TYPE, IgxSuffixDirective
28
} from '../input-group/public_api';
29
import {
30
    AutoPositionStrategy, IgxOverlayService, OverlayCancelableEventArgs, OverlayEventArgs,
31
    OverlaySettings, PositionSettings
32
} from '../services/public_api';
33
import { DateRange, IgxDateRangeEndComponent, IgxDateRangeInputsBaseComponent, IgxDateRangeSeparatorDirective, IgxDateRangeStartComponent, DateRangePickerFormatPipe, CustomDateRange } from './date-range-picker-inputs.common';
34
import { IgxPrefixDirective } from '../directives/prefix/prefix.directive';
35
import { IgxIconComponent } from '../icon/icon.component';
36
import { getCurrentResourceStrings } from '../core/i18n/resources';
37
import { fadeIn, fadeOut } from 'igniteui-angular/animations';
38

39
const SingleInputDatesConcatenationString = ' - ';
3✔
40

41
/**
42
 * Provides the ability to select a range of dates from a calendar UI or editable inputs.
43
 *
44
 * @igxModule IgxDateRangeModule
45
 *
46
 * @igxTheme igx-input-group-theme, igx-calendar-theme, igx-date-range-picker-theme
47
 *
48
 * @igxKeywords date, range, date range, date picker
49
 *
50
 * @igxGroup scheduling
51
 *
52
 * @remarks
53
 * It displays the range selection in a single or two input fields.
54
 * The default template displays a single *readonly* input field
55
 * while projecting `igx-date-range-start` and `igx-date-range-end`
56
 * displays two *editable* input fields.
57
 *
58
 * @example
59
 * ```html
60
 * <igx-date-range-picker mode="dropdown"></igx-date-range-picker>
61
 * ```
62
 */
63
@Component({
64
    selector: 'igx-date-range-picker',
65
    templateUrl: './date-range-picker.component.html',
66
    providers: [
67
        { provide: NG_VALUE_ACCESSOR, useExisting: IgxDateRangePickerComponent, multi: true },
68
        { provide: NG_VALIDATORS, useExisting: IgxDateRangePickerComponent, multi: true }
69
    ],
70
    imports: [
71
        NgTemplateOutlet,
72
        IgxIconComponent,
73
        IgxInputGroupComponent,
74
        IgxInputDirective,
75
        IgxPrefixDirective,
76
        IgxSuffixDirective,
77
        DateRangePickerFormatPipe
78
    ]
79
})
80
export class IgxDateRangePickerComponent extends PickerBaseDirective
3✔
81
    implements OnChanges, OnInit, AfterViewInit, OnDestroy, ControlValueAccessor, Validator {
82

83
    /**
84
     * The number of displayed month views.
85
     *
86
     * @remarks
87
     * Default is `2`.
88
     *
89
     * @example
90
     * ```html
91
     * <igx-date-range-picker [displayMonthsCount]="3"></igx-date-range-picker>
92
     * ```
93
     */
94
    @Input()
95
    public displayMonthsCount = 2;
105✔
96

97
    /**
98
     * Gets/Sets whether dates that are not part of the current month will be displayed.
99
     *
100
     * @remarks
101
     * Default value is `false`.
102
     *
103
     * @example
104
     * ```html
105
     * <igx-date-range-picker [hideOutsideDays]="true"></igx-date-range-picker>
106
     * ```
107
     */
108
    @Input({ transform: booleanAttribute })
109
    public hideOutsideDays: boolean;
110

111
    /**
112
     * A custom formatter function, applied on the selected or passed in date.
113
     *
114
     * @example
115
     * ```typescript
116
     * private dayFormatter = new Intl.DateTimeFormat("en", { weekday: "long" });
117
     * private monthFormatter = new Intl.DateTimeFormat("en", { month: "long" });
118
     *
119
     * public formatter(date: Date): string {
120
     *  return `${this.dayFormatter.format(date)} - ${this.monthFormatter.format(date)} - ${date.getFullYear()}`;
121
     * }
122
     * ```
123
     * ```html
124
     * <igx-date-range-picker [formatter]="formatter"></igx-date-range-picker>
125
     * ```
126
     */
127
    @Input()
128
    public formatter: (val: DateRange) => string;
129

130
    /**
131
     * Overrides the default text of the calendar dialog **Done** button.
132
     *
133
     * @remarks
134
     * Defaults to the value from resource strings, `"Done"` for the built-in EN.
135
     * The button will only show up in `dialog` mode.
136
     *
137
     * @example
138
     * ```html
139
     * <igx-date-range-picker doneButtonText="完了"></igx-date-range-picker>
140
     * ```
141
     */
142
    @Input()
143
    public set doneButtonText(value: string) {
144
        this._doneButtonText = value;
1✔
145
    }
146

147
    public get doneButtonText(): string {
148
        if (this._doneButtonText === null) {
9✔
149
            return this.resourceStrings.igx_date_range_picker_done_button;
8✔
150
        }
151
        return this._doneButtonText;
1✔
152
    }
153
    /**
154
     * Overrides the default text of the calendar dialog **Cancel** button.
155
     *
156
     * @remarks
157
     * Defaults to the value from resource strings, `"Cancel"` for the built-in EN.
158
     * The button will only show up in `dialog` mode.
159
     *
160
     * @example
161
     * ```html
162
     * <igx-date-range-picker cancelButtonText="取消"></igx-date-range-picker>
163
     * ```
164
     */
165
    @Input()
166
    public set cancelButtonText(value: string) {
167
        this._cancelButtonText = value;
1✔
168
    }
169

170
    public get cancelButtonText(): string {
171
        if (this._cancelButtonText === null) {
9✔
172
            return this.resourceStrings.igx_date_range_picker_cancel_button;
8✔
173
        }
174
        return this._cancelButtonText;
1✔
175
    }
176
    /**
177
     * Custom overlay settings that should be used to display the calendar.
178
     *
179
     * @example
180
     * ```html
181
     * <igx-date-range-picker [overlaySettings]="customOverlaySettings"></igx-date-range-picker>
182
     * ```
183
     */
184
    @Input()
185
    public override overlaySettings: OverlaySettings;
186

187
    /**
188
     * The format used when editable inputs are not focused.
189
     *
190
     * @remarks
191
     * Uses Angular's DatePipe.
192
     *
193
     * @example
194
     * ```html
195
     * <igx-date-range-picker displayFormat="EE/M/yy"></igx-date-range-picker>
196
     * ```
197
     *
198
     */
199
    @Input()
200
    public override displayFormat: string;
201

202
    /**
203
     * The expected user input format and placeholder.
204
     *
205
     * @example
206
     * ```html
207
     * <igx-date-range-picker inputFormat="dd/MM/yy"></igx-date-range-picker>
208
     * ```
209
     */
210
    @Input()
211
    public override inputFormat: string;
212

213
    /**
214
     * The minimum value in a valid range.
215
     *
216
     * @example
217
     * <igx-date-range-picker [minValue]="minDate"></igx-date-range-picker>
218
     */
219
    @Input()
220
    public set minValue(value: Date | string) {
221
        this._minValue = value;
34✔
222
        this.onValidatorChange();
34✔
223
    }
224

225
    public get minValue(): Date | string {
226
        return this._minValue;
140✔
227
    }
228

229
    /**
230
     * The maximum value in a valid range.
231
     *
232
     * @example
233
     * <igx-date-range-picker [maxValue]="maxDate"></igx-date-range-picker>
234
     */
235
    @Input()
236
    public set maxValue(value: Date | string) {
237
        this._maxValue = value;
34✔
238
        this.onValidatorChange();
34✔
239
    }
240

241
    public get maxValue(): Date | string {
242
        return this._maxValue;
140✔
243
    }
244

245
    /**
246
     * An accessor that sets the resource strings.
247
     * By default it uses EN resources.
248
     */
249
    @Input()
250
    public set resourceStrings(value: IDateRangePickerResourceStrings) {
251
        this._resourceStrings = Object.assign({}, this._resourceStrings, value);
1✔
252
    }
253

254
    /**
255
     * An accessor that returns the resource strings.
256
     */
257
    public get resourceStrings(): IDateRangePickerResourceStrings {
258
        return this._resourceStrings;
128✔
259
    }
260

261
    /**
262
     * Sets the `placeholder` for single-input `IgxDateRangePickerComponent`.
263
     *
264
     *   @example
265
     * ```html
266
     * <igx-date-range-picker [placeholder]="'Choose your dates'"></igx-date-range-picker>
267
     * ```
268
     */
269
    @Input()
270
    public override placeholder = '';
105✔
271

272
    /**
273
     * Gets/Sets the container used for the popup element.
274
     *
275
     * @remarks
276
     *  `outlet` is an instance of `IgxOverlayOutletDirective` or an `ElementRef`.
277
     * @example
278
     * ```html
279
     * <div igxOverlayOutlet #outlet="overlay-outlet"></div>
280
     * //..
281
     * <igx-date-range-picker [outlet]="outlet"></igx-date-range-picker>
282
     * //..
283
     * ```
284
     */
285
    @Input()
286
    public override outlet: IgxOverlayOutletDirective | ElementRef<any>;
287

288
    /**
289
     * Show/hide week numbers
290
     *
291
     * @remarks
292
     * Default is `false`.
293
     *
294
     * @example
295
     * ```html
296
     * <igx-date-range-picker [showWeekNumbers]="true"></igx-date-range-picker>
297
     * ``
298
     */
299
    @Input({ transform: booleanAttribute })
300
    public showWeekNumbers = false;
105✔
301

302
    /**
303
     * Emitted when the picker's value changes. Used for two-way binding.
304
     *
305
     * @example
306
     * ```html
307
     * <igx-date-range-picker [(value)]="date"></igx-date-range-picker>
308
     * ```
309
     */
310

311
     /**
312
      * Whether to render built-in predefined ranges.
313
      *
314
      * @example
315
      * ```html
316
      * <igx-date-range-picker [(usePredefinedRanges)]="true"></igx-date-range-picker>
317
      * ``
318
      *  */
319
    @Input() public usePredefinedRanges = false;
105✔
320

321
    /**
322
     *  Custom ranges rendered as chips.
323
     *
324
     * @example
325
     * ```html
326
     * <igx-date-range-picker [(usePredefinedRanges)]="true"></igx-date-range-picker>
327
     * ``
328
    */
329
    @Input() public customRanges: CustomDateRange[] = [];
105✔
330

331
    @Output()
332
    public valueChange = new EventEmitter<DateRange>();
105✔
333

334
    /** @hidden @internal */
335
    @HostBinding('class.igx-date-range-picker')
336
    public cssClass = 'igx-date-range-picker';
105✔
337

338
    @ViewChild(IgxInputGroupComponent, { read: ViewContainerRef })
339
    private viewContainerRef: ViewContainerRef;
340

341
    /** @hidden @internal */
342
    @ViewChild(IgxInputDirective)
343
    public inputDirective: IgxInputDirective;
344

345
    /** @hidden @internal */
346
    @ContentChildren(IgxDateRangeInputsBaseComponent)
347
    public projectedInputs: QueryList<IgxDateRangeInputsBaseComponent>;
348

349
    @ContentChild(IgxLabelDirective)
350
    public label: IgxLabelDirective;
351

352
    @ContentChild(IgxPickerActionsDirective)
353
    public pickerActions: IgxPickerActionsDirective;
354

355
    /** @hidden @internal */
356
    @ContentChild(IgxDateRangeSeparatorDirective, { read: TemplateRef })
357
    public dateSeparatorTemplate: TemplateRef<any>;
358

359
    /** @hidden @internal */
360
    public get dateSeparator(): string {
361
        if (this._dateSeparator === null) {
68✔
362
            return this.resourceStrings.igx_date_range_picker_date_separator;
68✔
363
        }
364
        return this._dateSeparator;
×
365
    }
366

367
    /** @hidden @internal */
368
    public get appliedFormat(): string {
369
        return DateTimeUtil.getLocaleDateFormat(this.locale, this.displayFormat)
726✔
370
            || DateTimeUtil.DEFAULT_INPUT_FORMAT;
371
    }
372

373
    /**
374
     * @example
375
     * ```html
376
     * <igx-date-range-picker locale="jp"></igx-date-range-picker>
377
     * ```
378
     */
379
    /**
380
     * Gets the `locale` of the date-range-picker.
381
     * If not set, defaults to application's locale.
382
     */
383
    @Input()
384
    public override get locale(): string {
385
        return this._locale;
1,483✔
386
    }
387

388
    /**
389
     * Sets the `locale` of the date-picker.
390
     * Expects a valid BCP 47 language tag.
391
     */
392
    public override set locale(value: string) {
393
        this._locale = value;
319✔
394
        // if value is invalid, set it back to _localeId
395
        try {
319✔
396
            getLocaleFirstDayOfWeek(this._locale);
319✔
397
        } catch (e) {
398
            this._locale = this._localeId;
1✔
399
        }
400
        if (this.hasProjectedInputs) {
319✔
401
            this.updateInputLocale();
3✔
402
            this.updateDisplayFormat();
3✔
403
        }
404
    }
405

406
    /** @hidden @internal */
407
    public get singleInputFormat(): string {
408
        if (this.placeholder !== '') {
336✔
409
            return this.placeholder;
2✔
410
        }
411

412
        const format = this.appliedFormat;
334✔
413
        return `${format}${SingleInputDatesConcatenationString}${format}`;
334✔
414
    }
415

416
    /**
417
     * Gets calendar state.
418
     *
419
     * ```typescript
420
     * let state = this.dateRange.collapsed;
421
     * ```
422
     */
423
    public override get collapsed(): boolean {
424
        return this._collapsed;
577✔
425
    }
426

427
    /**
428
     * The currently selected value / range from the calendar
429
     *
430
     * @remarks
431
     * The current value is of type `DateRange`
432
     *
433
     * @example
434
     * ```typescript
435
     * const newValue: DateRange = { start: new Date("2/2/2012"), end: new Date("3/3/2013")};
436
     * this.dateRangePicker.value = newValue;
437
     * ```
438
     */
439
    public get value(): DateRange | null {
440
        return this._value;
1,686✔
441
    }
442

443
    @Input()
444
    public set value(value: DateRange | null) {
445
        this.updateValue(value);
100✔
446
        this.onChangeCallback(value);
100✔
447
        this.valueChange.emit(value);
100✔
448
    }
449

450
    /** @hidden @internal */
451
    public get hasProjectedInputs(): boolean {
452
        return this.projectedInputs?.length > 0;
2,502✔
453
    }
454

455
    /** @hidden @internal */
456
    public get separatorClass(): string {
457
        return 'igx-date-range-picker__label';
404✔
458
    }
459

460
    protected override get toggleContainer(): HTMLElement | undefined {
461
        return this._calendarContainer;
7✔
462
    }
463

464
    private get required(): boolean {
465
        if (this._ngControl && this._ngControl.control && this._ngControl.control.validator) {
201✔
466
            const error = this._ngControl.control.validator({} as AbstractControl);
147✔
467
            return (error && error.required) ? true : false;
147!
468
        }
469

470
        return false;
54✔
471
    }
472

473
    private get calendar(): IgxCalendarComponent {
474
        return this._calendar;
860✔
475
    }
476

477
    private get dropdownOverlaySettings(): OverlaySettings {
478
        return Object.assign({}, this._dropDownOverlaySettings, this.overlaySettings);
35✔
479
    }
480

481
    private get dialogOverlaySettings(): OverlaySettings {
482
        return Object.assign({}, this._dialogOverlaySettings, this.overlaySettings);
9✔
483
    }
484

485
    private _resourceStrings = getCurrentResourceStrings(DateRangePickerResourceStringsEN);
105✔
486
    private _doneButtonText = null;
105✔
487
    private _cancelButtonText = null;
105✔
488
    private _dateSeparator = null;
105✔
489
    private _value: DateRange | null;
490
    private _originalValue: DateRange | null;
491
    private _overlayId: string;
492
    private _ngControl: NgControl;
493
    private _statusChanges$: Subscription;
494
    private _calendar: IgxCalendarComponent;
495
    private _calendarContainer?: HTMLElement;
496
    private _positionSettings: PositionSettings;
497
    private _focusedInput: IgxDateRangeInputsBaseComponent;
498
    private _overlaySubFilter:
105✔
499
        [MonoTypeOperatorFunction<OverlayEventArgs>, MonoTypeOperatorFunction<OverlayEventArgs | OverlayCancelableEventArgs>] = [
500
            filter(x => x.id === this._overlayId),
139✔
501
            takeUntil(merge(this._destroy$, this.closed))
502
        ];
503
    private _dialogOverlaySettings: OverlaySettings = {
105✔
504
        closeOnOutsideClick: true,
505
        modal: true,
506
        closeOnEscape: true
507
    };
508
    private _dropDownOverlaySettings: OverlaySettings = {
105✔
509
        closeOnOutsideClick: true,
510
        modal: false,
511
        closeOnEscape: true
512
    };
513
    private onChangeCallback: (dateRange: DateRange) => void = noop;
105✔
514
    private onTouchCallback: () => void = noop;
105✔
515
    private onValidatorChange: () => void = noop;
105✔
516

517
    constructor(element: ElementRef,
518
        @Inject(LOCALE_ID) _localeId: string,
519
        protected platform: PlatformUtil,
105✔
520
        private _injector: Injector,
105✔
521
        private _cdr: ChangeDetectorRef,
105✔
522
        @Inject(IgxOverlayService) private _overlayService: IgxOverlayService,
105✔
523
        @Optional() @Inject(IGX_INPUT_GROUP_TYPE) _inputGroupType?: IgxInputGroupType) {
524
        super(element, _localeId, _inputGroupType);
105✔
525
        this.locale = this.locale || this._localeId;
105!
526
    }
527

528
    /** @hidden @internal */
529
    @HostListener('keydown', ['$event'])
530
    /** @hidden @internal */
531
    public onKeyDown(event: KeyboardEvent): void {
532
        switch (event.key) {
5!
533
            case this.platform.KEYMAP.ARROW_UP:
534
                if (event.altKey) {
×
535
                    this.close();
×
536
                }
537
                break;
×
538
            case this.platform.KEYMAP.ARROW_DOWN:
539
                if (event.altKey) {
5✔
540
                    this.open();
5✔
541
                }
542
                break;
5✔
543
        }
544
    }
545

546
    /**
547
     * Opens the date range picker's dropdown or dialog.
548
     *
549
     * @example
550
     * ```html
551
     * <igx-date-range-picker #dateRange></igx-date-range-picker>
552
     *
553
     * <button type="button" igxButton (click)="dateRange.open()">Open Dialog</button
554
     * ```
555
     */
556
    public open(overlaySettings?: OverlaySettings): void {
557
        if (!this.collapsed || this.disabled) {
49✔
558
            return;
5✔
559
        }
560

561
        this._originalValue = this._value
44✔
562
            ? { start: new Date(this._value.start), end: new Date(this._value.end) }
563
            : null;
564

565
        const settings = Object.assign({}, this.isDropdown
44✔
566
            ? this.dropdownOverlaySettings
567
            : this.dialogOverlaySettings
568
            , overlaySettings);
569

570
        this._overlayId = this._overlayService
44✔
571
            .attach(IgxCalendarContainerComponent, this.viewContainerRef, settings);
572
        this.subscribeToOverlayEvents();
44✔
573
        this._overlayService.show(this._overlayId);
44✔
574
    }
575

576
    /**
577
     * Closes the date range picker's dropdown or dialog.
578
     *
579
     * @example
580
     * ```html
581
     * <igx-date-range-picker #dateRange></igx-date-range-picker>
582
     *
583
     * <button type="button" igxButton (click)="dateRange.close()">Close Dialog</button>
584
     * ```
585
     */
586
    public close(): void {
587
        if (!this.collapsed) {
61✔
588
            this._overlayService.hide(this._overlayId);
44✔
589
        }
590
    }
591

592
    /**
593
     * Toggles the date range picker's dropdown or dialog
594
     *
595
     * @example
596
     * ```html
597
     * <igx-date-range-picker #dateRange></igx-date-range-picker>
598
     *
599
     * <button type="button" igxButton (click)="dateRange.toggle()">Toggle Dialog</button>
600
     * ```
601
     */
602
    public toggle(overlaySettings?: OverlaySettings): void {
603
        if (!this.collapsed) {
13✔
604
            this.close();
2✔
605
        } else {
606
            this.open(overlaySettings);
11✔
607
        }
608
    }
609

610
    /**
611
     * Selects a range of dates. If no `endDate` is passed, range is 1 day (only `startDate`)
612
     *
613
     * @example
614
     * ```typescript
615
     * public selectFiveDayRange() {
616
     *  const today = new Date();
617
     *  const inFiveDays = new Date(new Date().setDate(today.getDate() + 5));
618
     *  this.dateRange.select(today, inFiveDays);
619
     * }
620
     * ```
621
     */
622
    public select(startDate: Date, endDate?: Date): void {
623
        endDate = endDate ?? startDate;
25✔
624
        const dateRange = [startDate, endDate];
25✔
625
        this.handleSelection(dateRange);
25✔
626
    }
627

628
    /**
629
     * Clears the input field(s) and the picker's value.
630
     *
631
     * @example
632
     * ```typescript
633
     * this.dateRangePicker.clear();
634
     * ```
635
     */
636
    public clear(): void {
637
        if (this.disabled) {
4!
NEW
UNCOV
638
            return;
×
639
        }
640

641
        this.value = null;
4✔
642
        this._calendar?.deselectDate();
4✔
643
        if (this.hasProjectedInputs) {
4✔
644
            this.projectedInputs.forEach((i) => {
2✔
645
                i.inputDirective.clear();
4✔
646
            });
647
        } else {
648
            this.inputDirective.clear();
2✔
649
        }
650
    }
651

652
    /** @hidden @internal */
653
    public writeValue(value: DateRange): void {
654
        this.updateValue(value);
90✔
655
    }
656

657
    /** @hidden @internal */
658
    public registerOnChange(fn: any): void {
659
        this.onChangeCallback = fn;
55✔
660
    }
661

662
    /** @hidden @internal */
663
    public registerOnTouched(fn: any): void {
664
        this.onTouchCallback = fn;
54✔
665
    }
666

667
    /** @hidden @internal */
668
    public validate(control: AbstractControl): ValidationErrors | null {
669
        const value: DateRange = control.value;
285✔
670
        const errors = {};
285✔
671
        if (value) {
285✔
672
            if (this.hasProjectedInputs) {
58✔
673
                const startInput = this.projectedInputs.find(i => i instanceof IgxDateRangeStartComponent) as IgxDateRangeStartComponent;
55✔
674
                const endInput = this.projectedInputs.find(i => i instanceof IgxDateRangeEndComponent) as IgxDateRangeEndComponent;
110✔
675
                if (!startInput.dateTimeEditor.value) {
55!
UNCOV
676
                    Object.assign(errors, { startValue: true });
×
677
                }
678
                if (!endInput.dateTimeEditor.value) {
55✔
679
                    Object.assign(errors, { endValue: true });
1✔
680
                }
681
            }
682

683
            const min = parseDate(this.minValue);
58✔
684
            const max = parseDate(this.maxValue);
58✔
685
            const start = parseDate(value.start);
58✔
686
            const end = parseDate(value.end);
58✔
687
            if ((min && start && DateTimeUtil.lessThanMinValue(start, min, false))
58✔
688
                || (min && end && DateTimeUtil.lessThanMinValue(end, min, false))) {
689
                Object.assign(errors, { minValue: true });
2✔
690
            }
691
            if ((max && start && DateTimeUtil.greaterThanMaxValue(start, max, false))
58✔
692
                || (max && end && DateTimeUtil.greaterThanMaxValue(end, max, false))) {
693
                Object.assign(errors, { maxValue: true });
1✔
694
            }
695
        }
696

697
        return Object.keys(errors).length > 0 ? errors : null;
285✔
698
    }
699

700
    /** @hidden @internal */
701
    public registerOnValidatorChange?(fn: any): void {
702
        this.onValidatorChange = fn;
54✔
703
    }
704

705
    /** @hidden @internal */
706
    public setDisabledState?(isDisabled: boolean): void {
707
        this.disabled = isDisabled;
55✔
708
    }
709

710
    /** @hidden */
711
    public ngOnInit(): void {
712
        this._ngControl = this._injector.get<NgControl>(NgControl, null);
102✔
713

714
        this.locale = this.locale || this._localeId;
102!
715
    }
716

717
    /** @hidden */
718
    public override ngAfterViewInit(): void {
719
        super.ngAfterViewInit();
99✔
720
        this.subscribeToDateEditorEvents();
99✔
721
        this.subscribeToClick();
99✔
722
        this.configPositionStrategy();
99✔
723
        this.configOverlaySettings();
99✔
724
        this.cacheFocusedInput();
99✔
725
        this.attachOnTouched();
99✔
726

727
        this.setRequiredToInputs();
99✔
728

729
        if (this._ngControl) {
99✔
730
            this._statusChanges$ = this._ngControl.statusChanges.subscribe(this.onStatusChanged.bind(this));
47✔
731
        }
732

733
        // delay invocations until the current change detection cycle has completed
734
        Promise.resolve().then(() => {
99✔
735
            this.updateDisabledState();
99✔
736
            this.initialSetValue();
99✔
737
            this.updateInputs();
99✔
738
            // B.P. 07 July 2021 - IgxDateRangePicker not showing initial disabled state with ChangeDetectionStrategy.OnPush #9776
739
            /**
740
             * if disabled is placed on the range picker element and there are projected inputs
741
             * run change detection since igxInput will initially set the projected inputs' disabled to false
742
             */
743
            if (this.hasProjectedInputs && this.disabled) {
99✔
744
                this._cdr.markForCheck();
3✔
745
            }
746
        });
747
        this.updateDisplayFormat();
99✔
748
        this.updateInputFormat();
99✔
749
    }
750

751
    /** @hidden @internal */
752
    public ngOnChanges(changes: SimpleChanges): void {
753
        if (changes['displayFormat'] && this.hasProjectedInputs) {
106✔
754
            this.updateDisplayFormat();
11✔
755
        }
756
        if (changes['inputFormat'] && this.hasProjectedInputs) {
106✔
757
            this.updateInputFormat();
4✔
758
        }
759
        if (changes['disabled']) {
106✔
760
            this.updateDisabledState();
81✔
761
        }
762
    }
763

764
    /** @hidden @internal */
765
    public override ngOnDestroy(): void {
766
        super.ngOnDestroy();
88✔
767
        if (this._statusChanges$) {
88✔
768
            this._statusChanges$.unsubscribe();
47✔
769
        }
770
        if (this._overlayId) {
88✔
771
            this._overlayService.detach(this._overlayId);
16✔
772
        }
773
    }
774

775
    /** @hidden @internal */
776
    public getEditElement(): HTMLInputElement | undefined {
777
        return this.inputDirective?.nativeElement;
54✔
778
    }
779

780
    protected onStatusChanged = () => {
105✔
781
        if (this.inputGroup) {
101✔
782
            this.setValidityState(this.inputDirective, this.inputGroup.isFocused);
7✔
783
        } else if (this.hasProjectedInputs) {
94✔
784
            this.projectedInputs
94✔
785
                .forEach((i) => {
786
                    this.setValidityState(i.inputDirective, i.isFocused);
188✔
787
                });
788
        }
789
        this.setRequiredToInputs();
101✔
790
    };
791

792
    private setValidityState(inputDirective: IgxInputDirective, isFocused: boolean) {
793
        if (this._ngControl && !this._ngControl.disabled && this.isTouchedOrDirty) {
195✔
794
            if (this.hasValidators && isFocused) {
181✔
795
                inputDirective.valid = this._ngControl.valid ? IgxInputState.VALID : IgxInputState.INVALID;
2✔
796
            } else {
797
                inputDirective.valid = this._ngControl.valid ? IgxInputState.INITIAL : IgxInputState.INVALID;
179✔
798
            }
799
        } else {
800
            inputDirective.valid = IgxInputState.INITIAL;
14✔
801
        }
802
    }
803

804
    private get isTouchedOrDirty(): boolean {
805
        return (this._ngControl.control.touched || this._ngControl.control.dirty);
185✔
806
    }
807

808
    private get hasValidators(): boolean {
809
        return (!!this._ngControl.control.validator || !!this._ngControl.control.asyncValidator);
181✔
810
    }
811

812
    private handleSelection(selectionData: Date[]): void {
813
        let newValue = this.extractRange(selectionData);
45✔
814
        if (!newValue.start && !newValue.end) {
45!
815
            newValue = null;
×
816
        }
817
        this.value = newValue;
45✔
818
        if (this.isDropdown && selectionData?.length > 1) {
45✔
819
            this.close();
32✔
820
        }
821
    }
822

823
    private handleClosing(e: IBaseCancelableBrowserEventArgs): void {
824
        const args = { owner: this, cancel: e?.cancel, event: e?.event };
48✔
825
        this.closing.emit(args);
48✔
826
        e.cancel = args.cancel;
48✔
827
        if (args.cancel) {
48✔
828
            return;
3✔
829
        }
830

831
        if (this.isDropdown && e?.event && !this.isFocused) {
45!
832
            // outside click
833
            this.updateValidityOnBlur();
×
834
        } else {
835
            this.onTouchCallback();
45✔
836
            // input click
837
            if (this.hasProjectedInputs && this._focusedInput) {
45✔
838
                this._focusedInput.setFocus();
7✔
839
            }
840
            if (this.inputDirective) {
45✔
841
                this.inputDirective.focus();
16✔
842
            }
843
        }
844
    }
845

846
    private subscribeToOverlayEvents() {
847
        this._overlayService.opening.pipe(...this._overlaySubFilter).subscribe((e) => {
44✔
848
            const overlayEvent = e as OverlayCancelableEventArgs;
44✔
849
            const args = { owner: this, cancel: overlayEvent?.cancel, event: e.event };
44✔
850
            this.opening.emit(args);
44✔
851
            if (args.cancel) {
44!
852
                this._overlayService.detach(this._overlayId);
×
853
                overlayEvent.cancel = true;
×
854
                return;
×
855
            }
856

857
            this._initializeCalendarContainer(e.componentRef.instance);
44✔
858
            this._calendarContainer = e.componentRef.location.nativeElement;
44✔
859
            this._collapsed = false;
44✔
860
            this.updateCalendar();
44✔
861
        });
862

863
        this._overlayService.opened.pipe(...this._overlaySubFilter).subscribe(() => {
44✔
864
            this.calendar.wrapper.nativeElement.focus();
30✔
865
            this.opened.emit({ owner: this });
30✔
866
        });
867

868
        this._overlayService.closing.pipe(...this._overlaySubFilter).subscribe((e: OverlayCancelableEventArgs) => {
44✔
869
            const isEscape = e.event && (e.event as KeyboardEvent).key === this.platform.KEYMAP.ESCAPE;
48✔
870
            if (this.isProjectedInputTarget(e.event) && !isEscape) {
48✔
871
                e.cancel = true;
1✔
872
            }
873
            this.handleClosing(e as OverlayCancelableEventArgs);
48✔
874
        });
875

876
        this._overlayService.closed.pipe(...this._overlaySubFilter).subscribe(() => {
44✔
877
            this._overlayService.detach(this._overlayId);
17✔
878
            this._collapsed = true;
17✔
879
            this._overlayId = null;
17✔
880
            this._calendar = null;
17✔
881
            this._calendarContainer = undefined;
17✔
882
            this.closed.emit({ owner: this });
17✔
883
        });
884
    }
885

886
    private isProjectedInputTarget(event: Event): boolean {
887
        if (!this.hasProjectedInputs || !event) {
48✔
888
            return false;
45✔
889
        }
890
        const path = event.composed ? event.composedPath() : [event.target];
3!
891
        return this.projectedInputs.some(i =>
3✔
892
            path.includes(i.dateTimeEditor.nativeElement)
5✔
893
        );
894
    }
895

896
    private updateValue(value: DateRange) {
897
        this._value = value ? value : null;
190✔
898
        this.updateInputs();
190✔
899
        this.updateCalendar();
190✔
900
    }
901

902
    private updateValidityOnBlur() {
903
        this._focusedInput = null;
2✔
904
        this.onTouchCallback();
2✔
905
        if (this._ngControl) {
2✔
906
            if (this.hasProjectedInputs) {
1✔
907
                this.projectedInputs.forEach(i => {
1✔
908
                    if (!this._ngControl.valid) {
2!
909
                        i.updateInputValidity(IgxInputState.INVALID);
2✔
910
                    } else {
911
                        i.updateInputValidity(IgxInputState.INITIAL);
×
912
                    }
913
                });
914
            }
915

916
            if (this.inputDirective) {
1!
917
                if (!this._ngControl.valid) {
×
918
                    this.inputDirective.valid = IgxInputState.INVALID;
×
919
                } else {
920
                    this.inputDirective.valid = IgxInputState.INITIAL;
×
921
                }
922
            }
923
        }
924
    }
925

926
    private updateDisabledState() {
927
        if (this.hasProjectedInputs) {
180✔
928
            const start = this.projectedInputs.find(i => i instanceof IgxDateRangeStartComponent) as IgxDateRangeStartComponent;
52✔
929
            const end = this.projectedInputs.find(i => i instanceof IgxDateRangeEndComponent) as IgxDateRangeEndComponent;
104✔
930
            start.inputDirective.disabled = this.disabled;
52✔
931
            end.inputDirective.disabled = this.disabled;
52✔
932
            return;
52✔
933
        }
934
    }
935

936
    private setRequiredToInputs(): void {
937
        // workaround for igxInput setting required
938
        Promise.resolve().then(() => {
200✔
939
            const isRequired = this.required;
200✔
940
            if (this.inputGroup && this.inputGroup.isRequired !== isRequired) {
200✔
941
                this.inputGroup.isRequired = isRequired;
4✔
942
            } else if (this.hasProjectedInputs && this._ngControl) {
196✔
943
                this.projectedInputs.forEach(i => i.isRequired = isRequired);
276✔
944
            }
945
        });
946
    }
947

948
    private parseMinValue(value: string | Date): Date | null {
949
        let minValue: Date = parseDate(value);
80✔
950
        if (!minValue && this.hasProjectedInputs) {
80✔
951
            const start = this.projectedInputs.filter(i => i instanceof IgxDateRangeStartComponent)[0];
92✔
952
            if (start) {
46✔
953
                minValue = parseDate(start.dateTimeEditor.minValue);
46✔
954
            }
955
        }
956

957
        return minValue;
80✔
958
    }
959

960
    private parseMaxValue(value: string | Date): Date | null {
961
        let maxValue: Date = parseDate(value);
80✔
962
        if (!maxValue && this.projectedInputs) {
80✔
963
            const end = this.projectedInputs.filter(i => i instanceof IgxDateRangeEndComponent)[0];
92✔
964
            if (end) {
78✔
965
                maxValue = parseDate(end.dateTimeEditor.maxValue);
46✔
966
            }
967
        }
968

969
        return maxValue;
80✔
970
    }
971

972
    private updateCalendar(): void {
973
        if (!this.calendar) {
236✔
974
            return;
156✔
975
        }
976
        this.calendar.disabledDates = [];
80✔
977
        const minValue = this.parseMinValue(this.minValue);
80✔
978
        if (minValue) {
80✔
979
            this.calendar.disabledDates.push({ type: DateRangeType.Before, dateRange: [minValue] });
2✔
980
        }
981
        const maxValue = this.parseMaxValue(this.maxValue);
80✔
982
        if (maxValue) {
80✔
983
            this.calendar.disabledDates.push({ type: DateRangeType.After, dateRange: [maxValue] });
2✔
984
        }
985

986
        const range: Date[] = [];
80✔
987
        if (this.value?.start && this.value?.end) {
80✔
988
            const _value = this.toRangeOfDates(this.value);
30✔
989
            if (DateTimeUtil.greaterThanMaxValue(_value.start, _value.end)) {
30!
990
                this.swapEditorDates();
×
991
            }
992
            if (this.valueInRange(this.value, minValue, maxValue)) {
30✔
993
                range.push(_value.start, _value.end);
30✔
994
            }
995
        }
996

997
        if (range.length > 0) {
80✔
998
            this.calendar.selectDate(range);
30✔
999
        } else if (range.length === 0 && this.calendar.monthViews) {
50✔
1000
            this.calendar.deselectDate();
6✔
1001
        }
1002
        this.calendar.viewDate = range[0] || new Date();
80✔
1003
    }
1004

1005
    private swapEditorDates(): void {
1006
        if (this.hasProjectedInputs) {
×
1007
            const start = this.projectedInputs.find(i => i instanceof IgxDateRangeStartComponent) as IgxDateRangeStartComponent;
×
1008
            const end = this.projectedInputs.find(i => i instanceof IgxDateRangeEndComponent) as IgxDateRangeEndComponent;
×
1009
            [start.dateTimeEditor.value, end.dateTimeEditor.value] = [end.dateTimeEditor.value, start.dateTimeEditor.value];
×
1010
            [this.value.start, this.value.end] = [this.value.end, this.value.start];
×
1011
        }
1012
    }
1013

1014
    private valueInRange(value: DateRange, minValue?: Date, maxValue?: Date): boolean {
1015
        const _value = this.toRangeOfDates(value);
30✔
1016
        if (minValue && DateTimeUtil.lessThanMinValue(_value.start, minValue, false)) {
30!
1017
            return false;
×
1018
        }
1019
        if (maxValue && DateTimeUtil.greaterThanMaxValue(_value.end, maxValue, false)) {
30!
1020
            return false;
×
1021
        }
1022

1023
        return true;
30✔
1024
    }
1025

1026
    private extractRange(selection: Date[]): DateRange {
1027
        return {
45✔
1028
            start: selection[0] || null,
45!
1029
            end: selection.length > 0 ? selection[selection.length - 1] : null
45!
1030
        };
1031
    }
1032

1033
    private toRangeOfDates(range: DateRange): { start: Date; end: Date } {
1034
        let start;
1035
        let end;
1036
        if (!isDate(range.start)) {
120✔
1037
            start = DateTimeUtil.parseIsoDate(range.start);
2✔
1038
        }
1039
        if (!isDate(range.end)) {
120✔
1040
            end = DateTimeUtil.parseIsoDate(range.end);
3✔
1041
        }
1042

1043
        if (start || end) {
120!
1044
            return { start, end };
×
1045
        }
1046

1047
        return { start: range.start as Date, end: range.end as Date };
120✔
1048
    }
1049

1050
    private subscribeToClick() {
1051
        const inputs = this.hasProjectedInputs
99✔
1052
            ? this.projectedInputs.map(i => i.inputDirective.nativeElement)
94✔
1053
            : [this.getEditElement()];
1054
        inputs.forEach(input => {
99✔
1055
            fromEvent(input, 'click')
146✔
1056
                .pipe(takeUntil(this._destroy$))
1057
                .subscribe(() => {
1058
                    if (!this.isDropdown) {
4✔
1059
                        this.toggle();
2✔
1060
                    }
1061
                });
1062
        });
1063
    }
1064

1065
    private subscribeToDateEditorEvents(): void {
1066
        if (this.hasProjectedInputs) {
99✔
1067
            const start = this.projectedInputs.find(i => i instanceof IgxDateRangeStartComponent) as IgxDateRangeStartComponent;
47✔
1068
            const end = this.projectedInputs.find(i => i instanceof IgxDateRangeEndComponent) as IgxDateRangeEndComponent;
94✔
1069
            if (start && end) {
47✔
1070
                start.dateTimeEditor.valueChange
47✔
1071
                    .pipe(takeUntil(this._destroy$))
1072
                    .subscribe(value => {
1073
                        if (this.value) {
1!
UNCOV
1074
                            this.value = { start: value, end: this.value.end };
×
1075
                        } else {
1076
                            this.value = { start: value, end: null };
1✔
1077
                        }
1078
                    });
1079
                end.dateTimeEditor.valueChange
47✔
1080
                    .pipe(takeUntil(this._destroy$))
1081
                    .subscribe(value => {
1082
                        if (this.value) {
1!
1083
                            this.value = { start: this.value.start, end: value as Date };
1✔
1084
                        } else {
UNCOV
1085
                            this.value = { start: null, end: value as Date };
×
1086
                        }
1087
                    });
1088
            }
1089
        }
1090
    }
1091

1092
    private attachOnTouched(): void {
1093
        if (this.hasProjectedInputs) {
99✔
1094
            this.projectedInputs.forEach(i => {
47✔
1095
                fromEvent(i.dateTimeEditor.nativeElement, 'blur')
94✔
1096
                    .pipe(takeUntil(this._destroy$))
1097
                    .subscribe(() => {
1098
                        if (this.collapsed) {
9✔
1099
                            this.updateValidityOnBlur();
1✔
1100
                        }
1101
                    });
1102
            });
1103
        } else {
1104
            fromEvent(this.inputDirective.nativeElement, 'blur')
52✔
1105
                .pipe(takeUntil(this._destroy$))
1106
                .subscribe(() => {
1107
                    if (this.collapsed) {
6!
UNCOV
1108
                        this.updateValidityOnBlur();
×
1109
                    }
1110
                });
1111
        }
1112
    }
1113

1114
    private cacheFocusedInput(): void {
1115
        if (this.hasProjectedInputs) {
99✔
1116
            this.projectedInputs.forEach(i => {
47✔
1117
                fromEvent(i.dateTimeEditor.nativeElement, 'focus')
94✔
1118
                    .pipe(takeUntil(this._destroy$))
1119
                    .subscribe(() => this._focusedInput = i);
14✔
1120
            });
1121
        }
1122
    }
1123

1124
    private configPositionStrategy(): void {
1125
        this._positionSettings = {
99✔
1126
            openAnimation: fadeIn,
1127
            closeAnimation: fadeOut
1128
        };
1129
        this._dropDownOverlaySettings.positionStrategy = new AutoPositionStrategy(this._positionSettings);
99✔
1130
        this._dropDownOverlaySettings.target = this.element.nativeElement;
99✔
1131
    }
1132

1133
    private configOverlaySettings(): void {
1134
        if (this.overlaySettings !== null) {
99✔
1135
            this._dropDownOverlaySettings = Object.assign({}, this._dropDownOverlaySettings, this.overlaySettings);
99✔
1136
            this._dialogOverlaySettings = Object.assign({}, this._dialogOverlaySettings, this.overlaySettings);
99✔
1137
        }
1138
    }
1139

1140
    private initialSetValue() {
1141
        // if there is no value and no ngControl on the picker but we have inputs we may have value set through
1142
        // their ngModels - we should generate our initial control value
1143
        if ((!this.value || (!this.value.start && !this.value.end)) && this.hasProjectedInputs && !this._ngControl) {
99!
1144
            const start = this.projectedInputs.find(i => i instanceof IgxDateRangeStartComponent);
3✔
1145
            const end = this.projectedInputs.find(i => i instanceof IgxDateRangeEndComponent);
6✔
1146
            this._value = {
3✔
1147
                start: start.dateTimeEditor.value as Date,
1148
                end: end.dateTimeEditor.value as Date
1149
            };
1150
        }
1151
    }
1152

1153
    private updateInputs(): void {
1154
        const start = this.projectedInputs?.find(i => i instanceof IgxDateRangeStartComponent) as IgxDateRangeStartComponent;
289✔
1155
        const end = this.projectedInputs?.find(i => i instanceof IgxDateRangeEndComponent) as IgxDateRangeEndComponent;
294✔
1156
        if (start && end) {
289✔
1157
            const _value = this.value ? this.toRangeOfDates(this.value) : null;
147✔
1158
            start.updateInputValue(_value?.start || null);
147✔
1159
            end.updateInputValue(_value?.end || null);
147✔
1160
        }
1161
    }
1162

1163
    private updateDisplayFormat(): void {
1164
        this.projectedInputs.forEach(i => {
113✔
1165
            const input = i as IgxDateRangeInputsBaseComponent;
122✔
1166
            input.dateTimeEditor.displayFormat = this.displayFormat;
122✔
1167
        });
1168
    }
1169

1170
    private updateInputFormat(): void {
1171
        this.projectedInputs.forEach(i => {
103✔
1172
            const input = i as IgxDateRangeInputsBaseComponent;
102✔
1173
            if (input.dateTimeEditor.inputFormat !== this.inputFormat) {
102✔
1174
                input.dateTimeEditor.inputFormat = this.inputFormat;
102✔
1175
            }
1176
        });
1177
    }
1178

1179
    private updateInputLocale(): void {
1180
        this.projectedInputs.forEach(i => {
3✔
1181
            const input = i as IgxDateRangeInputsBaseComponent;
6✔
1182
            input.dateTimeEditor.locale = this.locale;
6✔
1183
        });
1184
    }
1185

1186
    private _initializeCalendarContainer(componentInstance: IgxCalendarContainerComponent) {
1187
        this._calendar = componentInstance.calendar;
44✔
1188
        this.calendar.hasHeader = false;
44✔
1189
        this.calendar.locale = this.locale;
44✔
1190
        this.calendar.selection = CalendarSelection.RANGE;
44✔
1191
        this.calendar.weekStart = this.weekStart;
44✔
1192
        this.calendar.hideOutsideDays = this.hideOutsideDays;
44✔
1193
        this.calendar.monthsViewNumber = this.displayMonthsCount;
44✔
1194
        this.calendar.showWeekNumbers = this.showWeekNumbers;
44✔
1195
        this.calendar.selected.pipe(takeUntil(this._destroy$)).subscribe((ev: Date[]) => this.handleSelection(ev));
44✔
1196

1197
        componentInstance.mode = this.mode;
44✔
1198
        componentInstance.closeButtonLabel = !this.isDropdown ? this.doneButtonText : null;
44✔
1199
        componentInstance.cancelButtonLabel = !this.isDropdown ? this.cancelButtonText : null;
44✔
1200
        componentInstance.pickerActions = this.pickerActions;
44✔
1201
        componentInstance.usePredefinedRanges = this.usePredefinedRanges;
44✔
1202
        componentInstance.customRanges = this.customRanges;
44✔
1203
        componentInstance.resourceStrings = this.resourceStrings;
44✔
1204
        componentInstance.calendarClose.pipe(takeUntil(this._destroy$)).subscribe(() => this.close());
44✔
1205
        componentInstance.calendarCancel.pipe(takeUntil(this._destroy$)).subscribe(() => {
44✔
1206
            this._value = this._originalValue;
2✔
1207
            this.close()
2✔
1208
        });
1209
        componentInstance.rangeSelected
44✔
1210
        .pipe(takeUntil(this._destroy$))
1211
        .subscribe((r: DateRange) => {
1212
            if (r?.start && r?.end) {
6✔
1213
            this.select(new Date(r.start), new Date(r.end));
6✔
1214
            }
1215

1216
            if (this.isDropdown) {
6✔
1217
            this.close();
6✔
1218
            }
1219
        });
1220
    }
1221
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc