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

IgniteUI / igniteui-angular / 13355243137

16 Feb 2025 01:00PM CUT coverage: 91.629% (+0.004%) from 91.625%
13355243137

Pull #15279

github

web-flow
Merge a9f002677 into bcb78ae0a
Pull Request #15279: Fixing tab navigation in simple combo

12987 of 15218 branches covered (85.34%)

26390 of 28801 relevant lines covered (91.63%)

34353.37 hits per line

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

78.91
/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-data-selector.component.ts
1
import { useAnimation } from "@angular/animations";
2
import {
3
    ChangeDetectorRef,
4
    Component,
5
    EventEmitter,
6
    HostBinding,
7
    Input,
8
    Output,
9
    Renderer2,
10
    booleanAttribute
11
} from "@angular/core";
12
import { first } from "rxjs/operators";
13
import { SortingDirection } from "../../data-operations/sorting-strategy";
14
import { IDragBaseEventArgs, IDragGhostBaseEventArgs, IDragMoveEventArgs, IDropBaseEventArgs, IDropDroppedEventArgs, IgxDropDirective, IgxDragDirective, IgxDragHandleDirective } from "../../directives/drag-drop/drag-drop.directive";
15
import { ISelectionEventArgs } from "../../drop-down/drop-down.common";
16
import { IgxDropDownComponent } from "../../drop-down/drop-down.component";
17
import {
18
    AbsoluteScrollStrategy,
19
    AutoPositionStrategy,
20
    OverlaySettings,
21
    PositionSettings,
22
    VerticalAlignment
23
} from "../../services/public_api";
24
import { ColumnType, PivotGridType } from "../common/grid.interface";
25
import {
26
    IPivotAggregator,
27
    IPivotDimension,
28
    IPivotValue,
29
    PivotDimensionType
30
} from "./pivot-grid.interface";
31
import { PivotUtil } from './pivot-util';
32
import { IgxFilterPivotItemsPipe } from "./pivot-grid.pipes";
33
import { IgxDropDownItemComponent } from "../../drop-down/drop-down-item.component";
34
import { IgxDropDownItemNavigationDirective } from "../../drop-down/drop-down-navigation.directive";
35
import { IgxExpansionPanelBodyComponent } from "../../expansion-panel/expansion-panel-body.component";
36
import { IgxChipComponent } from "../../chips/chip.component";
37
import { IgxExpansionPanelTitleDirective } from "../../expansion-panel/expansion-panel.directives";
38
import { IgxExpansionPanelHeaderComponent } from "../../expansion-panel/expansion-panel-header.component";
39
import { IgxExpansionPanelComponent } from "../../expansion-panel/expansion-panel.component";
40
import { IgxAccordionComponent } from "../../accordion/accordion.component";
41
import { IgxCheckboxComponent } from "../../checkbox/checkbox.component";
42
import { IgxListItemComponent } from "../../list/list-item.component";
43
import { NgFor, NgIf } from "@angular/common";
44
import { IgxListComponent } from "../../list/list.component";
45
import { IgxInputDirective } from "../../directives/input/input.directive";
46
import { IgxPrefixDirective } from "../../directives/prefix/prefix.directive";
47
import { IgxIconComponent } from "../../icon/icon.component";
48
import { IgxInputGroupComponent } from "../../input-group/input-group.component";
49
import { fadeIn, fadeOut } from 'igniteui-angular/animations';
50
import { Size } from '../common/enums';
51

52
interface IDataSelectorPanel {
53
    name: string;
54
    i18n: string;
55
    type?: PivotDimensionType;
56
    dataKey: string;
57
    icon: string;
58
    itemKey: string;
59
    displayKey?: string;
60
    sortable: boolean;
61
    dragChannels: string[];
62
}
63

64
/* blazorIndirectRender
65
   blazorComponent */
