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

IgniteUI / igniteui-angular / 13331632524

14 Feb 2025 02:51PM CUT coverage: 22.015% (-69.6%) from 91.622%
13331632524

Pull #15372

github

web-flow
Merge d52d57714 into bcb78ae0a
Pull Request #15372: chore(*): test ci passing

1990 of 15592 branches covered (12.76%)

431 of 964 new or added lines in 18 files covered. (44.71%)

19956 existing lines in 307 files now uncovered.

6452 of 29307 relevant lines covered (22.02%)

249.17 hits per line

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

36.11
/projects/igniteui-angular/src/lib/drop-down/drop-down.component.ts
1
import {
2
    ChangeDetectorRef,
3
    Component,
4
    ContentChildren,
5
    ElementRef,
6
    forwardRef,
7
    QueryList,
8
    OnChanges,
9
    Input,
10
    OnDestroy,
11
    ViewChild,
12
    ContentChild,
13
    AfterViewInit,
14
    Output,
15
    EventEmitter,
16
    SimpleChanges,
17
    booleanAttribute,
18
    Inject
19
} from '@angular/core';
20
import { IgxToggleDirective, ToggleViewEventArgs } from '../directives/toggle/toggle.directive';
21
import { IgxDropDownItemComponent } from './drop-down-item.component';
22
import { IgxDropDownBaseDirective } from './drop-down.base';
23
import { DropDownActionKey, Navigate } from './drop-down.common';
24
import { IGX_DROPDOWN_BASE, IDropDownBase } from './drop-down.common';
25
import { ISelectionEventArgs } from './drop-down.common';
26
import { IBaseCancelableBrowserEventArgs, IBaseEventArgs } from '../core/utils';
27
import { IgxSelectionAPIService } from '../core/selection';
28
import { Subject } from 'rxjs';
29
import { IgxDropDownItemBaseDirective } from './drop-down-item.base';
30
import { IgxForOfToken } from '../directives/for-of/for_of.directive';
31
import { take } from 'rxjs/operators';
32
import { OverlaySettings } from '../services/overlay/utilities';
33
import { DOCUMENT, NgIf } from '@angular/common';
34
import { ConnectedPositioningStrategy } from '../services/public_api';
35

36
/**
37
 * **Ignite UI for Angular DropDown** -
38
 * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/drop-down)
39
 *
40
 * The Ignite UI for Angular Drop Down displays a scrollable list of items which may be visually grouped and
41
 * supports selection of a single item. Clicking or tapping an item selects it and closes the Drop Down
42
 *
43
 * Example:
44
 * ```html
45
 * <igx-drop-down>
46
 *   <igx-drop-down-item *ngFor="let item of items" disabled={{item.disabled}} isHeader={{item.header}}>
47
 *     {{ item.value }}
48
 *   </igx-drop-down-item>
49
 * </igx-drop-down>
50
 * ```
51
 */
52

