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

IgniteUI / igniteui-angular / 10617087306

29 Aug 2024 02:29PM UTC coverage: 91.556%. Remained the same
10617087306

push

github

web-flow
fix(IgxPivotDateDimension): Add null check. (#14706)

12769 of 14973 branches covered (85.28%)

1 of 1 new or added line in 1 file covered. (100.0%)

46 existing lines in 5 files now uncovered.

25978 of 28374 relevant lines covered (91.56%)

32520.63 hits per line

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

85.93
/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, NgIf, NgClass, NgFor, 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
    standalone: true,
136
    imports: [
137
        NgIf,
138
        NgFor,
139
        NgClass,
140
        NgStyle,
141
        NgTemplateOutlet,
142
        IgxGridHeaderRowComponent,
143
        IgxGridBodyDirective,
144
        IgxGridDragSelectDirective,
145
        IgxColumnMovingDropDirective,
146
        IgxGridForOfDirective,
147
        IgxTemplateOutletDirective,
148
        IgxTreeGridRowComponent,
149
        IgxSummaryRowComponent,
150
        IgxOverlayOutletDirective,
151
        IgxToggleDirective,
152
        IgxCircularProgressBarComponent,
153
        IgxSnackbarComponent,
154
        IgxButtonDirective,
155
        IgxRippleDirective,
156
        IgxRowEditTabStopDirective,
157
        IgxIconComponent,
158
        IgxGridColumnResizerComponent,
159
        IgxHasVisibleColumnsPipe,
160
        IgxGridRowPinningPipe,
161
        IgxGridRowClassesPipe,
162
        IgxGridRowStylesPipe,
163
        IgxSummaryDataPipe,
164
        IgxTreeGridHierarchizingPipe,
165
        IgxTreeGridFlatteningPipe,
166
        IgxTreeGridSortingPipe,
167
        IgxTreeGridFilteringPipe,
168
        IgxTreeGridPagingPipe,
169
        IgxTreeGridTransactionPipe,
170
        IgxTreeGridSummaryPipe,
171
        IgxTreeGridNormalizeRecordsPipe,
172
        IgxTreeGridAddRowPipe,
173
        IgxStringReplacePipe
174
],
175
    schemas: [CUSTOM_ELEMENTS_SCHEMA]
176
})
177
export class IgxTreeGridComponent extends IgxGridBaseDirective implements GridType, OnInit, AfterViewInit, DoCheck, AfterContentInit {
2✔
178
    /**
179
     * Sets the child data key of the `IgxTreeGridComponent`.
180
     * ```html
181
     * <igx-tree-grid #grid [data]="employeeData" [childDataKey]="'employees'" [autoGenerate]="true"></igx-tree-grid>
182
     * ```
183
     *
184
     * @memberof IgxTreeGridComponent
185
     */
186
    @Input()
187
    public childDataKey: string;
188

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

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

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

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

247
    /**
248
     * @hidden @internal
249
     */
250
    @HostBinding('attr.role')
251
    public role = 'treegrid';
523✔
252

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

265
    /**
266
     * @hidden
267
     * @internal
268
     */
269
    @ContentChild(IgxTreeGridGroupByAreaComponent, { read: IgxTreeGridGroupByAreaComponent })
270
    public treeGroupArea: IgxTreeGridGroupByAreaComponent;
271

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

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

284
    /**
285
     * @hidden
286
     */
287
    @ContentChild(IgxRowLoadingIndicatorTemplateDirective, { read: IgxRowLoadingIndicatorTemplateDirective })
288
    protected rowLoadingTemplate: IgxRowLoadingIndicatorTemplateDirective;
289

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

295
    /**
296
     * @hidden
297
     */
298
    public processedExpandedFlatData: any[] | null;
299

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

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

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

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

346
    /**
347
     * @hidden
348
     */
349
    public loadingRows = new Set<any>();
523✔
350

351
    protected override _filterStrategy = new TreeGridFilteringStrategy();
523✔
352
    protected override _transactions: HierarchicalTransactionService<HierarchicalTransaction, HierarchicalState>;
353
    private _data;
354
    private _rowLoadingIndicatorTemplate: TemplateRef<void>;
355
    private _expansionDepth = Infinity;
523✔
356

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

371
     /* treatAsRef */
372
    public set data(value: any[] | null) {
373
        this._data = value || [];
533✔
374
        this.summaryService.clearSummaryCache();
533✔
375
        if (!this._init) {
533✔
376
            this.validation.updateAll(this._data);
16✔
377
        }
378
        if (this.shouldGenerate) {
533!
UNCOV
379
            this.setupColumns();
×
380
        }
381
        this.cdr.markForCheck();
533✔
382
    }
383

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

389
    /**
390
     * Get transactions service for the grid.
391
     *
392
     * @experimental @hidden
393
     */
394
    public override get transactions() {
395
        if (this._diTransactions && !this.batchEditing) {
3,273,384✔
396
            return this._diTransactions;
364,022✔
397
        }
398
        return this._transactions;
2,909,362✔
399
    }
400

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

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

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

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

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

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

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

506
        this.rowToggle.pipe(takeUntil(this.destroy$)).subscribe((args) => {
523✔
507
            this.loadChildrenOnRowExpansion(args);
172✔
508
        });
509

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

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

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

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

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

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

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

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

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

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

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

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

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

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

721
    /**
722
     * @hidden
723
     * @internal
724
     */
725
    public override isRecordPinned(rec) {
726
        return this.getInitialPinnedIndex(rec.data) !== -1;
198,961✔
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;
9✔
758
        const row = { ...treeRowRec };
9✔
759
        const data = treeRowRec?.data || {};
9✔
760
        row.data = { ...data };
9✔
761
        Object.keys(row.data).forEach(key => {
9✔
762
            // persist foreign key if one is set.
763
            if (this.foreignKey && key === this.foreignKey) {
40✔
764
                row.data[key] = treeRowRec.data[this.crudService.addRowParent?.asChild ? this.primaryKey : key];
7✔
765
            } else {
766
                row.data[key] = undefined;
33✔
767
            }
768
        });
769
        let id = this.generateRowID();
9✔
770
        const rootRecPK = this.foreignKey && this.rootRecords && this.rootRecords.length > 0 ?
9✔
771
            this.rootRecords[0].data[this.foreignKey] : null;
772
        if (id === rootRecPK) {
9!
773
            // safeguard in case generated id matches the root foreign key.
UNCOV
774
            id = this.generateRowID();
×
775
        }
776
        row.key = id;
9✔
777
        row.data[this.primaryKey] = id;
9✔
778
        return { rowID: id, data: row.data, recordRef: row };
9✔
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;
12,742✔
946
    }
947

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

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

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

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

972
        return row;
12,749✔
973
    }
