• 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

0.53
/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-grid-navigation.service.ts
1
import { IActiveNode, IgxGridNavigationService } from '../grid-navigation.service';
2
import { Injectable } from '@angular/core';
3
import { IgxPivotGridComponent } from './pivot-grid.component';
4
import { HEADER_KEYS, ROW_COLLAPSE_KEYS, ROW_EXPAND_KEYS } from '../../core/utils';
5
import { PivotUtil } from './pivot-util';
6
import { IgxPivotRowDimensionMrlRowComponent } from './pivot-row-dimension-mrl-row.component';
7
import { IMultiRowLayoutNode } from '../public_api';
8
import { SortingDirection } from '../../data-operations/sorting-strategy';
9
import { take, timeout } from 'rxjs/operators';
10
import { IPivotDimension, IPivotGridRecord, PivotSummaryPosition } from './pivot-grid.interface';
11

12
@Injectable()
13
export class IgxPivotGridNavigationService extends IgxGridNavigationService {
2✔
14
    public override grid: IgxPivotGridComponent;
UNCOV
15
    public isRowHeaderActive = false;
×
UNCOV
16
    public isRowDimensionHeaderActive = false;
×
17

18
    public get lastRowDimensionsIndex() {
UNCOV
19
        return this.grid.visibleRowDimensions.length - 1;
×
20
    }
21

22
    public get lastRowDimensionMRLRowIndex() {
UNCOV
23
        return this.grid.verticalRowDimScrollContainers.first.igxGridForOf.length - 1;
×
24
    }
25

26
    public focusOutRowHeader() {
UNCOV
27
        this.isRowHeaderActive = false;
×
UNCOV
28
        this.isRowDimensionHeaderActive = false;
×
29
    }
30

31
    public override async handleNavigation(event: KeyboardEvent) {
UNCOV
32
        if (this.isRowHeaderActive) {
×
UNCOV
33
            const key = event.key.toLowerCase();
×
UNCOV
34
            const ctrl = event.ctrlKey;
×
UNCOV
35
            if (!HEADER_KEYS.has(key)) {
×
UNCOV
36
                return;
×
37
            }
UNCOV
38
            event.preventDefault();
×
39

UNCOV
40
            const newActiveNode: IActiveNode = {
×
41
                row: this.activeNode.row,
42
                column: this.activeNode.column,
43
                level: null,
44
                mchCache: null,
45
                layout: this.activeNode.layout
46
            }
47

UNCOV
48
            if (event.altKey) {
×
UNCOV
49
                this.handleAlt(key, event);
×
UNCOV
50
                return;
×
51
            }
52

53
            let verticalContainer;
UNCOV
54
            if (this.grid.hasHorizontalLayout) {
×
UNCOV
55
                let newPosition = {
×
56
                    row: this.activeNode.row,
57
                    column: this.activeNode.column,
58
                    layout: this.activeNode.layout
59
                };
UNCOV
60
                verticalContainer = this.grid.verticalRowDimScrollContainers.first;
×
UNCOV
61
                if (key.includes('left')) {
×
UNCOV
62
                    newPosition = await this.getNextHorizontalPosition(true, ctrl);
×
63
                }
UNCOV
64
                if (key.includes('right')) {
×
UNCOV
65
                    newPosition = await this.getNextHorizontalPosition(false, ctrl);
×
66
                }
UNCOV
67
                if (key.includes('up') || key === 'home') {
×
UNCOV
68
                    newPosition = await this.getNextVerticalPosition(true, ctrl || key === 'home', key === 'home');
×
69
                }
70

UNCOV
71
                if (key.includes('down') || key === 'end') {
×
UNCOV
72
                    newPosition = await this.getNextVerticalPosition(false, ctrl || key === 'end', key === 'end');
×
73
                }
74

UNCOV
75
                newActiveNode.row = newPosition.row;
×
UNCOV
76
                newActiveNode.column = newPosition.column;
×
UNCOV
77
                newActiveNode.layout = newPosition.layout;
×
78
            } else {
UNCOV
79
                if ((key.includes('left') || key === 'home') && this.activeNode.column > 0) {
×
UNCOV
80
                    newActiveNode.column = ctrl || key === 'home' ? 0 : this.activeNode.column - 1;
×
81
                }
UNCOV
82
                if ((key.includes('right') || key === 'end') && this.activeNode.column < this.lastRowDimensionsIndex) {
×
UNCOV
83
                    newActiveNode.column = ctrl || key === 'end' ? this.lastRowDimensionsIndex : this.activeNode.column + 1;
×
84
                }
85

UNCOV
86
                verticalContainer = this.grid.verticalRowDimScrollContainers.toArray()[newActiveNode.column];
×
UNCOV
87
                if (key.includes('up')) {
×
UNCOV
88
                    if (ctrl) {
×
UNCOV
89
                        newActiveNode.row = 0;
×
UNCOV
90
                    } else if (this.activeNode.row > 0) {
×
UNCOV
91
                        newActiveNode.row = this.activeNode.row - 1;
×
92
                    } else {
93
                        newActiveNode.row = -1;
×
94
                        newActiveNode.column = newActiveNode.layout ? newActiveNode.layout.colStart - 1 : 0;
×
95
                        newActiveNode.layout = null;
×
96
                        this.isRowDimensionHeaderActive = true;
×
97
                        this.isRowHeaderActive = false;
×
98
                        this.grid.theadRow.nativeElement.focus();
×
99
                    }
100
                }
101

UNCOV
102
                if (key.includes('down') && this.activeNode.row < this.findLastDataRowIndex()) {
×
UNCOV
103
                    newActiveNode.row = ctrl ? verticalContainer.igxForOf.length - 1 : Math.min(this.activeNode.row + 1, verticalContainer.igxForOf.length - 1);
×
104
                }
105

UNCOV
106
                if (key.includes('left') || key.includes('right')) {
×
UNCOV
107
                    const prevRIndex = this.activeNode.row;
×
UNCOV
108
                    const prevScrContainer = this.grid.verticalRowDimScrollContainers.toArray()[this.activeNode.column];
×
UNCOV
109
                    const src = prevScrContainer.getScrollForIndex(prevRIndex);
×
UNCOV
110
                    newActiveNode.row = this.activeNode.mchCache && this.activeNode.mchCache.level === newActiveNode.column ?
×
111
                        this.activeNode.mchCache.visibleIndex :
112
                        verticalContainer.getIndexAtScroll(src);
UNCOV
113
                    newActiveNode.mchCache = {
×
114
                        visibleIndex: this.activeNode.row,
115
                        level: this.activeNode.column
116
                    };
117
                }
118
            }
119

UNCOV
120
            this.setActiveNode(newActiveNode);
×
UNCOV
121
            if (!this.grid.hasHorizontalLayout && verticalContainer.isIndexOutsideView(newActiveNode.row)) {
×
122
                verticalContainer.scrollTo(newActiveNode.row);
×
123
            }
124
        } else {
UNCOV
125
            super.handleNavigation(event);
×
126
        }
127
    }
128

129
    public override handleAlt(key: string, event: KeyboardEvent): void {
UNCOV
130
        event.preventDefault();
×
131

132
        let rowData, dimIndex;
UNCOV
133
        if (!this.grid.hasHorizontalLayout) {
×
UNCOV
134
            dimIndex = this.activeNode.column;
×
UNCOV
135
            const scrContainer = this.grid.verticalRowDimScrollContainers.toArray()[dimIndex];
×
UNCOV
136
            rowData = scrContainer.igxGridForOf[this.activeNode.row];
×
137
        } else {
138
            const mrlRow = this.grid.rowDimensionMrlRowsCollection.find(mrl => mrl.rowIndex === this.activeNode.row);
×
139
            rowData = mrlRow.rowGroup[this.activeNode.layout.rowStart - 1];
×
140
            dimIndex = this.activeNode.layout.colStart - 1;
×
141
        }
UNCOV
142
        const dimension = this.grid.visibleRowDimensions[dimIndex];
×
UNCOV
143
        const expansionRowKey = PivotUtil.getRecordKey(rowData, dimension);
×
UNCOV
144
        const isExpanded = this.grid.expansionStates.get(expansionRowKey) ?? true;
×
145

146
        let prevCellLayout;
UNCOV
147
        if (this.grid.hasHorizontalLayout) {
×
148
            const parentRow = this.grid.rowDimensionMrlRowsCollection.find(row => row.rowIndex === this.activeNode.row);
×
149
            prevCellLayout = this.getNextVerticalColumnIndex(
×
150
                parentRow,
151
                Math.min(parentRow.rowGroup.length, this.activeNode.layout.rowStart),
152
                this.activeNode.layout.colStart);
153
        }
154

UNCOV
155
        if (ROW_EXPAND_KEYS.has(key) && !isExpanded) {
×
UNCOV
156
            this.grid.gridAPI.set_row_expansion_state(expansionRowKey, true, event)
×
UNCOV
157
        } else if (ROW_COLLAPSE_KEYS.has(key) && isExpanded) {
×
UNCOV
158
            this.grid.gridAPI.set_row_expansion_state(expansionRowKey, false, event)
×
159
        }
160

UNCOV
161
        if ((ROW_EXPAND_KEYS.has(key) && !isExpanded) || (ROW_COLLAPSE_KEYS.has(key) && isExpanded)) {
×
UNCOV
162
            this.onRowToggle(!isExpanded, dimension, rowData, prevCellLayout);
×
163
        }
UNCOV
164
        this.updateActiveNodeLayout();
×
UNCOV
165
        this.grid.notifyChanges();
×
166
    }
167

168
    public updateActiveNodeLayout() {
UNCOV
169
        if (this.grid.hasHorizontalLayout) {
×
170
            const mrlRow = this.grid.rowDimensionMrlRowsCollection.find(row => row.rowIndex === this.activeNode.row);
×
171
            const activeCell = mrlRow.contentCells.toArray()[this.activeNode.column];
×
172
            this.activeNode.layout = activeCell.layout;
×
173
        }
174
    }
175

176
    /** Update active cell when toggling row expand when horizontal summaries have position set to top */
177
    public onRowToggle(newExpandState: boolean, dimension: IPivotDimension, rowData: IPivotGridRecord, prevCellLayout: IMultiRowLayoutNode){
UNCOV
178
        if (this.grid.hasHorizontalLayout &&
×
179
            rowData.totalRecordDimensionName !== dimension.memberName &&
180
            dimension.horizontalSummary && this.grid.pivotUI.horizontalSummariesPosition === PivotSummaryPosition.Top) {
UNCOV
181
            const maxActiveRow = Math.min(this.lastRowDimensionMRLRowIndex, this.activeNode.row);
×
UNCOV
182
            const parentRowUpdated = this.grid.rowDimensionMrlRowsCollection.find(row => row.rowIndex === maxActiveRow);
×
UNCOV
183
            const maxRowEnd = parentRowUpdated.rowGroup.length + 1;
×
UNCOV
184
            const nextRowStart = Math.max(1, this.activeNode.layout.rowStart + (!newExpandState ? -1 : 1));
×
UNCOV
185
            const curValidRowStart = Math.min(parentRowUpdated.rowGroup.length, nextRowStart);
×
186
            // Get current cell layout, because the actineNode the rowStart might be different, based on where we come from(might be smaller cell).
187

UNCOV
188
            const curCellLayout = this.getNextVerticalColumnIndex(parentRowUpdated, curValidRowStart, this.activeNode.layout.colStart);
×
UNCOV
189
            const nextBlock = (!newExpandState && prevCellLayout.rowStart === 1) || (newExpandState &&  prevCellLayout.rowEnd >= maxRowEnd);
×
UNCOV
190
            this.activeNode.row += nextBlock ? (!newExpandState ? -1 : 1) : 0;
×
UNCOV
191
            this.activeNode.column = curCellLayout.columnVisibleIndex;
×
UNCOV
192
            this.activeNode.layout = curCellLayout;
×
193
        }
194
    }
195

196
    public override async headerNavigation(event: KeyboardEvent) {
UNCOV
197
        const key = event.key.toLowerCase();
×
UNCOV
198
        const ctrl = event.ctrlKey;
×
UNCOV
199
        if (!HEADER_KEYS.has(key)) {
×
UNCOV
200
            return;
×
201
        }
202

UNCOV
203
        if (this.isRowDimensionHeaderActive) {
×
204
            event.preventDefault();
×
205

206
            const newActiveNode: IActiveNode = {
×
207
                row: this.activeNode.row,
208
                column: this.activeNode.column,
209
                level: null,
210
                mchCache: this.activeNode.mchCache,
211
                layout: null
212
            }
213

214
            if (ctrl) {
×
215
                const dimIndex = this.activeNode.column;
×
216
                const dim = this.grid.visibleRowDimensions[dimIndex];
×
217
                if (this.activeNode.row === -1) {
×
218
                    if (key.includes('down') || key.includes('up')) {
×
219
                        let newSortDirection = SortingDirection.None;
×
220
                        if (key.includes('down')) {
×
221
                            newSortDirection = (dim.sortDirection === SortingDirection.Desc) ? SortingDirection.None : SortingDirection.Desc;
×
222
                        } else if (key.includes('up')) {
×
223
                            newSortDirection = (dim.sortDirection === SortingDirection.Asc) ? SortingDirection.None : SortingDirection.Asc;
×
224
                        }
225
                        this.grid.sortDimension(dim, newSortDirection);
×
226
                        return;
×
227
                    }
228
                }
229
            }
230
            if ((key.includes('left') || key === 'home') && this.activeNode.column > 0) {
×
231
                newActiveNode.column = ctrl || key === 'home' ? 0 : this.activeNode.column - 1;
×
232
            }
233
            if ((key.includes('right') || key === 'end') && this.activeNode.column < this.lastRowDimensionsIndex) {
×
234
                newActiveNode.column = ctrl || key === 'end' ? this.lastRowDimensionsIndex : this.activeNode.column + 1;
×
235
            } else if (key.includes('right')) {
×
236
                this.isRowDimensionHeaderActive = false;
×
237
                newActiveNode.column = 0;
×
238
                newActiveNode.level = this.activeNode.mchCache?.level || 0;
×
239
                newActiveNode.mchCache = this.activeNode.mchCache || {
×
240
                    level: 0,
241
                    visibleIndex: 0
242
                };
243
            }
244

245
            if (key.includes('down')) {
×
246
                if (this.grid.hasHorizontalLayout) {
×
247
                    this.activeNode.row = 0;
×
248
                    this.activeNode.layout = {
×
249
                        rowStart: 1,
250
                        rowEnd: 2,
251
                        colStart: newActiveNode.column + 1,
252
                        colEnd: newActiveNode.column + 2,
253
                        columnVisibleIndex: newActiveNode.column
254
                    };
255

256
                    const newPosition = await this.getNextVerticalPosition(true, ctrl || key === 'home', key === 'home');
×
257
                    newActiveNode.row = 0;
×
258
                    newActiveNode.column = newPosition.column;
×
259
                    newActiveNode.layout = newPosition.layout;
×
260
                } else {
261
                    const verticalContainer = this.grid.verticalRowDimScrollContainers.toArray()[newActiveNode.column];
×
262
                    newActiveNode.row = ctrl ? verticalContainer.igxForOf.length - 1 : 0;
×
263
                }
264

265
                this.isRowDimensionHeaderActive = false;
×
266
                this.isRowHeaderActive = true;
×
267
                this.grid.rowDimensionContainer.toArray()[this.grid.hasHorizontalLayout ? 0 : newActiveNode.column].nativeElement.focus();
×
268
            }
269

270
            this.setActiveNode(newActiveNode);
×
UNCOV
271
        } else if (key.includes('left') && this.activeNode.column === 0 && this.grid.pivotUI.showRowHeaders) {
×
272
            this.isRowDimensionHeaderActive = true;
×
273
            const newActiveNode: IActiveNode = {
×
274
                row: this.activeNode.row,
275
                column: this.lastRowDimensionsIndex,
276
                level: null,
277
                mchCache: this.activeNode.mchCache,
278
                layout: null
279
            }
280

281
            this.setActiveNode(newActiveNode);
×
282
        } else {
UNCOV
283
            super.headerNavigation(event);
×
284
        }
285
    }
286

287
    public override focusTbody(event) {
UNCOV
288
        if (!this.activeNode || this.activeNode.row === null || this.activeNode.row === undefined) {
×
UNCOV
289
            this.activeNode = this.lastActiveNode;
×
290
        } else {
UNCOV
291
            super.focusTbody(event);
×
292
        }
293
    }
294

295
    public async getNextVerticalPosition(previous, ctrl, homeEnd) {
UNCOV
296
        const parentRow = this.grid.rowDimensionMrlRowsCollection.find(row => row.rowIndex === this.activeNode.row);
×
UNCOV
297
        const maxRowEnd = parentRow.rowGroup.length + 1;
×
UNCOV
298
        const curValidRowStart = Math.min(parentRow.rowGroup.length, this.activeNode.layout.rowStart);
×
299
        // Get current cell layout, because the actineNode the rowStart might be different, based on where we come from(might be smaller cell).
UNCOV
300
        const curCellLayout = this.getNextVerticalColumnIndex(parentRow, curValidRowStart, this.activeNode.layout.colStart);
×
UNCOV
301
        const nextBlock = (previous && curCellLayout.rowStart === 1) || (!previous && curCellLayout.rowEnd === maxRowEnd);
×
UNCOV
302
        if (nextBlock &&
×
303
            ((previous && this.activeNode.row === 0) ||
304
            (!previous && this.activeNode.row === this.lastRowDimensionMRLRowIndex))) {
305
            if (previous && this.grid.pivotUI.showRowHeaders) {
×
306
                this.isRowDimensionHeaderActive = true;
×
307
                this.isRowHeaderActive = false;
×
308
                this.grid.theadRow.nativeElement.focus();
×
309
                return  { row: -1, column: this.activeNode.layout.colStart - 1, layout: this.activeNode.layout };
×
310
            }
311
            return { row: this.activeNode.row, column: this.activeNode.column, layout: this.activeNode.layout };
×
312
        }
313

UNCOV
314
        const nextMRLRowIndex = previous ?
×
315
            (ctrl ? 0 : this.activeNode.row - 1) :
×
316
            (ctrl ? this.lastRowDimensionMRLRowIndex : this.activeNode.row + 1) ;
×
UNCOV
317
        let nextRow = nextBlock || ctrl ? this.grid.rowDimensionMrlRowsCollection.find(row => row.rowIndex === nextMRLRowIndex) : parentRow;
×
UNCOV
318
        if (!nextRow) {
×
319
            const nextDataViewIndex = previous ?
×
320
                (ctrl ? 0 : parentRow.rowGroup[curCellLayout.rowStart - 1].dataIndex - 1) :
×
321
                (ctrl ? this.grid.dataView.length - 1 : parentRow.rowGroup[curCellLayout.rowEnd - 2].dataIndex + 1);
×
322
            await this.scrollToNextHorizontalDimRow(nextDataViewIndex);
×
323
            nextRow = nextBlock || ctrl ? this.grid.rowDimensionMrlRowsCollection.find(row => row.rowIndex === nextMRLRowIndex) : parentRow;
×
324
        }
325

UNCOV
326
        const nextRowStart = nextBlock ?
×
327
            (previous ? nextRow.rowGroup.length : 1) :
×
328
            (previous ? curCellLayout.rowStart - 1 : curCellLayout.rowEnd);
×
UNCOV
329
        const maxColEnd = Math.max(...nextRow.contentCells.map(cell => cell.layout.colEnd));
×
UNCOV
330
        const nextColumnLayout = this.getNextVerticalColumnIndex(
×
331
            nextRow,
332
            ctrl ? (previous ? 1 : nextRow.rowGroup.length) : nextRowStart,
×
333
            homeEnd ? (previous ? 1 : maxColEnd - 1) : this.activeNode.layout.colStart
×
334
        );
335

UNCOV
336
        const nextDataViewIndex = previous ?
×
337
            nextRow.rowGroup[nextColumnLayout.rowStart - 1].dataIndex:
338
            nextRow.rowGroup[nextColumnLayout.rowEnd - 2].dataIndex;
UNCOV
339
        await this.scrollToNextHorizontalDimRow(nextDataViewIndex);
×
340

UNCOV
341
        return {
×
342
            row: nextBlock || ctrl ? nextMRLRowIndex : this.activeNode.row,
×
343
            column: nextColumnLayout.columnVisibleIndex,
344
            layout: {
345
                rowStart: nextColumnLayout.rowStart,
346
                rowEnd: nextColumnLayout.rowEnd,
347
                colStart: homeEnd ? nextColumnLayout.colStart : this.activeNode.layout.colStart,
×
348
                colEnd: nextColumnLayout.colEnd,
349
                columnVisibleIndex: nextColumnLayout.columnVisibleIndex
350
            } as IMultiRowLayoutNode
351
        };
352
    }
353

354
    public async getNextHorizontalPosition(previous, ctrl) {
UNCOV
355
        const parentRow = this.grid.rowDimensionMrlRowsCollection.find(row => row.rowIndex === this.activeNode.row);
×
UNCOV
356
        const maxColEnd = Math.max(...parentRow.contentCells.map(cell => cell.layout.colEnd));
×
357
        // Get current cell layout, because the actineNode the rowStart might be different, based on where we come from(might be smaller cell).
UNCOV
358
        const curCellLayout = this.getNextVerticalColumnIndex(parentRow, this.activeNode.layout.rowStart, this.activeNode.layout.colStart);
×
359

UNCOV
360
        if ((previous && curCellLayout.colStart === 1) || (!previous && curCellLayout.colEnd === maxColEnd)) {
×
361
            return { row: this.activeNode.row, column: this.activeNode.column, layout: this.activeNode.layout };
×
362
        }
363

UNCOV
364
        const nextColStartNormal = curCellLayout.colStart + (previous ? -1 : curCellLayout.colEnd - curCellLayout.colStart);
×
UNCOV
365
        const nextColumnLayout = this.getNextVerticalColumnIndex(
×
366
            parentRow,
367
            this.activeNode.layout.rowStart,
368
            ctrl ? (previous ? 1 : maxColEnd - 1) : nextColStartNormal
×
369
        );
370

UNCOV
371
        const nextDataViewIndex = parentRow.rowGroup[nextColumnLayout.rowStart - 1].dataIndex
×
UNCOV
372
        await this.scrollToNextHorizontalDimRow(nextDataViewIndex);
×
373

UNCOV
374
        return {
×
375
            row: this.activeNode.row,
376
            column: nextColumnLayout.columnVisibleIndex,
377
            layout: {
378
                rowStart: this.activeNode.layout.rowStart,
379
                rowEnd: nextColumnLayout.rowEnd,
380
                colStart: nextColumnLayout.colStart,
381
                colEnd: nextColumnLayout.colEnd,
382
                columnVisibleIndex: nextColumnLayout.columnVisibleIndex
383
            } as IMultiRowLayoutNode
384
        };
385
    }
386

387
    private async scrollToNextHorizontalDimRow(nextDataViewIndex: number) {
UNCOV
388
        const verticalContainer = this.grid.verticalScrollContainer;
×
UNCOV
389
        if (verticalContainer.isIndexOutsideView(nextDataViewIndex)) {
×
390
            verticalContainer.scrollTo(nextDataViewIndex);
×
391
            await new Promise((resolve) => {
×
392
                this.grid.gridScroll.pipe(take(1), timeout({ first: 10000 })).subscribe({
×
393
                    next: (value) => resolve(value),
×
394
                    error: (err) => resolve(err)
×
395
                });
396
            });
397
        }
398
    }
399

400

401
    private getNextVerticalColumnIndex(nextRow: IgxPivotRowDimensionMrlRowComponent, newRowStart, newColStart) {
UNCOV
402
        const nextCell = nextRow.contentCells.find(cell => {
×
UNCOV
403
            return cell.layout.rowStart <= newRowStart && newRowStart < cell.layout.rowEnd &&
×
404
                cell.layout.colStart <= newColStart && newColStart < cell.layout.colEnd;
405
        });
UNCOV
406
        return nextCell.layout;
×
407
    }
408
}
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