53
@Component({
54
    selector: 'igx-drop-down',
55
    templateUrl: './drop-down.component.html',
56
    providers: [{ provide: IGX_DROPDOWN_BASE, useExisting: IgxDropDownComponent }],
57
    imports: [IgxToggleDirective, NgIf]
58
})
59
export class IgxDropDownComponent extends IgxDropDownBaseDirective implements IDropDownBase, OnChanges, AfterViewInit, OnDestroy {
2✔
60
    /**
61
     * @hidden
62
     * @internal
63
     */
64
    @ContentChildren(forwardRef(() => IgxDropDownItemComponent), { descendants: true })
2✔
65
    public override children: QueryList<IgxDropDownItemBaseDirective>;
66

67
    /**
68
     * Emitted before the dropdown is opened
69
     *
70
     * ```html
71
     * <igx-drop-down (opening)='handleOpening($event)'></igx-drop-down>
72
     * ```
73
     */
74
    @Output()
75
    public opening = new EventEmitter<IBaseCancelableBrowserEventArgs>();
183✔
76

77
    /**
78
     * Emitted after the dropdown is opened
79
     *
80
     * ```html
81
     * <igx-drop-down (opened)='handleOpened($event)'></igx-drop-down>
82
     * ```
83
     */
84
    @Output()
85
    public opened = new EventEmitter<IBaseEventArgs>();
183✔
86

87
    /**
88
     * Emitted before the dropdown is closed
89
     *
90
     * ```html
91
     * <igx-drop-down (closing)='handleClosing($event)'></igx-drop-down>
92
     * ```
93
     */
94
    @Output()
95
    public closing = new EventEmitter<IBaseCancelableBrowserEventArgs>();
183✔
96

97
    /**
98
     * Emitted after the dropdown is closed
99
     *
100
     * ```html
101
     * <igx-drop-down (closed)='handleClosed($event)'></igx-drop-down>
102
     * ```
103
     */
104
    @Output()
105
    public closed = new EventEmitter<IBaseEventArgs>();
183✔
106

107
    /**
108
     * Gets/sets whether items take focus. Disabled by default.
109
     * When enabled, drop down items gain tab index and are focused when active -
110
     * this includes activating the selected item when opening the drop down and moving with keyboard navigation.
111
     *
112
     * Note: Keep that focus shift in mind when using the igxDropDownItemNavigation directive
113
     * and ensure it's placed either on each focusable item or a common ancestor to allow it to handle keyboard events.
114
     *
115
     * ```typescript
116
     * // get
117
     * let dropDownAllowsItemFocus = this.dropdown.allowItemsFocus;
118
     * ```
119
     *
120
     * ```html
121
     * <!--set-->
122
     * <igx-drop-down [allowItemsFocus]='true'></igx-drop-down>
123
     * ```
124
     */
125
    @Input({ transform: booleanAttribute })
126
    public allowItemsFocus = false;
183✔
127

128
    /**
129
     * Sets aria-labelledby attribute value.
130
     * ```html
131
     * <igx-drop-down [labelledby]="labelId"></igx-drop-down>
132
     * ```
133
     */
134
    @Input()
135
    public labelledBy: string;
136

137
    @ContentChild(IgxForOfToken)
138
    protected virtDir: IgxForOfToken<any>;
139

140
    @ViewChild(IgxToggleDirective, { static: true })
141
    protected toggleDirective: IgxToggleDirective;
142

143
    @ViewChild('scrollContainer', { static: true })
144
    protected scrollContainerRef: ElementRef;
145

146
    /**
147
     * @hidden @internal
148
     */
149
    public override get focusedItem(): IgxDropDownItemBaseDirective {
150
        if (this.virtDir) {
363!
UNCOV
151
            return this._focusedItem && this._focusedItem.index !== -1 ?
×
UNCOV
152
                (this.children.find(e => e.index === this._focusedItem.index) || null) :
×
153
                null;
154
        }
155
        return this._focusedItem;
363✔
156
    }
157

158
    public override set focusedItem(value: IgxDropDownItemBaseDirective) {
159
        if (!value) {
76!
UNCOV
160
            this.selection.clear(`${this.id}-active`);
×
UNCOV
161
            this._focusedItem = null;
×
UNCOV
162
            return;
×
163
        }
164
        this._focusedItem = value;
76✔
165
        if (this.virtDir) {
76!
UNCOV
166
            this._focusedItem = {
×
167
                value: value.value,
168
                index: value.index
169
            } as IgxDropDownItemBaseDirective;
170
        }
171
        this.selection.set(`${this.id}-active`, new Set([this._focusedItem]));
76✔
172
    }
173

174
    public override get id(): string {
175
        return this._id;
27,903✔
176
    }
177
    public override set id(value: string) {
UNCOV
178
        this.selection.set(value, this.selection.get(this.id));
×
UNCOV
179
        this.selection.clear(this.id);
×
UNCOV
180
        this.selection.set(value, this.selection.get(`${this.id}-active`));
×
UNCOV
181
        this.selection.clear(`${this.id}-active`);
×
UNCOV
182
        this._id = value;
×
183
    }
184

185
    /** Id of the internal listbox of the drop down */
186
    public get listId() {
187
        return this.id + '-list';
5,923✔
188
    }
189

190
    /**
191
     * Get currently selected item
192
     *
193
     * ```typescript
194
     * let currentItem = this.dropdown.selectedItem;
195
     * ```
196
     */
197
    public get selectedItem(): IgxDropDownItemBaseDirective {
198
        const selectedItem = this.selection.first_item(this.id);
4✔
199
        if (selectedItem) {
4!
UNCOV
200
            return selectedItem;
×
201
        }
202
        return null;
4✔
203
    }
204

205
    /**
206
     * Gets if the dropdown is collapsed
207
     *
208
     * ```typescript
209
     * let isCollapsed = this.dropdown.collapsed;
210
     * ```
211
     */
212
    public get collapsed(): boolean {
213
        return this.toggleDirective.collapsed;
9,358✔
214
    }
215

216
    /** @hidden @internal */
217
    public override get scrollContainer(): HTMLElement {
218
        return this.scrollContainerRef.nativeElement;
39✔
219
    }
220

221
    protected get collectionLength() {
UNCOV
222
        if (this.virtDir) {
×
UNCOV
223
            return this.virtDir.totalItemCount || this.virtDir.igxForOf.length;
×
224
        }
225
    }
226

227
    protected destroy$ = new Subject<boolean>();
183✔
228
    protected _scrollPosition: number;
229

230
    constructor(
231
        elementRef: ElementRef,
232
        cdr: ChangeDetectorRef,
233
        @Inject(DOCUMENT) document: any,
234
        protected selection: IgxSelectionAPIService) {
183✔
235
        super(elementRef, cdr, document);
183✔
236
    }
237

238
    /**
239
     * Opens the dropdown
240
     *
241
     * ```typescript
242
     * this.dropdown.open();
243
     * ```
244
     */
245
    public open(overlaySettings?: OverlaySettings) {
246
        const settings = { ... {}, ...this.getDefaultOverlaySettings(), ...overlaySettings };
41✔
247
        this.toggleDirective.open(settings);
41✔
248
        this.updateScrollPosition();
41✔
249
    }
250

251
    /**
252
     * @hidden @internal
253
     */
254
    public getDefaultOverlaySettings(): OverlaySettings {
255
        return {
41✔
256
            closeOnOutsideClick: true,
257
            modal: false,
258
            positionStrategy: new ConnectedPositioningStrategy()
259
        };
260
    }
261

262
    /**
263
     * Closes the dropdown
264
     *
265
     * ```typescript
266
     * this.dropdown.close();
267
     * ```
268
     */
269
    public close(event?: Event) {
270
        this.toggleDirective.close(event);
22✔
271
    }
272

273
    /**
274
     * Toggles the dropdown
275
     *
276
     * ```typescript
277
     * this.dropdown.toggle();
278
     * ```
279
     */
280
    public toggle(overlaySettings?: OverlaySettings) {
281
        if (this.collapsed || this.toggleDirective.isClosing) {
43✔
282
            this.open(overlaySettings);
39✔
283
        } else {
284
            this.close();
4✔
285
        }
286
    }
287

288
    /**
289
     * Select an item by index
290
     *
291
     * @param index of the item to select; If the drop down uses *igxFor, pass the index in data
292
     */
293
    public setSelectedItem(index: number) {
UNCOV
294
        if (index < 0 || index >= this.items.length) {
×
UNCOV
295
            return;
×
296
        }
297
        let newSelection: IgxDropDownItemBaseDirective;
UNCOV
298
        if (this.virtDir) {
×
UNCOV
299
            newSelection = {
×
300
                value: this.virtDir.igxForOf[index],
301
                index
302
            } as IgxDropDownItemBaseDirective;
303
        } else {
UNCOV
304
            newSelection = this.items[index];
×
305
        }
UNCOV
306
        this.selectItem(newSelection);
×
307
    }
308

309
    /**
310
     * Navigates to the item on the specified index
311
     * If the data in the drop-down is virtualized, pass the index of the item in the virtualized data.
312
     *
313
     * @param newIndex number
314
     */
315
    public override navigateItem(index: number) {
316
        if (this.virtDir) {
39!
UNCOV
317
            if (index === -1 || index >= this.collectionLength) {
×
UNCOV
318
                return;
×
319
            }
UNCOV
320
            const direction = index > (this.focusedItem ? this.focusedItem.index : -1) ? Navigate.Down : Navigate.Up;
×
UNCOV
321
            const subRequired = this.isIndexOutOfBounds(index, direction);
×
UNCOV
322
            this.focusedItem = {
×
323
                value: this.virtDir.igxForOf[index],
324
                index
325
            } as IgxDropDownItemBaseDirective;
UNCOV
326
            if (subRequired) {
×
UNCOV
327
                this.virtDir.scrollTo(index);
×
328
            }
UNCOV
329
            if (subRequired) {
×
UNCOV
330
                this.virtDir.chunkLoad.pipe(take(1)).subscribe(() => {
×
UNCOV
331
                    this.skipHeader(direction);
×
332
                });
333
            } else {
UNCOV
334
                this.skipHeader(direction);
×
335
            }
336
        } else {
337
            super.navigateItem(index);
39✔
338
        }
339
        if (this.allowItemsFocus && this.focusedItem) {
39!
UNCOV
340
            this.focusedItem.element.nativeElement.focus();
×
UNCOV
341
            this.cdr.markForCheck();
×
342
        }
343
    }
344

345
    /**
346
     * @hidden @internal
347
     */
348
    public updateScrollPosition() {
349
        if (!this.virtDir) {
41✔
350
            return;
41✔
351
        }
UNCOV
352
        if (!this.selectedItem) {
×
UNCOV
353
            this.virtDir.scrollTo(0);
×
UNCOV
354
            return;
×
355
        }
UNCOV
356
        let targetScroll = this.virtDir.getScrollForIndex(this.selectedItem.index);
×
357
        // TODO: This logic _cannot_ be right, those are optional user-provided inputs that can be strings with units, refactor:
UNCOV
358
        const itemsInView = this.virtDir.igxForContainerSize / this.virtDir.igxForItemSize;
×
UNCOV
359
        targetScroll -= (itemsInView / 2 - 1) * this.virtDir.igxForItemSize;
×
UNCOV
360
        this.virtDir.getScroll().scrollTop = targetScroll;
×
361
    }
362

363
    /**
364
     * @hidden @internal
365
     */
366
    public onToggleOpening(e: IBaseCancelableBrowserEventArgs) {
367
        const args: IBaseCancelableBrowserEventArgs = { owner: this, event: e.event, cancel: false };
2✔
368
        this.opening.emit(args);
2✔
369
        e.cancel = args.cancel;
2✔
370
        if (e.cancel) {
2!
UNCOV
371
            return;
×
372
        }
373

374
        if (this.virtDir) {
2!
UNCOV
375
            this.virtDir.scrollPosition = this._scrollPosition;
×
376
        }
377
    }
378

379
    /**
380
     * @hidden @internal
381
     */
382
    public onToggleContentAppended(_event: ToggleViewEventArgs) {
383
        if (!this.virtDir && this.selectedItem) {
41!
UNCOV
384
            this.scrollToItem(this.selectedItem);
×
385
        }
386
    }
387

388
    /**
389
     * @hidden @internal
390
     */
391
    public onToggleOpened() {
392
        this.updateItemFocus();
2✔
393
        this.opened.emit({ owner: this });
2✔
394
    }
395

396
    /**
397
     * @hidden @internal
398
     */
399
    public onToggleClosing(e: IBaseCancelableBrowserEventArgs) {
400
        const args: IBaseCancelableBrowserEventArgs = { owner: this, event: e.event, cancel: false };
1✔
401
        this.closing.emit(args);
1✔
402
        e.cancel = args.cancel;
1✔
403
        if (e.cancel) {
1!
UNCOV
404
            return;
×
405
        }
406
        if (this.virtDir) {
1!
UNCOV
407
            this._scrollPosition = this.virtDir.scrollPosition;
×
408
        }
409
    }
410

411
    /**
412
     * @hidden @internal
413
     */
414
    public onToggleClosed() {
415
        this.focusItem(false);
1✔
416
        this.closed.emit({ owner: this });
1✔
417
    }
418

419
    /**
420
     * @hidden @internal
421
     */
422
    public ngOnDestroy() {
423
        this.destroy$.next(true);
183✔
424
        this.destroy$.complete();
183✔
425
        this.selection.delete(this.id);
183✔
426
        this.selection.delete(`${this.id}-active`);
183✔
427
    }
428

429
    /** @hidden @internal */
430
    public calculateScrollPosition(item: IgxDropDownItemBaseDirective): number {
UNCOV
431
        if (!item) {
×
432
            return 0;
×
433
        }
434

UNCOV
435
        const elementRect = item.element.nativeElement.getBoundingClientRect();
×
UNCOV
436
        const parentRect = this.scrollContainer.getBoundingClientRect();
×
UNCOV
437
        const scrollDelta = parentRect.top - elementRect.top;
×
UNCOV
438
        let scrollPosition = this.scrollContainer.scrollTop - scrollDelta;
×
439

UNCOV
440
        const dropDownHeight = this.scrollContainer.clientHeight;
×
UNCOV
441
        scrollPosition -= dropDownHeight / 2;
×
UNCOV
442
        scrollPosition += item.elementHeight / 2;
×
443

UNCOV
444
        return Math.floor(scrollPosition);
×
445
    }
446

447
    /**
448
     * @hidden @internal
449
     */
450
    public ngOnChanges(changes: SimpleChanges) {
451
        if (changes.id) {
199!
452
            // temp workaround until fix --> https://github.com/angular/angular/issues/34992
UNCOV
453
            this.toggleDirective.id = changes.id.currentValue;
×
454
        }
455
    }
456

457
    public ngAfterViewInit() {
458
        if (this.virtDir) {
143!
UNCOV
459
            this.virtDir.igxForItemSize = 28;
×
460
        }
461
    }
462

463
    /** Keydown Handler */
464
    public override onItemActionKey(key: DropDownActionKey, event?: Event) {
UNCOV
465
        super.onItemActionKey(key, event);
×
UNCOV
466
        this.close(event);
×
467
    }
468

469
    /**
470
     * Virtual scroll implementation
471
     *
472
     * @hidden @internal
473
     */
474
    public override navigateFirst() {
475
        if (this.virtDir) {
39!
UNCOV
476
            this.navigateItem(0);
×
477
        } else {
478
            super.navigateFirst();
39✔
479
        }
480
    }
481

482
    /**
483
     * @hidden @internal
484
     */
485
    public override navigateLast() {
UNCOV
486
        if (this.virtDir) {
×
UNCOV
487
            this.navigateItem(this.virtDir.totalItemCount ? this.virtDir.totalItemCount - 1 : this.virtDir.igxForOf.length - 1);
×
488
        } else {
UNCOV
489
            super.navigateLast();
×
490
        }
491
    }
492

493
    /**
494
     * @hidden @internal
495
     */
496
    public override navigateNext() {
UNCOV
497
        if (this.virtDir) {
×
UNCOV
498
            this.navigateItem(this._focusedItem ? this._focusedItem.index + 1 : 0);
×
499
        } else {
UNCOV
500
            super.navigateNext();
×
501
        }
502
    }
503

504
    /**
505
     * @hidden @internal
506
     */
507
    public override navigatePrev() {
UNCOV
508
        if (this.virtDir) {
×
UNCOV
509
            this.navigateItem(this._focusedItem ? this._focusedItem.index - 1 : 0);
×
510
        } else {
UNCOV
511
            super.navigatePrev();
×
512
        }
513
    }
514

515
    /**
516
     * Handles the `selectionChanging` emit and the drop down toggle when selection changes
517
     *
518
     * @hidden
519
     * @internal
520
     * @param newSelection
521
     * @param emit
522
     * @param event
523
     */
524
    public override selectItem(newSelection?: IgxDropDownItemBaseDirective, event?: Event, emit = true) {
×
UNCOV
525
        const oldSelection = this.selectedItem;
×
UNCOV
526
        if (!newSelection) {
×
UNCOV
527
            newSelection = this.focusedItem;
×
528
        }
UNCOV
529
        if (newSelection === null) {
×
UNCOV
530
            return;
×
531
        }
UNCOV
532
        if (newSelection instanceof IgxDropDownItemBaseDirective && newSelection.isHeader) {
×
533
            return;
×
534
        }
UNCOV
535
        if (this.virtDir) {
×
UNCOV
536
            newSelection = {
×
537
                value: newSelection.value,
538
                index: newSelection.index
539
            } as IgxDropDownItemBaseDirective;
540
        }
UNCOV
541
        const args: ISelectionEventArgs = { oldSelection, newSelection, cancel: false, owner: this };
×
542

UNCOV
543
        if (emit) {
×
UNCOV
544
            this.selectionChanging.emit(args);
×
545
        }
546

UNCOV
547
        if (!args.cancel) {
×
UNCOV
548
            if (this.isSelectionValid(args.newSelection)) {
×
UNCOV
549
                this.selection.set(this.id, new Set([args.newSelection]));
×
UNCOV
550
                if (!this.virtDir) {
×
UNCOV
551
                    if (oldSelection) {
×
UNCOV
552
                        oldSelection.selected = false;
×
553
                    }
UNCOV
554
                    if (args.newSelection) {
×
UNCOV
555
                        args.newSelection.selected = true;
×
556
                    }
557
                }
UNCOV
558
                if (event) {
×
UNCOV
559
                    this.toggleDirective.close(event);
×
560
                }
561
            } else {
UNCOV
562
                throw new Error('Please provide a valid drop-down item for the selection!');
×
563
            }
564
        }
565
    }
566

567
    /**
568
     * Clears the selection of the dropdown
569
     * ```typescript
570
     * this.dropdown.clearSelection();
571
     * ```
572
     */
573
    public clearSelection() {
UNCOV
574
        const oldSelection = this.selectedItem;
×
UNCOV
575
        const newSelection: IgxDropDownItemBaseDirective = null;
×
UNCOV
576
        const args: ISelectionEventArgs = { oldSelection, newSelection, cancel: false, owner: this };
×
UNCOV
577
        this.selectionChanging.emit(args);
×
UNCOV
578
        if (this.selectedItem && !args.cancel) {
×
UNCOV
579
            this.selectedItem.selected = false;
×
UNCOV
580
            this.selection.clear(this.id);
×
581
        }
582
    }
583

584
    /**
585
     * Checks whether the selection is valid
586
     * `null` - the selection should be emptied
587
     * Virtual? - the selection should at least have and `index` and `value` property
588
     * Non-virtual? - the selection should be a valid drop-down item and **not** be a header
589
     */
590
    protected isSelectionValid(selection: any): boolean {
UNCOV
591
        return selection === null
×
592
            || (this.virtDir && selection.hasOwnProperty('value') && selection.hasOwnProperty('index'))
593
            || (selection instanceof IgxDropDownItemComponent && !selection.isHeader);
594
    }
595

596
    protected scrollToItem(item: IgxDropDownItemBaseDirective) {
UNCOV
597
        this.scrollContainer.scrollTop = this.calculateScrollPosition(item);
×
598
    }
599

600
    protected focusItem(value: boolean) {
601
        if (value || this._focusedItem) {
75✔
602
            this._focusedItem.focused = value;
74✔
603
        }
604
    }
605

606
    protected updateItemFocus() {
607
        if (this.selectedItem) {
41✔
608
            this.focusedItem = this.selectedItem;
37✔
609
            this.focusItem(true);
37✔
610
        } else if (this.allowItemsFocus) {
4!
UNCOV
611
            this.navigateFirst();
×
612
        }
613
    }
614

615
    protected skipHeader(direction: Navigate) {
UNCOV
616
        if (!this.focusedItem) {
×
UNCOV
617
            return;
×
618
        }
UNCOV
619
        if (this.focusedItem.isHeader || this.focusedItem.disabled) {
×
UNCOV
620
            if (direction === Navigate.Up) {
×
UNCOV
621
                this.navigatePrev();
×
622
            } else {
UNCOV
623
                this.navigateNext();
×
624
            }
625
        }
626
    }
627

628
    private isIndexOutOfBounds(index: number, direction: Navigate) {
UNCOV
629
        const virtState = this.virtDir.state;
×
UNCOV
630
        const currentPosition = this.virtDir.getScroll().scrollTop;
×
UNCOV
631
        const itemPosition = this.virtDir.getScrollForIndex(index, direction === Navigate.Down);
×
UNCOV
632
        const indexOutOfChunk = index < virtState.startIndex || index > virtState.chunkSize + virtState.startIndex;
×
UNCOV
633
        const scrollNeeded = direction === Navigate.Down ? currentPosition < itemPosition : currentPosition > itemPosition;
×
UNCOV
634
        const subRequired = indexOutOfChunk || scrollNeeded;
×
UNCOV
635
        return subRequired;
×
636
    }
637
}
638

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

© 2025 Coveralls, Inc