66
/* wcElementTag: igc-pivot-data-selector */
67
/**
68
 * Pivot Data Selector provides means to configure the pivot state of the Pivot Grid via a vertical panel UI
69
 *
70
 * @igxModule IgxPivotGridModule
71
 * @igxGroup Grids & Lists
72
 * @igxKeywords data selector, pivot, grid
73
 * @igxTheme pivot-data-selector-theme
74
 * @remarks
75
 * The Ignite UI Data Selector has a searchable list with the grid data columns,
76
 * there are also four expandable areas underneath for filters, rows, columns, and values
77
 * is used for grouping and aggregating simple flat data into a pivot table.
78
 * @example
79
 * ```html
80
 * <igx-pivot-grid #grid1 [data]="data" [pivotConfiguration]="configuration">
81
 * </igx-pivot-grid>
82
 * <igx-pivot-data-selector [grid]="grid1"></igx-pivot-data-selector>
83
 * ```
84
 */
85
@Component({
86
    selector: "igx-pivot-data-selector",
87
    templateUrl: "./pivot-data-selector.component.html",
88
    imports: [IgxInputGroupComponent, IgxIconComponent, IgxPrefixDirective, IgxInputDirective, IgxListComponent, NgFor, IgxListItemComponent, IgxCheckboxComponent, IgxAccordionComponent, IgxExpansionPanelComponent, IgxExpansionPanelHeaderComponent, IgxDropDirective, IgxExpansionPanelTitleDirective, IgxChipComponent, IgxExpansionPanelBodyComponent, NgIf, IgxDragDirective, IgxDropDownItemNavigationDirective, IgxDragHandleDirective, IgxDropDownComponent, IgxDropDownItemComponent, IgxFilterPivotItemsPipe]
89
})
90
export class IgxPivotDataSelectorComponent {
2✔
91

92
    /**
93
     * Gets/sets whether the columns panel is expanded
94
     * Get
95
     * ```typescript
96
     *  const columnsPanelState: boolean = this.dataSelector.columnsExpanded;
97
     * ```
98
     * Set
99
     * ```html
100
     * <igx-pivot-data-selector [grid]="grid1" [columnsExpanded]="columnsPanelState"></igx-pivot-data-selector>
101
     * ```
102
     *
103
     * Two-way data binding:
104
     * ```html
105
     * <igx-pivot-data-selector [grid]="grid1" [(columnsExpanded)]="columnsPanelState"></igx-pivot-data-selector>
106
     * ```
107
     */
108
    @Input({ transform: booleanAttribute })
109
    public columnsExpanded = true;
69✔
110

111
    /**
112
     * @hidden
113
     */
114
    @Output()
115
    public columnsExpandedChange = new EventEmitter<boolean>();
69✔
116

117
    /**
118
     * Gets/sets whether the rows panel is expanded
119
     * Get
120
     * ```typescript
121
     *  const rowsPanelState: boolean = this.dataSelector.rowsExpanded;
122
     * ```
123
     * Set
124
     * ```html
125
     * <igx-pivot-data-selector [grid]="grid1" [rowsExpanded]="rowsPanelState"></igx-pivot-data-selector>
126
     * ```
127
     *
128
     * Two-way data binding:
129
     * ```html
130
     * <igx-pivot-data-selector [grid]="grid1" [(rowsExpanded)]="rowsPanelState"></igx-pivot-data-selector>
131
     * ```
132
     */
133
    @Input({ transform: booleanAttribute })
134
    public rowsExpanded = true;
69✔
135

136
    /**
137
     * @hidden
138
     */
139
    @Output()
140
    public rowsExpandedChange = new EventEmitter<boolean>();
69✔
141

142
    /**
143
     * Gets/sets whether the filters panel is expanded
144
     * Get
145
     * ```typescript
146
     *  const filtersPanelState: boolean = this.dataSelector.filtersExpanded;
147
     * ```
148
     * Set
149
     * ```html
150
     * <igx-pivot-data-selector [grid]="grid1" [filtersExpanded]="filtersPanelState"></igx-pivot-data-selector>
151
     * ```
152
     *
153
     * Two-way data binding:
154
     * ```html
155
     * <igx-pivot-data-selector [grid]="grid1" [(filtersExpanded)]="filtersPanelState"></igx-pivot-data-selector>
156
     * ```
157
     */
158
    @Input({ transform: booleanAttribute })
159
    public filtersExpanded = true;
69✔
160

161
    /**
162
     * @hidden
163
     */
164
    @Output()
165
    public filtersExpandedChange = new EventEmitter<boolean>();
69✔
166

167
    /**
168
     * Gets/sets whether the values panel is expanded
169
     * Get
170
     * ```typescript
171
     *  const valuesPanelState: boolean = this.dataSelector.valuesExpanded;
172
     * ```
173
     * Set
174
     * ```html
175
     * <igx-pivot-data-selector [grid]="grid1" [valuesExpanded]="valuesPanelState"></igx-pivot-data-selector>
176
     * ```
177
     *
178
     * Two-way data binding:
179
     * ```html
180
     * <igx-pivot-data-selector [grid]="grid1" [(valuesExpanded)]="valuesPanelState"></igx-pivot-data-selector>
181
     * ```
182
     */
183
    @Input({ transform: booleanAttribute })
184
    public valuesExpanded = true;
69✔
185

186
    /**
187
     * @hidden
188
     */
189
    @Output()
190
    public valuesExpandedChange = new EventEmitter<boolean>();
69✔
191

192
    private _grid: PivotGridType;
193
    private _dropDelta = 0;
69✔
194

195
    /** @hidden @internal **/
196
    @HostBinding("class.igx-pivot-data-selector")
197
    public cssClass = "igx-pivot-data-selector";
69✔
198

199
    @HostBinding("style.--ig-size")
200
    protected get size(): Size {
201
        return this.grid?.gridSize;
467✔
202
    }
203

204
    /** @hidden @internal **/
205
    public dimensions: IPivotDimension[];
206

207
    private _subMenuPositionSettings: PositionSettings = {
69✔
208
        verticalStartPoint: VerticalAlignment.Bottom,
209
        closeAnimation: undefined,
210
    };
211

212
    private _subMenuOverlaySettings: OverlaySettings = {
69✔
213
        closeOnOutsideClick: true,
214
        modal: false,
215
        positionStrategy: new AutoPositionStrategy(
216
            this._subMenuPositionSettings
217
        ),
218
        scrollStrategy: new AbsoluteScrollStrategy(),
219
    };
220

221
    /* blazorSuppress */
222
    public animationSettings = {
69✔
223
        closeAnimation: useAnimation(fadeOut, {
224
            params: {
225
                duration: "0ms",
226
            },
227
        }),
228
        openAnimation: useAnimation(fadeIn, {
229
            params: {
230
                duration: "0ms",
231
            },
232
        }),
233
    };
234

235
    /** @hidden @internal */
236
    public aggregateList: IPivotAggregator[] = [];
69✔
237
    /** @hidden @internal */
238
    public value: IPivotValue;
239
    /** @hidden @internal */
240
    public ghostText: string;
241
    /** @hidden @internal */
242
    public ghostWidth: number;
243
    /** @hidden @internal */
244
    public dropAllowed: boolean;
245
    /** @hidden @internal */
246
    public get dims(): IPivotDimension[] {
247
        return this._grid?.allDimensions || [];
535✔
248
    }
249
    /** @hidden @internal */
250
    public get values(): IPivotValue[] {
251
        return this._grid?.pivotConfiguration.values || [];
535✔
252
    }
253

254
    constructor(private renderer: Renderer2, private cdr: ChangeDetectorRef) { }
69✔
255

256
    /**
257
     * @hidden @internal
258
     */
259
    public _panels: IDataSelectorPanel[] = [
69✔
260
        {
261
            name: "Filters",
262
            i18n: 'igx_grid_pivot_selector_filters',
263
            type: PivotDimensionType.Filter,
264
            dataKey: "filterDimensions",
265
            icon: "filter_list",
266
            itemKey: "memberName",
267
            displayKey: 'displayName',
268
            sortable: false,
269
            dragChannels: ["Filters", "Columns", "Rows"]
270
        },
271
        {
272
            name: "Columns",
273
            i18n: 'igx_grid_pivot_selector_columns',
274
            type: PivotDimensionType.Column,
275
            dataKey: "columnDimensions",
276
            icon: "view_column",
277
            itemKey: "memberName",
278
            displayKey: 'displayName',
279
            sortable: true,
280
            dragChannels: ["Filters", "Columns", "Rows"]
281
        },
282
        {
283
            name: "Rows",
284
            i18n: 'igx_grid_pivot_selector_rows',
285
            type: PivotDimensionType.Row,
286
            dataKey: "rowDimensions",
287
            icon: "table_rows",
288
            itemKey: "memberName",
289
            displayKey: 'displayName',
290
            sortable: true,
291
            dragChannels: ["Filters", "Columns", "Rows"]
292
        },
293
        {
294
            name: "Values",
295
            i18n: 'igx_grid_pivot_selector_values',
296
            type: null,
297
            dataKey: "values",
298
            icon: "functions",
299
            itemKey: "member",
300
            displayKey: 'displayName',
301
            sortable: false,
302
            dragChannels: ["Values"]
303
        },
304
    ];
305

306

307
    /* treatAsRef */
308
    /**
309
     * Sets the grid.
310
     */
311
    @Input()
312
    public set grid(value: PivotGridType) {
313
        this._grid = value;
68✔
314
    }
315

316
    /* treatAsRef */
317
    /**
318
     * Returns the grid.
319
     */
320
    public get grid(): PivotGridType {
321
        return this._grid;
24,507✔
322
    }
323

324
    /**
325
     * @hidden
326
     * @internal
327
     */
328
    public onItemSort(
329
        _: Event,
330
        dimension: IPivotDimension,
331
        dimensionType: PivotDimensionType
332
    ) {
333
        if (
6!
334
            !this._panels.find(
335
                (panel: IDataSelectorPanel) => panel.type === dimensionType
15✔
336
            ).sortable
337
        )
338
            return;
×
339

340
        const startDirection = dimension.sortDirection || SortingDirection.None;
6✔
341
        const direction = startDirection + 1 > SortingDirection.Desc ?
6✔
342
            SortingDirection.None : startDirection + 1;
343
        this.grid.sortDimension(dimension, direction);
6✔
344
    }
345

346
    /**
347
     * @hidden
348
     * @internal
349
     */
350
    public onFilteringIconPointerDown(event: PointerEvent) {
351
        event.stopPropagation();
×
352
        event.preventDefault();
×
353
    }
354

355
    /**
356
     * @hidden
357
     * @internal
358
     */
359
    public onFilteringIconClick(event: MouseEvent, dimension: IPivotDimension) {
360
        event.stopPropagation();
2✔
361
        event.preventDefault();
2✔
362

363
        let dim = dimension;
2✔
364
        let col: ColumnType;
365

366
        while (dim) {
2✔
367
            col = this.grid.dimensionDataColumns.find(
2✔
368
                (x) => x.field === dim.memberName
3✔
369
            );
370
            if (col) {
2!
371
                break;
2✔
372
            } else {
373
                dim = dim.childLevel;
×
374
            }
375
        }
376

377
        this.grid.filteringService.toggleFilterDropdown(event.target, col);
2✔
378
    }
379

380
    /**
381
     * @hidden
382
     * @internal
383
     */
384
    protected getDimensionState(dimensionType: PivotDimensionType) {
385
        switch (dimensionType) {
1!
386
            case PivotDimensionType.Row:
387
                return this.grid.rowDimensions;
×
388
            case PivotDimensionType.Column:
389
                return this.grid.columnDimensions;
×
390
            case PivotDimensionType.Filter:
391
                return this.grid.filterDimensions;
×
392
            default:
393
                return null;
1✔
394
        }
395
    }
396

397
    /**
398
     * @hidden
399
     * @internal
400
     */
401
    protected moveValueItem(itemId: string) {
402
        const aggregation = this.grid.pivotConfiguration.values;
1✔
403
        const valueIndex =
404
            aggregation.findIndex((x) => x.member === itemId) !== -1
2!
405
                ? aggregation?.findIndex((x) => x.member === itemId)
2✔
406
                : aggregation.length;
407
        const newValueIndex =
408
            valueIndex + this._dropDelta < 0 ? 0 : valueIndex + this._dropDelta;
1!
409

410
        const aggregationItem = aggregation.find(
1✔
411
            (x) => x.member === itemId || x.displayName === itemId
2✔
412
        );
413

414
        if (aggregationItem) {
1✔
415
            this.grid.moveValue(aggregationItem, newValueIndex);
1✔
416
            this.grid.valuesChange.emit({
1✔
417
                values: this.grid.pivotConfiguration.values,
418
            });
419
        }
420
    }
421

422
    /**
423
     * @hidden
424
     * @internal
425
     */
426
    public onItemDropped(
427
        event: IDropDroppedEventArgs,
428
        dimensionType: PivotDimensionType
429
    ) {
430
        if (!this.dropAllowed) {
1!
431
            return;
×
432
        }
433

434
        const dimension = this.grid.getDimensionsByType(dimensionType);
1✔
435
        const dimensionState = this.getDimensionState(dimensionType);
1✔
436
        const itemId = event.drag.element.nativeElement.id;
1✔
437
        const targetId = event.owner.element.nativeElement.id;
1✔
438
        const dimensionItem = dimension?.find((x) => x.memberName === itemId);
1✔
439
        const itemIndex =
440
            dimension?.findIndex((x) => x?.memberName === itemId) !== -1
1!
441
                ? dimension?.findIndex((x) => x.memberName === itemId)
×
442
                : dimension?.length;
443
        const dimensions = this.grid.allDimensions.filter((x) => x && x.memberName === itemId);
2✔
444

445
        const reorder =
446
            dimensionState?.findIndex((item) => item.memberName === itemId) !==
1✔
447
            -1;
448

449
        let targetIndex =
450
            targetId !== ""
1!
451
                ? dimension?.findIndex((x) => x.memberName === targetId)
×
452
                : dimension?.length;
453

454
        if (!dimension) {
1✔
455
            this.moveValueItem(itemId);
1✔
456
        }
457

458
        if (reorder) {
1✔
459
            targetIndex =
1✔
460
                itemIndex + this._dropDelta < 0
1!
461
                    ? 0
462
                    : itemIndex + this._dropDelta;
463
        }
464

465
        if (dimensionItem) {
1!
466
            this.grid.moveDimension(dimensionItem, dimensionType, targetIndex);
×
467
        } else {
468
            const newDim = dimensions.find((x) => x.memberName === itemId);
1✔
469
            this.grid.moveDimension(newDim, dimensionType, targetIndex);
1✔
470
        }
471

472
        this.grid.dimensionsChange.emit({
1✔
473
            dimensions: dimension,
474
            dimensionCollectionType: dimensionType,
475
        });
476
    }
477

478
    /**
479
     * @hidden
480
     * @internal
481
     */
482
    protected updateDropDown(
483
        value: IPivotValue,
484
        dropdown: IgxDropDownComponent
485
    ) {
486
        this.value = value;
×
487
        dropdown.width = "200px";
×
488
        this.aggregateList = PivotUtil.getAggregateList(value, this.grid);
×
489
        this.cdr.detectChanges();
×
490
        dropdown.open(this._subMenuOverlaySettings);
×
491
    }
492

493
    /**
494
     * @hidden
495
     * @internal
496
     */
497
    public onSummaryClick(
498
        event: MouseEvent,
499
        value: IPivotValue,
500
        dropdown: IgxDropDownComponent
501
    ) {
502
        this._subMenuOverlaySettings.target =
×
503
            event.currentTarget as HTMLElement;
504

505
        if (dropdown.collapsed) {
×
506
            this.updateDropDown(value, dropdown);
×
507
        } else {
508
            // close for previous chip
509
            dropdown.close();
×
510
            dropdown.closed.pipe(first()).subscribe(() => {
×
511
                this.updateDropDown(value, dropdown);
×
512
            });
513
        }
514
    }
515

516
    /**
517
     * @hidden
518
     * @internal
519
     */
520
    public onAggregationChange(event: ISelectionEventArgs) {
521
        if (!this.isSelected(event.newSelection.value)) {
×
522
            this.value.aggregate = event.newSelection.value;
×
523
            this.grid.pipeTrigger++;
×
524
            this.grid.cdr.markForCheck();
×
525
        }
526
    }
527

528
    /**
529
     * @hidden
530
     * @internal
531
     */
532
    public isSelected(val: IPivotAggregator) {
533
        return this.value.aggregate.key === val.key;
×
534
    }
535

536
    /**
537
     * @hidden
538
     * @internal
539
     */
540
    public ghostCreated(event: IDragGhostBaseEventArgs, value: string) {
541
        const { width: itemWidth } =
542
            event.owner.element.nativeElement.getBoundingClientRect();
1✔
543
        this.ghostWidth = itemWidth;
1✔
544
        this.ghostText = value;
1✔
545
        this.renderer.setStyle(
1✔
546
            event.owner.element.nativeElement,
547
            "position",
548
            "absolute"
549
        );
550
        this.renderer.setStyle(
1✔
551
            event.owner.element.nativeElement,
552
            "visibility",
553
            "hidden"
554
        );
555
    }
556

557
    /**
558
     * @hidden
559
     * @internal
560
     */
561
    public toggleItem(item: IPivotDimension | IPivotValue) {
562
        if (item as IPivotValue) {
1✔
563
            this.grid.toggleValue(item as IPivotValue);
1✔
564
        }
565

566
        if (item as IPivotDimension) {
1✔
567
            this.grid.toggleDimension(item as IPivotDimension);
1✔
568
        }
569
    }
570

571
    /**
572
     * @hidden
573
     * @internal
574
     */
575
    public onPanelEntry(event: IDropBaseEventArgs, panel: string) {
576
        this.dropAllowed = event.dragData.gridID === this.grid.id && event.dragData.selectorChannels?.some(
2✔
577
            (channel: string) => channel === panel
2✔
578
        );
579
    }
580

581
    /**
582
     * @hidden
583
     * @internal
584
     */
585
    public onItemDragMove(event: IDragMoveEventArgs) {
586
        const clientRect =
587
            event.owner.element.nativeElement.getBoundingClientRect();
2✔
588
        this._dropDelta = Math.round(
2✔
589
            (event.nextPageY - event.startY) / clientRect.height
590
        );
591
    }
592

593
    /**
594
     * @hidden
595
     * @internal
596
     */
597
    public onItemDragEnd(event: IDragBaseEventArgs) {
598
        this.renderer.setStyle(
1✔
599
            event.owner.element.nativeElement,
600
            "position",
601
            "static"
602
        );
603
        this.renderer.setStyle(
1✔
604
            event.owner.element.nativeElement,
605
            "visibility",
606
            "visible"
607
        );
608
    }
609

610
    /**
611
     * @hidden
612
     * @internal
613
     */
614
    public onItemDragOver(event: IDropBaseEventArgs) {
615
        if (this.dropAllowed) {
2✔
616
            this.renderer.addClass(
2✔
617
                event.owner.element.nativeElement,
618
                "igx-drag--push"
619
            );
620
        }
621
    }
622

623
    /**
624
     * @hidden
625
     * @internal
626
     */
627
    public onItemDragLeave(event: IDropBaseEventArgs) {
628
        if (this.dropAllowed) {
2✔
629
            this.renderer.removeClass(
2✔
630
                event.owner.element.nativeElement,
631
                "igx-drag--push"
632
            );
633
        }
634
    }
635

636
    /**
637
     * @hidden
638
     * @internal
639
     */
640
    public getPanelCollapsed(panelType: PivotDimensionType): boolean {
641
        switch (panelType) {
2,140✔
642
            case PivotDimensionType.Column:
643
                return !this.columnsExpanded;
535✔
644
            case PivotDimensionType.Filter:
645
                return !this.filtersExpanded;
535✔
646
            case PivotDimensionType.Row:
647
                return !this.rowsExpanded;
535✔
648
            default:
649
                return !this.valuesExpanded;
535✔
650
        }
651
    }
652

653
    /**
654
     * @hidden
655
     * @internal
656
     */
657
    public onCollapseChange(value: boolean, panelType: PivotDimensionType): void {
658
        switch (panelType) {
4✔
659
            case PivotDimensionType.Column:
660
                this.columnsExpanded = !value;
1✔
661
                this.columnsExpandedChange.emit(this.columnsExpanded);
1✔
662
                break;
1✔
663
            case PivotDimensionType.Filter:
664
                this.filtersExpanded = !value;
1✔
665
                this.filtersExpandedChange.emit(this.filtersExpanded);
1✔
666
                break;
1✔
667
            case PivotDimensionType.Row:
668
                this.rowsExpanded = !value;
1✔
669
                this.rowsExpandedChange.emit(this.rowsExpanded);
1✔
670
                break;
1✔
671
            default:
672
                this.valuesExpanded = !value;
1✔
673
                this.valuesExpandedChange.emit(this.valuesExpanded)
1✔
674
        }
675
    }
676
}
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