974

975
    protected override generateDataFields(data: any[]): string[] {
976
        return super.generateDataFields(data).filter(field => field !== this.childDataKey);
11✔
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) {
UNCOV
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) ?
36✔
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) {
UNCOV
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) {
523!
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);
×
UNCOV
1066
            this.updateColumns(nonColumnLayoutColumns);
×
1067
        }
1068
        super.initColumns(collection, cb);
523✔
1069
    }
1070

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

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

1097
    private addChildRows(children: any[], parentID: any) {
1098
        if (this.primaryKey && this.foreignKey) {
8✔
1099
            for (const child of children) {
6✔
1100
                child[this.foreignKey] = parentID;
10✔
1101
            }
1102
            this.data.push(...children);
6✔
1103
        } else if (this.childDataKey) {
2✔
1104
            let parent = this.records.get(parentID);
2✔
1105
            let parentData = parent.data;
2✔
1106

1107
            if (this.transactions.enabled && this.transactions.getAggregatedChanges(true).length) {
2!
1108
                const path = [];
×
1109
                while (parent) {
×
1110
                    path.push(parent.key);
×
UNCOV
1111
                    parent = parent.parent;
×
1112
                }
1113

UNCOV
1114
                let collection = this.data;
×
1115
                let record: any;
1116
                for (let i = path.length - 1; i >= 0; i--) {
×
1117
                    const pid = path[i];
×
UNCOV
1118
                    record = collection.find(r => r[this.primaryKey] === pid);
×
1119

1120
                    if (!record) {
×
UNCOV
1121
                        break;
×
1122
                    }
UNCOV
1123
                    collection = record[this.childDataKey];
×
1124
                }
1125
                if (record) {
×
UNCOV
1126
                    parentData = record;
×
1127
                }
1128
            }
1129

1130
            parentData[this.childDataKey] = children;
2✔
1131
        }
1132
        this.selectionService.clearHeaderCBState();
8✔
1133
        this.pipeTrigger++;
8✔
1134
        if (this.rowSelection === GridSelectionMode.multipleCascade) {
8✔
1135
            // Force pipe triggering for building the data structure
1136
            this.cdr.detectChanges();
2✔
1137
            if (this.selectionService.isRowSelected(parentID)) {
2✔
1138
                this.selectionService.rowSelection.delete(parentID);
2✔
1139
                this.selectionService.selectRowsWithNoEvent([parentID]);
2✔
1140
            }
1141
        }
1142
    }
1143

1144
    private loadChildrenOnRowExpansion(args: IRowToggleEventArgs) {
1145
        if (this.loadChildrenOnDemand) {
172✔
1146
            const parentID = args.rowID;
11✔
1147

1148
            if (args.expanded && !this._expansionStates.has(parentID)) {
11✔
1149
                this.loadingRows.add(parentID);
8✔
1150

1151
                this.loadChildrenOnDemand(parentID, children => {
8✔
1152
                    this.loadingRows.delete(parentID);
8✔
1153
                    this.addChildRows(children, parentID);
8✔
1154
                    this.notifyChanges();
8✔
1155
                });
1156
            }
1157
        }
1158
    }
1159

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