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

IgniteUI / igniteui-angular / 13561607909

27 Feb 2025 08:03AM UTC coverage: 91.644% (+0.003%) from 91.641%
13561607909

push

github

web-flow
fix(grid): Update grid cell active state selector specificity (#15402)

13328 of 15596 branches covered (85.46%)

26882 of 29333 relevant lines covered (91.64%)

33708.8 hits per line

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

86.47
/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.ts
1
import {
2
    ChangeDetectionStrategy,
3
    Component,
4
    HostBinding,
5
    Input,
6
    OnInit,
7
    TemplateRef,
8
    ContentChild,
9
    AfterContentInit,
10
    ViewChild,
11
    DoCheck,
12
    AfterViewInit,
13
    ElementRef,
14
    NgZone,
15
    Inject,
16
    ChangeDetectorRef,
17
    IterableDiffers,
18
    ViewContainerRef,
19
    Optional,
20
    LOCALE_ID,
21
    Injector,
22
    EnvironmentInjector,
23
    CUSTOM_ELEMENTS_SCHEMA,
24
    booleanAttribute
25
} from '@angular/core';
26
import { DOCUMENT, NgClass, NgTemplateOutlet, NgStyle } from '@angular/common';
27

28
import { IgxTreeGridAPIService } from './tree-grid-api.service';
29
import { IgxGridBaseDirective } from '../grid-base.directive';
30
import { ITreeGridRecord } from './tree-grid.interfaces';
31
import { IRowDataCancelableEventArgs, IRowDataEventArgs, IRowToggleEventArgs } from '../common/events';
32
import {
33
    HierarchicalTransaction,
34
    HierarchicalState,
35
    TransactionType,
36
    TransactionEventOrigin,
37
    StateUpdateEvent
38
} from '../../services/transaction/transaction';
39
import { IgxFilteringService } from '../filtering/grid-filtering.service';
40
import { IgxGridSummaryService } from '../summaries/grid-summary.service';
41
import { IgxGridSelectionService } from '../selection/selection.service';
42
import { mergeObjects, PlatformUtil } from '../../core/utils';
43
import { first, takeUntil } from 'rxjs/operators';
44
import { IgxRowLoadingIndicatorTemplateDirective } from './tree-grid.directives';
45
import { IgxForOfSyncService, IgxForOfScrollSyncService } from '../../directives/for-of/for_of.sync.service';
46
import { IgxGridNavigationService } from '../grid-navigation.service';
47
import { CellType, GridServiceType, GridType, IGX_GRID_BASE, IGX_GRID_SERVICE_BASE, RowType } from '../common/grid.interface';
48
import { IgxColumnComponent } from '../columns/column.component';
49
import { IgxTreeGridSelectionService } from './tree-grid-selection.service';
50
import { GridSelectionMode } from '../common/enums';
51
import { IgxSummaryRow, IgxTreeGridRow } from '../grid-public-row';
52
import { IgxGridCRUDService } from '../common/crud.service';
53
import { IgxTreeGridGroupByAreaComponent } from '../grouping/tree-grid-group-by-area.component';
54
import { IgxGridCell } from '../grid-public-cell';
55
import { IgxHierarchicalTransactionFactory } from '../../services/transaction/transaction-factory.service';
56
import { IgxColumnResizingService } from '../resizing/resizing.service';
57
import { HierarchicalTransactionService } from '../../services/transaction/hierarchical-transaction';
58
import { IgxOverlayService } from '../../services/overlay/overlay';
59
import { IgxGridTransaction } from '../common/types';
60
import { TreeGridFilteringStrategy } from './tree-grid.filtering.strategy';
61
import { IgxGridValidationService } from '../grid/grid-validation.service';
62
import { IgxTreeGridSummaryPipe } from './tree-grid.summary.pipe';
63
import { IgxTreeGridFilteringPipe } from './tree-grid.filtering.pipe';
64
import { IgxTreeGridHierarchizingPipe, IgxTreeGridFlatteningPipe, IgxTreeGridSortingPipe, IgxTreeGridPagingPipe, IgxTreeGridTransactionPipe, IgxTreeGridNormalizeRecordsPipe, IgxTreeGridAddRowPipe } from './tree-grid.pipes';
65
import { IgxSummaryDataPipe } from '../summaries/grid-root-summary.pipe';
66
import { IgxHasVisibleColumnsPipe, IgxGridRowPinningPipe, IgxGridRowClassesPipe, IgxGridRowStylesPipe, IgxStringReplacePipe } from '../common/pipes';
67
import { IgxGridColumnResizerComponent } from '../resizing/resizer.component';
68
import { IgxIconComponent } from '../../icon/icon.component';
69
import { IgxRowEditTabStopDirective } from '../grid.rowEdit.directive';
70
import { IgxRippleDirective } from '../../directives/ripple/ripple.directive';
71
import { IgxButtonDirective } from '../../directives/button/button.directive';
72
import { IgxSnackbarComponent } from '../../snackbar/snackbar.component';
73
import { IgxCircularProgressBarComponent } from '../../progressbar/progressbar.component';
74
import { IgxOverlayOutletDirective, IgxToggleDirective } from '../../directives/toggle/toggle.directive';
75
import { IgxSummaryRowComponent } from '../summaries/summary-row.component';
76
import { IgxTreeGridRowComponent } from './tree-grid-row.component';
77
import { IgxTemplateOutletDirective } from '../../directives/template-outlet/template_outlet.directive';
78
import { IgxGridForOfDirective } from '../../directives/for-of/for_of.directive';
79
import { IgxColumnMovingDropDirective } from '../moving/moving.drop.directive';
80
import { IgxGridDragSelectDirective } from '../selection/drag-select.directive';
81
import { IgxGridBodyDirective } from '../grid.common';
82
import { IgxGridHeaderRowComponent } from '../headers/grid-header-row.component';
83
import { IgxTextHighlightService } from '../../directives/text-highlight/text-highlight.service';
84

85
let NEXT_ID = 0;
2✔
86

87
/* blazorAdditionalDependency: Column */
88
/* blazorAdditionalDependency: ColumnGroup */
89
/* blazorAdditionalDependency: ColumnLayout */
90
/* blazorAdditionalDependency: GridToolbar */
91
/* blazorAdditionalDependency: GridToolbarActions */
92
/* blazorAdditionalDependency: GridToolbarTitle */
93
/* blazorAdditionalDependency: GridToolbarAdvancedFiltering */
94
/* blazorAdditionalDependency: GridToolbarExporter */
95
/* blazorAdditionalDependency: GridToolbarHiding */
96
/* blazorAdditionalDependency: GridToolbarPinning */
97
/* blazorAdditionalDependency: ActionStrip */
98
/* blazorAdditionalDependency: GridActionsBaseDirective */
99
/* blazorAdditionalDependency: GridEditingActions */
100
/* blazorAdditionalDependency: GridPinningActions */
101
/* blazorIndirectRender */
102
/**
103
 * **Ignite UI for Angular Tree Grid** -
104
 * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/grid/grid)
105
 *
106
 * The Ignite UI Tree Grid displays and manipulates hierarchical data with consistent schema formatted as a table and
107
 * provides features such as sorting, filtering, editing, column pinning, paging, column moving and hiding.
108
 *
109
 * Example:
110
 * ```html
111
 * <igx-tree-grid [data]="employeeData" primaryKey="employeeID" foreignKey="PID" [autoGenerate]="false">
112
 *   <igx-column field="first" header="First Name"></igx-column>
113
 *   <igx-column field="last" header="Last Name"></igx-column>
114
 *   <igx-column field="role" header="Role"></igx-column>
115
 * </igx-tree-grid>
116
 * ```
117
 */
118
@Component({
119
    changeDetection: ChangeDetectionStrategy.OnPush,
120
    selector: 'igx-tree-grid',
121
    templateUrl: 'tree-grid.component.html',
122
    providers: [
123
        IgxGridCRUDService,
124
        IgxGridValidationService,
125
        IgxGridSummaryService,
126
        IgxGridNavigationService,
127
        { provide: IgxGridSelectionService, useClass: IgxTreeGridSelectionService },
128
        { provide: IGX_GRID_SERVICE_BASE, useClass: IgxTreeGridAPIService },
129
        { provide: IGX_GRID_BASE, useExisting: IgxTreeGridComponent },
130
        IgxFilteringService,
131
        IgxColumnResizingService,
132
        IgxForOfSyncService,
133
        IgxForOfScrollSyncService
134
    ],
135
    imports: [
136
        NgClass,
137
        NgStyle,
138
        NgTemplateOutlet,
139
        IgxGridHeaderRowComponent,
140
        IgxGridBodyDirective,
141
        IgxGridDragSelectDirective,
142
        IgxColumnMovingDropDirective,
143
        IgxGridForOfDirective,
144
        IgxTemplateOutletDirective,
145
        IgxTreeGridRowComponent,
146
        IgxSummaryRowComponent,
147
        IgxOverlayOutletDirective,
148
        IgxToggleDirective,
149
        IgxCircularProgressBarComponent,
150
        IgxSnackbarComponent,
151
        IgxButtonDirective,
152
        IgxRippleDirective,
153
        IgxRowEditTabStopDirective,
154
        IgxIconComponent,
155
        IgxGridColumnResizerComponent,
156
        IgxHasVisibleColumnsPipe,
157
        IgxGridRowPinningPipe,
158
        IgxGridRowClassesPipe,
159
        IgxGridRowStylesPipe,
160
        IgxSummaryDataPipe,
161
        IgxTreeGridHierarchizingPipe,
162
        IgxTreeGridFlatteningPipe,
163
        IgxTreeGridSortingPipe,
164
        IgxTreeGridFilteringPipe,
165
        IgxTreeGridPagingPipe,
166
        IgxTreeGridTransactionPipe,
167
        IgxTreeGridSummaryPipe,
168
        IgxTreeGridNormalizeRecordsPipe,
169
        IgxTreeGridAddRowPipe,
170
        IgxStringReplacePipe
171
    ],
172
    schemas: [CUSTOM_ELEMENTS_SCHEMA]
173
})
174
export class IgxTreeGridComponent extends IgxGridBaseDirective implements GridType, OnInit, AfterViewInit, DoCheck, AfterContentInit {
2✔
175
    /**
176
     * Sets the child data key of the `IgxTreeGridComponent`.
177
     * ```html
178
     * <igx-tree-grid #grid [data]="employeeData" [childDataKey]="'employees'" [autoGenerate]="true"></igx-tree-grid>
179
     * ```
180
     *
181
     * @memberof IgxTreeGridComponent
182
     */
183
    @Input()
184
    public childDataKey: string;
185

186
    /**
187
     * Sets the foreign key of the `IgxTreeGridComponent`.
188
     * ```html
189
     * <igx-tree-grid #grid [data]="employeeData" [primaryKey]="'employeeID'" [foreignKey]="'parentID'" [autoGenerate]="true">
190
     * </igx-tree-grid>
191
     * ```
192
     *
193
     * @memberof IgxTreeGridComponent
194
     */
195
    @Input()
196
    public foreignKey: string;
197

198
    /**
199
     * Sets the key indicating whether a row has children.
200
     * This property is only used for load on demand scenarios.
201
     * ```html
202
     * <igx-tree-grid #grid [data]="employeeData" [primaryKey]="'employeeID'" [foreignKey]="'parentID'"
203
     *                [loadChildrenOnDemand]="loadChildren"
204
     *                [hasChildrenKey]="'hasEmployees'">
205
     * </igx-tree-grid>
206
     * ```
207
     *
208
     * @memberof IgxTreeGridComponent
209
     */
210
    @Input()
211
    public hasChildrenKey: string;
212

213
    /**
214
     * Sets whether child records should be deleted when their parent gets deleted.
215
     * By default it is set to true and deletes all children along with the parent.
216
     * ```html
217
     * <igx-tree-grid [data]="employeeData" [primaryKey]="'employeeID'" [foreignKey]="'parentID'" cascadeOnDelete="false">
218
     * </igx-tree-grid>
219
     * ```
220
     *
221
     * @memberof IgxTreeGridComponent
222
     */
223
    @Input({ transform: booleanAttribute })
224
    public cascadeOnDelete = true;
526✔
225

226
    /* csSuppress */
227
    /**
228
     * Sets a callback for loading child rows on demand.
229
     * ```html
230
     * <igx-tree-grid [data]="employeeData" [primaryKey]="'employeeID'" [foreignKey]="'parentID'" [loadChildrenOnDemand]="loadChildren">
231
     * </igx-tree-grid>
232
     * ```
233
     * ```typescript
234
     * public loadChildren = (parentID: any, done: (children: any[]) => void) => {
235
     *     this.dataService.getData(parentID, children => done(children));
236
     * }
237
     * ```
238
     *
239
     * @memberof IgxTreeGridComponent
240
     */
241
    @Input()
242
    public loadChildrenOnDemand: (parentID: any, done: (children: any[]) => void) => void;
243

244
    /**
245
     * @hidden @internal
246
     */
247
    @HostBinding('attr.role')
248
    public role = 'treegrid';
526✔
249

250
    /**
251
     * Sets the value of the `id` attribute. If not provided it will be automatically generated.
252
     * ```html
253
     * <igx-tree-grid [id]="'igx-tree-grid-1'"></igx-tree-grid>
254
     * ```
255
     *
256
     * @memberof IgxTreeGridComponent
257
     */
258
    @HostBinding('attr.id')
259
    @Input()
260
    public id = `igx-tree-grid-${NEXT_ID++}`;
526✔
261

262
    /**
263
     * @hidden
264
     * @internal
265
     */
266
    @ContentChild(IgxTreeGridGroupByAreaComponent, { read: IgxTreeGridGroupByAreaComponent })
267
    public treeGroupArea: IgxTreeGridGroupByAreaComponent;
268

269
    /**
270
     * @hidden @internal
271
     */
272
    @ViewChild('record_template', { read: TemplateRef, static: true })
273
    protected recordTemplate: TemplateRef<any>;
274

275
    /**
276
     * @hidden @internal
277
     */
278
    @ViewChild('summary_template', { read: TemplateRef, static: true })
279
    protected summaryTemplate: TemplateRef<any>;
280

281
    /**
282
     * @hidden
283
     */
284
    @ContentChild(IgxRowLoadingIndicatorTemplateDirective, { read: IgxRowLoadingIndicatorTemplateDirective })
285
    protected rowLoadingTemplate: IgxRowLoadingIndicatorTemplateDirective;
286

287
    /**
288
     * @hidden
289
     */
290
    public flatData: any[] | null;
291

292
    /**
293
     * @hidden
294
     */
295
    public processedExpandedFlatData: any[] | null;
296

297
    /**
298
     * Returns an array of the root level `ITreeGridRecord`s.
299
     * ```typescript
300
     * // gets the root record with index=2
301
     * const states = this.grid.rootRecords[2];
302
     * ```
303
     *
304
     * @memberof IgxTreeGridComponent
305
     */
306
    public rootRecords: ITreeGridRecord[];
307

308
    /* blazorSuppress */
309
    /**
310
     * Returns a map of all `ITreeGridRecord`s.
311
     * ```typescript
312
     * // gets the record with primaryKey=2
313
     * const states = this.grid.records.get(2);
314
     * ```
315
     *
316
     * @memberof IgxTreeGridComponent
317
     */
318
    public records: Map<any, ITreeGridRecord> = new Map<any, ITreeGridRecord>();
526✔
319

320
    /**
321
     * Returns an array of processed (filtered and sorted) root `ITreeGridRecord`s.
322
     * ```typescript
323
     * // gets the processed root record with index=2
324
     * const states = this.grid.processedRootRecords[2];
325
     * ```
326
     *
327
     * @memberof IgxTreeGridComponent
328
     */
329
    public processedRootRecords: ITreeGridRecord[];
330

331
    /* blazorSuppress */
332
    /**
333
     * Returns a map of all processed (filtered and sorted) `ITreeGridRecord`s.
334
     * ```typescript
335
     * // gets the processed record with primaryKey=2
336
     * const states = this.grid.processedRecords.get(2);
337
     * ```
338
     *
339
     * @memberof IgxTreeGridComponent
340
     */
341
    public processedRecords: Map<any, ITreeGridRecord> = new Map<any, ITreeGridRecord>();
526✔
342

343
    /**
344
     * @hidden
345
     */
346
    public loadingRows = new Set<any>();
526✔
347

348
    protected override _filterStrategy = new TreeGridFilteringStrategy();
526✔
349
    protected override _transactions: HierarchicalTransactionService<HierarchicalTransaction, HierarchicalState>;
350
    private _data;
351
    private _rowLoadingIndicatorTemplate: TemplateRef<void>;
352
    private _expansionDepth = Infinity;
526✔
353

354
     /* treatAsRef */
355
    /**
356
     * Gets/Sets the array of data that populates the component.
357
     * ```html
358
     * <igx-tree-grid [data]="Data" [autoGenerate]="true"></igx-tree-grid>
359
     * ```
360
     *
361
     * @memberof IgxTreeGridComponent
362
     */
363
    @Input()
364
    public get data(): any[] | null {
365
        return this._data;
17,931✔
366
    }
367

368
     /* treatAsRef */
369
    public set data(value: any[] | null) {
370
        const oldData = this._data;
542✔
371
        this._data = value || [];
542✔
372
        this.summaryService.clearSummaryCache();
542✔
373
        if (!this._init) {
542✔
374
            this.validation.updateAll(this._data);
23✔
375
        }
376
        if (this.autoGenerate && this._data.length > 0 && this.shouldRecreateColumns(oldData, this._data)) {
542✔
377
            this.setupColumns();
4✔
378
        }
379
        this.checkPrimaryKeyField();
542✔
380
        this.cdr.markForCheck();
542✔
381
    }
382

383
    /** @hidden @internal */
384
    public override get type(): GridType["type"] {
385
        return 'tree';
2,382✔
386
    }
387

388
    /**
389
     * Get transactions service for the grid.
390
     *
391
     * @experimental @hidden
392
     */
393
    public override get transactions() {
394
        if (this._diTransactions && !this.batchEditing) {
3,284,308✔
395
            return this._diTransactions;
364,757✔
396
        }
397
        return this._transactions;
2,919,551✔
398
    }
399

400
    /**
401
     * Sets the count of levels to be expanded in the `IgxTreeGridComponent`. By default it is
402
     * set to `Infinity` which means all levels would be expanded.
403
     * ```html
404
     * <igx-tree-grid #grid [data]="employeeData" [childDataKey]="'employees'" expansionDepth="1" [autoGenerate]="true"></igx-tree-grid>
405
     * ```
406
     *
407
     * @memberof IgxTreeGridComponent
408
     */
409
    @Input()
410
    public get expansionDepth(): number {
411
        return this._expansionDepth;
14,108✔
412
    }
413

414
    public set expansionDepth(value: number) {
415
        this._expansionDepth = value;
175✔
416
        this.notifyChanges();
175✔
417
    }
418

419
    /**
420
     * Template for the row loading indicator when load on demand is enabled.
421
     * ```html
422
     * <ng-template #rowLoadingTemplate>
423
     *     <igx-icon>loop</igx-icon>
424
     * </ng-template>
425
     *
426
     * <igx-tree-grid #grid [data]="employeeData" [primaryKey]="'ID'" [foreignKey]="'parentID'"
427
     *                [loadChildrenOnDemand]="loadChildren"
428
     *                [rowLoadingIndicatorTemplate]="rowLoadingTemplate">
429
     * </igx-tree-grid>
430
     * ```
431
     *
432
     * @memberof IgxTreeGridComponent
433
     */
434
    @Input()
435
    public get rowLoadingIndicatorTemplate(): TemplateRef<void> {
436
        return this._rowLoadingIndicatorTemplate;
8✔
437
    }
438

439
    public set rowLoadingIndicatorTemplate(value: TemplateRef<void>) {
440
        this._rowLoadingIndicatorTemplate = value;
×
441
        this.notifyChanges();
×
442
    }
443

444
    // Kind of stupid
445
    // private get _gridAPI(): IgxTreeGridAPIService {
446
    //     return this.gridAPI as IgxTreeGridAPIService;
447
    // }
448

449
    constructor(
450
        validationService: IgxGridValidationService,
451
        selectionService: IgxGridSelectionService,
452
        colResizingService: IgxColumnResizingService,
453
        @Inject(IGX_GRID_SERVICE_BASE) gridAPI: GridServiceType,
454
        // public gridAPI: GridBaseAPIService<IgxGridBaseDirective & GridType>,
455
        transactionFactory: IgxHierarchicalTransactionFactory,
456
        _elementRef: ElementRef<HTMLElement>,
457
        _zone: NgZone,
458
        @Inject(DOCUMENT) document: any,
459
        cdr: ChangeDetectorRef,
460
        differs: IterableDiffers,
461
        viewRef: ViewContainerRef,
462
        injector: Injector,
463
        envInjector: EnvironmentInjector,
464
        navigation: IgxGridNavigationService,
465
        filteringService: IgxFilteringService,
466
        textHighlightService: IgxTextHighlightService,
467
        @Inject(IgxOverlayService) overlayService: IgxOverlayService,
468
        summaryService: IgxGridSummaryService,
469
        @Inject(LOCALE_ID) localeId: string,
470
        platform: PlatformUtil,
471
        @Optional() @Inject(IgxGridTransaction) protected override _diTransactions?:
526✔
472
            HierarchicalTransactionService<HierarchicalTransaction, HierarchicalState>,
473
    ) {
474
        super(
526✔
475
            validationService,
476
            selectionService,
477
            colResizingService,
478
            gridAPI,
479
            transactionFactory,
480
            _elementRef,
481
            _zone,
482
            document,
483
            cdr,
484
            differs,
485
            viewRef,
486
            injector,
487
            envInjector,
488
            navigation,
489
            filteringService,
490
            textHighlightService,
491
            overlayService,
492
            summaryService,
493
            localeId,
494
            platform,
495
            _diTransactions,
496
        );
497
    }
498

499
    /**
500
     * @hidden
501
     */
502
    public override ngOnInit() {
503
        super.ngOnInit();
526✔
504

505
        this.rowToggle.pipe(takeUntil(this.destroy$)).subscribe((args) => {
526✔
506
            this.loadChildrenOnRowExpansion(args);
175✔
507
        });
508

509
        // TODO: cascade selection logic should be refactor to be handled in the already existing subs
510
        this.rowAddedNotifier.pipe(takeUntil(this.destroy$)).subscribe(args => {
526✔
511
            if (this.rowSelection === GridSelectionMode.multipleCascade) {
61✔
512
                let rec = this.gridAPI.get_rec_by_id(this.primaryKey ? args.data[this.primaryKey] : args.data);
6!
513
                if (rec && rec.parent) {
6✔
514
                    this.gridAPI.grid.selectionService.updateCascadeSelectionOnFilterAndCRUD(
4✔
515
                        new Set([rec.parent]), rec.parent.key);
516
                } else {
517
                    // The record is still not available
518
                    // Wait for the change detection to update records through pipes
519
                    requestAnimationFrame(() => {
2✔
520
                        rec = this.gridAPI.get_rec_by_id(this.primaryKey ?
2!
521
                            args.data[this.primaryKey] : args.data);
522
                        if (rec && rec.parent) {
2✔
523
                            this.gridAPI.grid.selectionService.updateCascadeSelectionOnFilterAndCRUD(
2✔
524
                                new Set([rec.parent]), rec.parent.key);
525
                        }
526
                        this.notifyChanges();
2✔
527
                    });
528
                }
529
            }
530
        });
531

532
        this.rowDeletedNotifier.pipe(takeUntil(this.destroy$)).subscribe(args => {
526✔
533
            if (this.rowSelection === GridSelectionMode.multipleCascade) {
107✔
534
                if (args.data) {
8!
535
                    const rec = this.gridAPI.get_rec_by_id(
8✔
536
                        this.primaryKey ? args.data[this.primaryKey] : args.data);
8!
537
                    this.handleCascadeSelection(args, rec);
8✔
538
                } else {
539
                    // if a row has been added and before commiting the transaction deleted
540
                    const leafRowsDirectParents = new Set<any>();
×
541
                    this.records.forEach(record => {
×
542
                        if (record && (!record.children || record.children.length === 0) && record.parent) {
×
543
                            leafRowsDirectParents.add(record.parent);
×
544
                        }
545
                    });
546
                    // Wait for the change detection to update records through pipes
547
                    requestAnimationFrame(() => {
×
548
                        this.gridAPI.grid.selectionService.updateCascadeSelectionOnFilterAndCRUD(leafRowsDirectParents);
×
549
                        this.notifyChanges();
×
550
                    });
551
                }
552
            }
553
        });
554

555
        this.filteringDone.pipe(takeUntil(this.destroy$)).subscribe(() => {
526✔
556
            if (this.rowSelection === GridSelectionMode.multipleCascade) {
56✔
557
                const leafRowsDirectParents = new Set<any>();
15✔
558
                this.records.forEach(record => {
15✔
559
                    if (record && (!record.children || record.children.length === 0) && record.parent) {
151✔
560
                        leafRowsDirectParents.add(record.parent);
91✔
561
                    }
562
                });
563
                this.gridAPI.grid.selectionService.updateCascadeSelectionOnFilterAndCRUD(leafRowsDirectParents);
15✔
564
                this.notifyChanges();
15✔
565
            }
566
        });
567
    }
568

569
    /**
570
     * @hidden
571
     */
572
    public override ngAfterViewInit() {
573
        super.ngAfterViewInit();
526✔
574
        // TODO: pipesExectured event
575
        // run after change detection in super triggers pipes for records structure
576
        if (this.rowSelection === GridSelectionMode.multipleCascade && this.selectedRows.length) {
526!
577
            const selRows = this.selectedRows;
×
578
            this.selectionService.clearRowSelection();
×
579
            this.selectRows(selRows, true);
×
580
            this.cdr.detectChanges();
×
581
        }
582
    }
583

584
    /**
585
     * @hidden
586
     */
587
    public override ngAfterContentInit() {
588
        if (this.rowLoadingTemplate) {
526!
589
            this._rowLoadingIndicatorTemplate = this.rowLoadingTemplate.template;
×
590
        }
591
        super.ngAfterContentInit();
526✔
592
    }
593

594
    public override getDefaultExpandState(record: ITreeGridRecord) {
595
        return record.children && record.children.length && record.level < this.expansionDepth;
×
596
    }
597

598
    /**
599
     * Expands all rows.
600
     * ```typescript
601
     * this.grid.expandAll();
602
     * ```
603
     *
604
     * @memberof IgxTreeGridComponent
605
     */
606
    public override expandAll() {
607
        this._expansionDepth = Infinity;
41✔
608
        this.expansionStates = new Map<any, boolean>();
41✔
609
    }
610

611
    /**
612
     * Collapses all rows.
613
     *
614
     * ```typescript
615
     * this.grid.collapseAll();
616
     *  ```
617
     *
618
     * @memberof IgxTreeGridComponent
619
     */
620
    public override collapseAll() {
621
        this._expansionDepth = 0;
14✔
622
        this.expansionStates = new Map<any, boolean>();
14✔
623
    }
624

625
    /**
626
     * @hidden
627
     */
628
    public override refreshGridState(args?: IRowDataEventArgs) {
629
        super.refreshGridState();
65✔
630
        if (this.primaryKey && this.foreignKey && args) {
65✔
631
            const rowID = args.data[this.foreignKey];
35✔
632
            this.summaryService.clearSummaryCache({ rowID });
35✔
633
            this.pipeTrigger++;
35✔
634
            this.cdr.detectChanges();
35✔
635
        }
636
    }
637

638
    /* blazorCSSuppress */
639
    /**
640
     * Creates a new `IgxTreeGridRowComponent` with the given data. If a parentRowID is not specified, the newly created
641
     * row would be added at the root level. Otherwise, it would be added as a child of the row whose primaryKey matches
642
     * the specified parentRowID. If the parentRowID does not exist, an error would be thrown.
643
     * ```typescript
644
     * const record = {
645
     *     ID: this.grid.data[this.grid1.data.length - 1].ID + 1,
646
     *     Name: this.newRecord
647
     * };
648
     * this.grid.addRow(record, 1); // Adds a new child row to the row with ID=1.
649
     * ```
650
     *
651
     * @param data
652
     * @param parentRowID
653
     * @memberof IgxTreeGridComponent
654
     */
655
    // TODO: remove evt emission
656
    public override addRow(data: any, parentRowID?: any) {
657
        this.crudService.endEdit(true);
56✔
658
        this.gridAPI.addRowToData(data, parentRowID);
56✔
659

660
        this.rowAddedNotifier.next({
52✔
661
            data: data,
662
            rowData: data, owner: this,
663
            primaryKey: data[this.primaryKey],
664
            rowKey: data[this.primaryKey]
665
        });
666
        this.pipeTrigger++;
52✔
667
        this.notifyChanges();
52✔
668
    }
669

670
    /**
671
     * Enters add mode by spawning the UI with the context of the specified row by index.
672
     *
673
     * @remarks
674
     * Accepted values for index are integers from 0 to this.grid.dataView.length
675
     * @remarks
676
     * When adding the row as a child, the parent row is the specified row.
677
     * @remarks
678
     * To spawn the UI on top, call the function with index = null or a negative number.
679
     * In this case trying to add this row as a child will result in error.
680
     * @example
681
     * ```typescript
682
     * this.grid.beginAddRowByIndex(10);
683
     * this.grid.beginAddRowByIndex(10, true);
684
     * this.grid.beginAddRowByIndex(null);
685
     * ```
686
     * @param index - The index to spawn the UI at. Accepts integers from 0 to this.grid.dataView.length
687
     * @param asChild - Whether the record should be added as a child. Only applicable to igxTreeGrid.
688
     */
689
    public override beginAddRowByIndex(index: number, asChild?: boolean): void {
690
        if (index === null || index < 0) {
×
691
            return this.beginAddRowById(null, asChild);
×
692
        }
693
        return this._addRowForIndex(index - 1, asChild);
×
694
    }
695

696
    /**
697
     * @hidden
698
     */
699
    public getContext(rowData: any, rowIndex: number, pinned?: boolean): any {
700
        return {
45,221✔
701
            $implicit: this.isGhostRecord(rowData) ? rowData.recordRef : rowData,
45,221✔
702
            index: this.getDataViewIndex(rowIndex, pinned),
703
            templateID: {
704
                type: this.isSummaryRow(rowData) ? 'summaryRow' : 'dataRow',
45,221✔
705
                id: null
706
            },
707
            disabled: this.isGhostRecord(rowData) ? rowData.recordRef.isFilteredOutParent === undefined : false
45,221✔
708
        };
709
    }
710

711
    /**
712
     * @hidden
713
     * @internal
714
     */
715
    public override getInitialPinnedIndex(rec) {
716
        const id = this.gridAPI.get_row_id(rec);
199,560✔
717
        return this._pinnedRecordIDs.indexOf(id);
199,560✔
718
    }
719

720
    /**
721
     * @hidden
722
     * @internal
723
     */
724
    public override isRecordPinned(rec) {
725
        return this.getInitialPinnedIndex(rec.data) !== -1;
199,514✔
726
    }
727

728
    /**
729
     *
730
     * Returns an array of the current cell selection in the form of `[{ column.field: cell.value }, ...]`.
731
     *
732
     * @remarks
733
     * If `formatters` is enabled, the cell value will be formatted by its respective column formatter (if any).
734
     * If `headers` is enabled, it will use the column header (if any) instead of the column field.
735
     */
736
    public override getSelectedData(formatters = false, headers = false): any[] {
128✔
737
        let source = [];
64✔
738

739
        const process = (record) => {
64✔
740
            if (record.summaries) {
1,091✔
741
                source.push(null);
21✔
742
                return;
21✔
743
            }
744
            source.push(record.data);
1,070✔
745
        };
746

747
        this.unpinnedDataView.forEach(process);
64✔
748
        source = this.isRowPinningToTop ? [...this.pinnedDataView, ...source] : [...source, ...this.pinnedDataView];
64!
749
        return this.extractDataFromSelection(source, formatters, headers);
64✔
750
    }
751

752
    /**
753
     * @hidden @internal
754
     */
755
    public override getEmptyRecordObjectFor(inTreeRow: RowType) {
756
        const treeRowRec = inTreeRow?.treeRow || null;
10✔
757
        const row = { ...treeRowRec };
10✔
758
        const data = treeRowRec?.data || {};
10✔
759
        row.data = { ...data };
10✔
760
        Object.keys(row.data).forEach(key => {
10✔
761
            // persist foreign key if one is set.
762
            if (this.foreignKey && key === this.foreignKey) {
45✔
763
                row.data[key] = treeRowRec.data[this.crudService.addRowParent?.asChild ? this.primaryKey : key];
8✔
764
            } else {
765
                row.data[key] = undefined;
37✔
766
            }
767
        });
768
        let id = this.generateRowID();
10✔
769
        const rootRecPK = this.foreignKey && this.rootRecords && this.rootRecords.length > 0 ?
10✔
770
            this.rootRecords[0].data[this.foreignKey] : null;
771
        if (id === rootRecPK) {
10!
772
            // safeguard in case generated id matches the root foreign key.
773
            id = this.generateRowID();
×
774
        }
775
        row.key = id;
10✔
776
        row.data[this.primaryKey] = id;
10✔
777
        return { rowID: id, data: row.data, recordRef: row };
10✔
778
    }
779

780
    /** @hidden */
781
    public override deleteRowById(rowId: any): any {
782
        //  if this is flat self-referencing data, and CascadeOnDelete is set to true
783
        //  and if we have transactions we should start pending transaction. This allows
784
        //  us in case of delete action to delete all child rows as single undo action
785
        const args: IRowDataCancelableEventArgs = {
64✔
786
            rowID: rowId,
787
            primaryKey: rowId,
788
            rowKey: rowId,
789
            cancel: false,
790
            rowData: this.getRowData(rowId),
791
            data: this.getRowData(rowId),
792
            oldValue: null,
793
            owner: this
794
        };
795
        this.rowDelete.emit(args);
64✔
796
        if (args.cancel) {
64✔
797
            return;
1✔
798
        }
799

800
        const record = this.gridAPI.deleteRowById(rowId);
63✔
801
        const key = record[this.primaryKey];
63✔
802
        if (record !== null && record !== undefined) {
63✔
803
            const rowDeletedEventArgs: IRowDataEventArgs = {
63✔
804
                data: record,
805
                rowData: record,
806
                owner: this,
807
                primaryKey: key,
808
                rowKey: key
809
            };
810
            this.rowDeleted.emit(rowDeletedEventArgs);
63✔
811
        }
812
        return record;
63✔
813
    }
814

815
    /**
816
     * Returns the `IgxTreeGridRow` by index.
817
     *
818
     * @example
819
     * ```typescript
820
     * const myRow = treeGrid.getRowByIndex(1);
821
     * ```
822
     * @param index
823
     */
824
    public getRowByIndex(index: number): RowType {
825
        if (index < 0 || index >= this.dataView.length) {
902✔
826
            return undefined;
1✔
827
        }
828
        return this.createRow(index);
901✔
829
    }
830

831
    /**
832
     * Returns the `RowType` object by the specified primary key.
833
     *
834
     * @example
835
     * ```typescript
836
     * const myRow = this.treeGrid.getRowByIndex(1);
837
     * ```
838
     * @param index
839
     */
840
    public getRowByKey(key: any): RowType {
841
        const rec = this.filteredSortedData ? this.primaryKey ? this.filteredSortedData.find(r => r[this.primaryKey] === key) :
307✔
842
            this.filteredSortedData.find(r => r === key) : undefined;
10✔
843
        const index = this.dataView.findIndex(r => r.data && r.data === rec);
362✔
844
        if (index < 0 || index >= this.filteredSortedData.length) {
108✔
845
            return undefined;
7✔
846
        }
847
        return new IgxTreeGridRow(this as any, index, rec);
101✔
848
    }
849

850
    /**
851
     * Returns the collection of all RowType for current page.
852
     *
853
     * @hidden @internal
854
     */
855
    public allRows(): RowType[] {
856
        return this.dataView.map((rec, index) => this.createRow(index));
1,579✔
857
    }
858

859
    /**
860
     * Returns the collection of `IgxTreeGridRow`s for current page.
861
     *
862
     * @hidden @internal
863
     */
864
    public dataRows(): RowType[] {
865
        return this.allRows().filter(row => row instanceof IgxTreeGridRow);
1,579✔
866
    }
867

868
    /**
869
     * Returns an array of the selected `IgxGridCell`s.
870
     *
871
     * @example
872
     * ```typescript
873
     * const selectedCells = this.grid.selectedCells;
874
     * ```
875
     */
876
    public get selectedCells(): CellType[] {
877
        return this.dataRows().map((row) => row.cells.filter((cell) => cell.selected))
7,478✔
878
            .reduce((a, b) => a.concat(b), []);
1,579✔
879
    }
880

881
    /**
882
     * Returns a `CellType` object that matches the conditions.
883
     *
884
     * @example
885
     * ```typescript
886
     * const myCell = this.grid1.getCellByColumn(2, "UnitPrice");
887
     * ```
888
     * @param rowIndex
889
     * @param columnField
890
     */
891
    public getCellByColumn(rowIndex: number, columnField: string): CellType {
892
        const row = this.getRowByIndex(rowIndex);
221✔
893
        const column = this.columns.find((col) => col.field === columnField);
532✔
894
        if (row && row instanceof IgxTreeGridRow && column) {
221✔
895
            return new IgxGridCell(this as any, rowIndex, column);
221✔
896
        }
897
    }
898

899
    /**
900
     * Returns a `CellType` object that matches the conditions.
901
     *
902
     * @remarks
903
     * Requires that the primaryKey property is set.
904
     * @example
905
     * ```typescript
906
     * grid.getCellByKey(1, 'index');
907
     * ```
908
     * @param rowSelector match any rowID
909
     * @param columnField
910
     */
911
    public getCellByKey(rowSelector: any, columnField: string): CellType {
912
        const row = this.getRowByKey(rowSelector);
5✔
913
        const column = this.columns.find((col) => col.field === columnField);
10✔
914
        if (row && column) {
5✔
915
            return new IgxGridCell(this as any, row.index, column);
5✔
916
        }
917
    }
918

919
    public override pinRow(rowID: any, index?: number): boolean {
920
        const row = this.getRowByKey(rowID);
27✔
921
        return super.pinRow(rowID, index, row);
27✔
922
    }
923

924
    public override unpinRow(rowID: any): boolean {
925
        const row = this.getRowByKey(rowID);
5✔
926
        return super.unpinRow(rowID, row);
5✔
927
    }
928

929
    /** @hidden */
930
    public generateRowPath(rowId: any): any[] {
931
        const path: any[] = [];
52✔
932
        let record = this.records.get(rowId);
52✔
933

934
        while (record.parent) {
52✔
935
            path.push(record.parent.key);
46✔
936
            record = record.parent;
46✔
937
        }
938

939
        return path.reverse();
52✔
940
    }
941

942
    /** @hidden */
943
    public isTreeRow(record: any): boolean {
944
        return record.key !== undefined && record.data;
12,744✔
945
    }
946

947
    /** @hidden */
948
    public override getUnpinnedIndexById(id) {
949
        return this.unpinnedRecords.findIndex(x => x.data[this.primaryKey] === id);
43✔
950
    }
951

952
    /**
953
     * @hidden
954
     */
955
    public createRow(index: number, data?: any): RowType {
956
        let row: RowType;
957
        const dataIndex = this._getDataViewIndex(index);
12,751✔
958
        const rec: any = data ?? this.dataView[dataIndex];
12,751✔
959

960
        if (this.isSummaryRow(rec)) {
12,751✔
961
            row = new IgxSummaryRow(this as any, index, rec.summaries);
7✔
962
        }
963

964
        if (!row && rec) {
12,751✔
965
            const isTreeRow = this.isTreeRow(rec);
12,744✔
966
            const dataRec = isTreeRow ? rec.data : rec;
12,744✔
967
            const treeRow = isTreeRow ? rec : undefined;
12,744✔
968
            row = new IgxTreeGridRow(this as any, index, dataRec, treeRow);
12,744✔
969
        }
970

971
        return row;
12,751✔
972
    }
973

974
    protected override generateDataFields(data: any[]): string[] {
975
        return super.generateDataFields(data).filter(field => field !== this.childDataKey);
16✔
976
    }
977

978
    protected override transactionStatusUpdate(event: StateUpdateEvent) {
979
        let actions = [];
152✔
980
        if (event.origin === TransactionEventOrigin.REDO) {
152✔
981
            actions = event.actions ? event.actions.filter(x => x.transaction.type === TransactionType.DELETE) : [];
25!
982
            if (this.rowSelection === GridSelectionMode.multipleCascade) {
20✔
983
                this.handleCascadeSelection(event);
1✔
984
            }
985
        } else if (event.origin === TransactionEventOrigin.UNDO) {
132✔
986
            actions = event.actions ? event.actions.filter(x => x.transaction.type === TransactionType.ADD) : [];
32!
987
            if (this.rowSelection === GridSelectionMode.multipleCascade) {
27✔
988
                if (event.actions[0].transaction.type === 'add') {
2✔
989
                    const rec = this.gridAPI.get_rec_by_id(event.actions[0].transaction.id);
1✔
990
                    this.handleCascadeSelection(event, rec);
1✔
991
                } else {
992
                    this.handleCascadeSelection(event);
1✔
993
                }
994
            }
995
        }
996
        if (actions.length) {
152✔
997
            for (const action of actions) {
13✔
998
                this.deselectChildren(action.transaction.id);
18✔
999
            }
1000
        }
1001
        super.transactionStatusUpdate(event);
152✔
1002
    }
1003

1004
    protected findRecordIndexInView(rec) {
1005
        return this.dataView.findIndex(x => x.data[this.primaryKey] === rec[this.primaryKey]);
×
1006
    }
1007

1008
    /**
1009
     * @hidden @internal
1010
     */
1011
    protected override getDataBasedBodyHeight(): number {
1012
        return !this.flatData || (this.flatData.length < this._defaultTargetRecordNumber) ?
44✔
1013
            0 : this.defaultTargetBodyHeight;
1014
    }
1015

1016
    /**
1017
     * @hidden
1018
     */
1019
    protected override scrollTo(row: any | number, column: any | number): void {
1020
        let delayScrolling = false;
85✔
1021
        let record: ITreeGridRecord;
1022

1023
        if (typeof (row) !== 'number') {
85✔
1024
            const rowData = row;
84✔
1025
            const rowID = this.gridAPI.get_row_id(rowData);
84✔
1026
            record = this.processedRecords.get(rowID);
84✔
1027
            this.gridAPI.expand_path_to_record(record);
84✔
1028

1029
            if (this.paginator) {
84✔
1030
                const rowIndex = this.processedExpandedFlatData.indexOf(rowData);
27✔
1031
                const page = Math.floor(rowIndex / this.perPage);
27✔
1032

1033
                if (this.page !== page) {
27✔
1034
                    delayScrolling = true;
7✔
1035
                    this.page = page;
7✔
1036
                }
1037
            }
1038
        }
1039

1040
        if (delayScrolling) {
85✔
1041
            this.verticalScrollContainer.dataChanged.pipe(first()).subscribe(() => {
7✔
1042
                this.scrollDirective(this.verticalScrollContainer,
7✔
1043
                    typeof (row) === 'number' ? row : this.unpinnedDataView.indexOf(record));
7!
1044
            });
1045
        } else {
1046
            this.scrollDirective(this.verticalScrollContainer,
78✔
1047
                typeof (row) === 'number' ? row : this.unpinnedDataView.indexOf(record));
78✔
1048
        }
1049

1050
        this.scrollToHorizontally(column);
85✔
1051
    }
1052

1053
    protected override writeToData(rowIndex: number, value: any) {
1054
        mergeObjects(this.flatData[rowIndex], value);
×
1055
    }
1056

1057
    /**
1058
     * @hidden
1059
     */
1060
    protected override initColumns(collection: IgxColumnComponent[], cb: (args: any) => void = null) {
×
1061
        if (this.hasColumnLayouts) {
530!
1062
            // invalid configuration - tree grid should not allow column layouts
1063
            // remove column layouts
1064
            const nonColumnLayoutColumns = this.columns.filter((col) => !col.columnLayout && !col.columnLayoutChild);
×
1065
            this.updateColumns(nonColumnLayoutColumns);
×
1066
        }
1067
        super.initColumns(collection, cb);
530✔
1068
    }
1069

1070
    /**
1071
     * @hidden @internal
1072
     */
1073
    protected override getGroupAreaHeight(): number {
1074
        return this.treeGroupArea ? this.getComputedHeight(this.treeGroupArea.nativeElement) : 0;
1,504✔
1075
    }
1076

1077
    /** {@link triggerPipes} will re-create pinnedData on CRUD operations */
1078
    protected trackPinnedRowData(record: ITreeGridRecord) {
1079
        // TODO FIX: pipeline data doesn't match end interface (¬_¬ )
1080
        // return record.key || (record as any).rowID;
1081
        return record;
556✔
1082
    }
1083

1084
    /**
1085
     * @description A recursive way to deselect all selected children of a given record
1086
     * @param recordID ID of the record whose children to deselect
1087
     * @hidden
1088
     * @internal
1089
     */
1090
    private deselectChildren(recordID): void {
1091
        const selectedChildren = [];
21✔
1092
        // G.E. Apr 28, 2021 #9465 Records which are not in view can also be selected so we need to
1093
        // deselect them as well, hence using 'records' map instead of getRowByKey() method which will
1094
        // return only row components (i.e. records in view).
1095
        const rowToDeselect = this.records.get(recordID);
21✔
1096
        this.selectionService.deselectRowsWithNoEvent([recordID]);
21✔
1097
        this.gridAPI.get_selected_children(rowToDeselect, selectedChildren);
21✔
1098
        if (selectedChildren.length > 0) {
21✔
1099
            selectedChildren.forEach(x => this.deselectChildren(x));
3✔
1100
        }
1101
    }
1102

1103
    private addChildRows(children: any[], parentID: any) {
1104
        if (this.primaryKey && this.foreignKey) {
8✔
1105
            for (const child of children) {
6✔
1106
                child[this.foreignKey] = parentID;
10✔
1107
            }
1108
            this.data.push(...children);
6✔
1109
        } else if (this.childDataKey) {
2✔
1110
            let parent = this.records.get(parentID);
2✔
1111
            let parentData = parent.data;
2✔
1112

1113
            if (this.transactions.enabled && this.transactions.getAggregatedChanges(true).length) {
2!
1114
                const path = [];
×
1115
                while (parent) {
×
1116
                    path.push(parent.key);
×
1117
                    parent = parent.parent;
×
1118
                }
1119

1120
                let collection = this.data;
×
1121
                let record: any;
1122
                for (let i = path.length - 1; i >= 0; i--) {
×
1123
                    const pid = path[i];
×
1124
                    record = collection.find(r => r[this.primaryKey] === pid);
×
1125

1126
                    if (!record) {
×
1127
                        break;
×
1128
                    }
1129
                    collection = record[this.childDataKey];
×
1130
                }
1131
                if (record) {
×
1132
                    parentData = record;
×
1133
                }
1134
            }
1135

1136
            parentData[this.childDataKey] = children;
2✔
1137
        }
1138
        this.selectionService.clearHeaderCBState();
8✔
1139
        this.pipeTrigger++;
8✔
1140
        if (this.rowSelection === GridSelectionMode.multipleCascade) {
8✔
1141
            // Force pipe triggering for building the data structure
1142
            this.cdr.detectChanges();
2✔
1143
            if (this.selectionService.isRowSelected(parentID)) {
2✔
1144
                this.selectionService.rowSelection.delete(parentID);
2✔
1145
                this.selectionService.selectRowsWithNoEvent([parentID]);
2✔
1146
            }
1147
        }
1148
    }
1149

1150
    private loadChildrenOnRowExpansion(args: IRowToggleEventArgs) {
1151
        if (this.loadChildrenOnDemand) {
175✔
1152
            const parentID = args.rowID;
11✔
1153

1154
            if (args.expanded && !this._expansionStates.has(parentID)) {
11✔
1155
                this.loadingRows.add(parentID);
8✔
1156

1157
                this.loadChildrenOnDemand(parentID, children => {
8✔
1158
                    this.loadingRows.delete(parentID);
8✔
1159
                    this.addChildRows(children, parentID);
8✔
1160
                    this.notifyChanges();
8✔
1161
                });
1162
            }
1163
        }
1164
    }
1165

1166
    private handleCascadeSelection(event: IRowDataEventArgs | StateUpdateEvent, rec: ITreeGridRecord = null) {
2✔
1167
        // Wait for the change detection to update records through the pipes
1168
        requestAnimationFrame(() => {
11✔
1169
            if (rec === null) {
11✔
1170
                rec = this.gridAPI.get_rec_by_id((event as StateUpdateEvent).actions[0].transaction.id);
2✔
1171
            }
1172
            if (rec && rec.parent) {
11✔
1173
                this.gridAPI.grid.selectionService.updateCascadeSelectionOnFilterAndCRUD(
11✔
1174
                    new Set([rec.parent]), rec.parent.key
1175
                );
1176
                this.notifyChanges();
11✔
1177
            }
1178
        });
1179
    }
1180
}
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