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

IgniteUI / igniteui-angular / 19739916929

27 Nov 2025 02:42PM UTC coverage: 91.464% (-0.1%) from 91.609%
19739916929

push

github

web-flow
feat: add PDF export feature to Grid components (#16324)

Co-authored-by: kdinev <1472513+kdinev@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Galina Edinakova <gedinakova@infragistics.com>
Co-authored-by: onlyexeption <ibarakov@infragistics.com>
Co-authored-by: Maria Tsvyatkova <mtsvyatkova@infragistics.com>
Co-authored-by: Radoslav Karaivanov <rkaraivanov@infragistics.com>
Co-authored-by: Damyan Petev <damyanpetev@users.noreply.github.com>

14155 of 16696 branches covered (84.78%)

367 of 479 new or added lines in 7 files covered. (76.62%)

3 existing lines in 2 files now uncovered.

28447 of 31102 relevant lines covered (91.46%)

34583.54 hits per line

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

86.52
/projects/igniteui-angular/grids/tree-grid/src/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
    DOCUMENT
26
} from '@angular/core';
27
import { NgClass, NgTemplateOutlet, NgStyle } from '@angular/common';
28
import { IgxTreeGridAPIService } from './tree-grid-api.service';
29
import { IgxGridBaseDirective, IgxGridCellMergePipe, IgxGridUnmergeActivePipe } from 'igniteui-angular/grids/grid';
30
import {
31
    CellType,
32
    GridSelectionMode,
33
    GridServiceType,
34
    GridType,
35
    IGX_GRID_BASE,
36
    IGX_GRID_SERVICE_BASE,
37
    IgxColumnComponent,
38
    IgxColumnMovingDropDirective,
39
    IgxColumnResizingService,
40
    IgxFilteringService,
41
    IgxGridBodyDirective,
42
    IgxGridCell,
43
    IgxGridColumnResizerComponent,
44
    IgxGridCRUDService,
45
    IgxGridDragSelectDirective,
46
    IgxGridHeaderRowComponent,
47
    IgxGridNavigationService,
48
    IgxGridRowClassesPipe,
49
    IgxGridRowPinningPipe,
50
    IgxGridRowStylesPipe,
51
    IgxGridSelectionService,
52
    IgxGridSummaryService,
53
    IgxGridTransaction,
54
    IgxGridValidationService,
55
    IgxHasVisibleColumnsPipe,
56
    IgxRowEditTabStopDirective,
57
    IgxStringReplacePipe,
58
    IgxSummaryDataPipe,
59
    IgxSummaryRow,
60
    IgxSummaryRowComponent,
61
    IgxTreeGridRow,
62
    IRowDataCancelableEventArgs,
63
    IRowDataEventArgs,
64
    IRowToggleEventArgs,
65
    RowType
66
} from 'igniteui-angular/grids/core';
67
import { first, takeUntil } from 'rxjs/operators';
68
import { IgxRowLoadingIndicatorTemplateDirective } from './tree-grid.directives';
69
import { IgxTreeGridSelectionService } from './tree-grid-selection.service';
70
import { DefaultTreeGridMergeStrategy, HierarchicalState, HierarchicalTransaction, HierarchicalTransactionService, IGridMergeStrategy, IgxHierarchicalTransactionFactory, IgxOverlayOutletDirective, IgxOverlayService, ITreeGridRecord, mergeObjects, PlatformUtil, StateUpdateEvent, TransactionEventOrigin, TransactionType, TreeGridFilteringStrategy } from 'igniteui-angular/core';
71
import { IgxTreeGridSummaryPipe } from './tree-grid.summary.pipe';
72
import { IgxTreeGridFilteringPipe } from './tree-grid.filtering.pipe';
73
import { IgxTreeGridHierarchizingPipe, IgxTreeGridFlatteningPipe, IgxTreeGridSortingPipe, IgxTreeGridPagingPipe, IgxTreeGridTransactionPipe, IgxTreeGridNormalizeRecordsPipe, IgxTreeGridAddRowPipe } from './tree-grid.pipes';
74
import { IgxTreeGridRowComponent } from './tree-grid-row.component';
75
import { IgxButtonDirective, IgxForOfScrollSyncService, IgxForOfSyncService, IgxGridForOfDirective, IgxRippleDirective, IgxScrollInertiaDirective, IgxTemplateOutletDirective, IgxTextHighlightService, IgxToggleDirective } from 'igniteui-angular/directives';
76
import { IgxCircularProgressBarComponent } from 'igniteui-angular/progressbar';
77
import { IgxSnackbarComponent } from 'igniteui-angular/snackbar';
78
import { IgxIconComponent } from 'igniteui-angular/icon';
79
import { IgxTreeGridGroupByAreaComponent } from './tree-grid-group-by-area.component';
80

81
let NEXT_ID = 0;
3✔
82

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

347
    protected override _filterStrategy = new TreeGridFilteringStrategy();
535✔
348
    protected override _transactions: HierarchicalTransactionService<HierarchicalTransaction, HierarchicalState>;
349
    protected override _mergeStrategy: IGridMergeStrategy = new DefaultTreeGridMergeStrategy();
535✔
350
    private _data;
351
    private _rowLoadingIndicatorTemplate: TemplateRef<void>;
352
    private _expansionDepth = Infinity;
535✔
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;
18,131✔
366
    }
367

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

383
    /** @hidden @internal */
384
    public override get type(): GridType["type"] {
385
        return 'tree';
2,483✔
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) {
947,440✔
395
            return this._diTransactions;
85,932✔
396
        }
397
        return this._transactions;
861,508✔
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,265✔
412
    }
413

414
    public set expansionDepth(value: number) {
415
        this._expansionDepth = value;
178✔
416
        this.notifyChanges();
178✔
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?:
535✔
472
            HierarchicalTransactionService<HierarchicalTransaction, HierarchicalState>,
473
    ) {
474
        super(
535✔
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();
535✔
504

505
        this.rowToggle.pipe(takeUntil(this.destroy$)).subscribe((args) => {
535✔
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 => {
535✔
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 => {
535✔
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(() => {
535✔
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();
535✔
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) {
535!
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) {
535!
589
            this._rowLoadingIndicatorTemplate = this.rowLoadingTemplate.template;
×
590
        }
591
        super.ngAfterContentInit();
535✔
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,879✔
701
            $implicit: this.isGhostRecord(rowData) || this.isRecordMerged(rowData) ? rowData.recordRef : rowData,
137,467✔
702
            index: this.getDataViewIndex(rowIndex, pinned),
703
            templateID: {
704
                type: this.isSummaryRow(rowData) ? 'summaryRow' : 'dataRow',
45,879✔
705
                id: null
706
            },
707
            disabled: this.isGhostRecord(rowData) ? rowData.recordRef.isFilteredOutParent === undefined : false,
45,879✔
708
            metaData: this.isRecordMerged(rowData) ? rowData : null
45,879✔
709
        };
710
    }
711

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

972
        return row;
14,365✔
973
    }
974

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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