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

atinc / ngx-tethys / 0cfd1254-9bf9-492c-a0a2-e43f466037a2

16 Nov 2023 02:29AM UTC coverage: 90.201%. Remained the same
0cfd1254-9bf9-492c-a0a2-e43f466037a2

Pull #2898

circleci

smile1016
fix(cascader): fix test
Pull Request #2898: fix(cascader): fix bug of selectall #INFR-10448

5277 of 6513 branches covered (0.0%)

Branch coverage included in aggregate %.

6 of 9 new or added lines in 1 file covered. (66.67%)

29 existing lines in 1 file now uncovered.

13189 of 13959 relevant lines covered (94.48%)

977.22 hits per line

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

87.27
/src/cascader/cascader.component.ts
1
import {
2
    EXPANDED_DROPDOWN_POSITIONS,
3
    InputBoolean,
4
    InputNumber,
5
    ScrollToService,
6
    TabIndexDisabledControlValueAccessorMixin
7
} from 'ngx-tethys/core';
8
import { ThyEmptyComponent } from 'ngx-tethys/empty';
9
import { ThyIconComponent } from 'ngx-tethys/icon';
10
import { SelectControlSize, SelectOptionBase, ThySelectControlComponent } from 'ngx-tethys/shared';
11
import { Id } from 'ngx-tethys/types';
12
import { coerceBooleanProperty, elementMatchClosest, isArray, isEmpty, set, helpers } from 'ngx-tethys/util';
13
import { BehaviorSubject, Subject } from 'rxjs';
14
import { debounceTime, distinctUntilChanged, filter, take, takeUntil } from 'rxjs/operators';
15

16
import { SelectionModel } from '@angular/cdk/collections';
17
import {
18
    CdkConnectedOverlay,
19
    CdkOverlayOrigin,
20
    ConnectedOverlayPositionChange,
21
    ConnectionPositionPair,
22
    ViewportRuler
130✔
23
} from '@angular/cdk/overlay';
77✔
24
import { NgClass, NgFor, NgIf, NgStyle, NgTemplateOutlet } from '@angular/common';
25
import {
53✔
26
    ChangeDetectorRef,
6✔
27
    Component,
28
    ElementRef,
29
    EventEmitter,
47✔
30
    forwardRef,
31
    HostListener,
130✔
32
    Input,
33
    OnDestroy,
34
    OnInit,
94✔
35
    Output,
71✔
36
    QueryList,
37
    TemplateRef,
23✔
38
    ViewChild,
23✔
39
    ViewChildren
29✔
40
} from '@angular/core';
19✔
41
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
42
import { useHostRenderer } from '@tethys/cdk/dom';
43

4✔
44
import { ThyCascaderOptionComponent } from './cascader-li.component';
45
import { ThyCascaderSearchOptionComponent } from './cascader-search-option.component';
42✔
46
import { ThyCascaderExpandTrigger, ThyCascaderOption, ThyCascaderSearchOption, ThyCascaderTriggerType } from './types';
47
import { deepCopy } from '@angular-devkit/core';
48

49
function toArray<T>(value: T | T[]): T[] {
50
    let ret: T[];
1✔
51
    if (value == null) {
52
        ret = [];
54✔
53
    } else if (!Array.isArray(value)) {
54✔
54
        ret = [value];
4✔
55
    } else {
56
        ret = value;
57
    }
58
    return ret;
3✔
59
}
3✔
60

61
function arrayEquals<T>(array1: T[], array2: T[]): boolean {
62
    if (!array1 || !array2 || array1.length !== array2.length) {
6✔
63
        return false;
64
    }
65

30✔
66
    const len = array1.length;
30✔
67
    for (let i = 0; i < len; i++) {
68
        if (array1[i] !== array2[i]) {
69
            return false;
284✔
70
        }
71
    }
72
    return true;
30✔
73
}
30✔
74

75
const defaultDisplayRender = (label: any) => label.join(' / ');
76

94✔
77
/**
78
 * 级联选择菜单
79
 * @name thy-cascader
408✔
80
 */
81
@Component({
82
    selector: 'thy-cascader,[thy-cascader]',
44✔
83
    templateUrl: './cascader.component.html',
84
    providers: [
85
        {
31✔
86
            provide: NG_VALUE_ACCESSOR,
87
            useExisting: forwardRef(() => ThyCascaderComponent),
88
            multi: true
12✔
89
        }
12✔
90
    ],
91
    host: {
92
        '[attr.tabindex]': `tabIndex`,
2,295✔
93
        '(focus)': 'onFocus($event)',
94
        '(blur)': 'onBlur($event)'
95
    },
486✔
96
    styles: [
486✔
97
        `
98
            .thy-cascader-menus {
99
                position: relative;
47✔
100
            }
47✔
101
        `
47✔
102
    ],
47✔
103
    standalone: true,
47✔
104
    imports: [
47✔
105
        CdkOverlayOrigin,
47✔
106
        NgIf,
35✔
107
        ThySelectControlComponent,
108
        NgClass,
47✔
109
        NgTemplateOutlet,
110
        CdkConnectedOverlay,
111
        NgStyle,
112
        NgFor,
×
113
        ThyCascaderOptionComponent,
×
114
        ThyCascaderSearchOptionComponent,
×
115
        ThyEmptyComponent,
116
        ThyIconComponent
117
    ]
47✔
118
})
9✔
119
export class ThyCascaderComponent extends TabIndexDisabledControlValueAccessorMixin implements ControlValueAccessor, OnInit, OnDestroy {
120
    /**
121
     * 选项的实际值的属性名
122
     */
12!
UNCOV
123
    @Input() thyValueProperty = 'value';
×
124

125
    /**
126
     * 选项的显示值的属性名
12✔
127
     */
128
    @Input() thyLabelProperty = 'label';
129

130
    /**
47✔
131
     * 描述输入字段预期值的简短的提示信息
188✔
132
     */
133
    @Input() thyPlaceholder = '请选择';
47✔
134

47✔
135
    /**
47✔
136
     * 控制大小(4种)
47✔
137
     * @type 'sm' | 'md' | 'lg' | ''
47✔
138
     */
139
    @Input() thySize: SelectControlSize = '';
140

107✔
141
    /**
107✔
142
     * 数据项
106✔
143
     * @type ThyCascaderOption[]
106✔
144
     * @default []
64✔
145
     */
146
    @Input()
106✔
147
    set thyOptions(options: ThyCascaderOption[] | null) {
38✔
148
        this.columns = options && options.length ? [options] : [];
149
        if (this.defaultValue && this.columns.length) {
150
            this.initOptions(0);
107✔
151
        }
105✔
152
    }
153

154
    /**
2✔
155
     * 点击父级菜单选项时,可通过该函数判断是否允许值的变化
2✔
156
     */
157
    @Input() thyChangeOn: (option: ThyCascaderOption, level: number) => boolean;
158

159
    /**
106✔
160
     * 点击项时,表单是否动态展示数据项
106✔
161
     * @type boolean
10✔
162
     */
10!
163
    @Input() @InputBoolean() thyChangeOnSelect = false;
164

165
    /**
10!
166
     * 显示输入框
10!
167
     * @type boolean
168
     */
169
    @Input() @InputBoolean() thyShowInput = true;
106✔
170

106✔
171
    /**
172
     * 用户自定义模板
173
     * @type TemplateRef
125✔
174
     */
106✔
175
    @Input()
31✔
176
    set thyLabelRender(value: TemplateRef<any>) {
177
        this.labelRenderTpl = value;
106✔
178
        this.isLabelRenderTemplate = value instanceof TemplateRef;
179
    }
180

19✔
181
    get thyLabelRender(): TemplateRef<any> {
4✔
182
        return this.labelRenderTpl;
4✔
UNCOV
183
    }
×
184

185
    /**
4✔
186
     * 用于动态加载选项
187
     */
19✔
188
    @Input() thyLoadData: (node: ThyCascaderOption, index?: number) => PromiseLike<any>;
10✔
189

190
    /**
191
     * 控制触发状态, 支持 `click` | `hover`
9✔
192
     * @type ThyCascaderTriggerType | ThyCascaderTriggerType[]
193
     */
19✔
194
    @Input() thyTriggerAction: ThyCascaderTriggerType | ThyCascaderTriggerType[] = ['click'];
10✔
195

196
    /**
19✔
197
     * 鼠标经过下方列表项时,是否自动展开列表,支持 `click` | `hover`
198
     * @type ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[]
199
     */
200
    @Input() thyExpandTriggerAction: ThyCascaderExpandTrigger | ThyCascaderExpandTrigger[] = ['click'];
103!
UNCOV
201

×
202
    /**
203
     * 自定义浮层样式
103✔
204
     */
77✔
205
    @Input() thyMenuStyle: { [key: string]: string };
77✔
206

12✔
207
    /**
208
     * 自定义浮层类名
209
     * @type string
65✔
210
     */
65✔
211
    @Input()
65✔
212
    set thyMenuClassName(value: string) {
213
        this.menuClassName = value;
214
        this.setMenuClass();
215
    }
26✔
216

26✔
217
    get thyMenuClassName(): string {
26✔
218
        return this.menuClassName;
27✔
219
    }
27!
220

27✔
221
    /**
222
     * 自定义浮层列类名
UNCOV
223
     * @type string
×
UNCOV
224
     */
×
UNCOV
225
    @Input()
×
226
    set thyColumnClassName(value: string) {
227
        this.columnClassName = value;
228
        this.setMenuClass();
26✔
229
    }
230

231
    get thyColumnClassName(): string {
232
        return this.columnClassName;
104✔
233
    }
104✔
234

104✔
235
    /**
104✔
236
     * 是否只读
237
     * @default false
238
     */
104✔
239
    @Input()
27✔
240
    // eslint-disable-next-line prettier/prettier
81✔
241
    override get thyDisabled(): boolean {
27✔
242
        return this.disabled;
27✔
243
    }
244

245
    override set thyDisabled(value: boolean) {
27✔
246
        this.disabled = coerceBooleanProperty(value);
247
    }
248

249
    disabled = false;
53✔
250

107✔
251
    /**
75✔
252
     * 空状态下的展示文字
75✔
253
     * @default 暂无可选项
75✔
254
     */
26✔
255
    @Input()
256
    set thyEmptyStateText(value: string) {
257
        this.emptyStateText = value;
258
    }
259

260
    /**
47✔
261
     * 是否多选
262
     * @type boolean
263
     * @default false
200✔
264
     */
200✔
265
    @Input()
1✔
266
    @InputBoolean()
1✔
267
    set thyMultiple(value: boolean) {
268
        this.isMultiple = value;
269
        this.initSelectionModel();
270
    }
107✔
271

272
    get thyMultiple(): boolean {
273
        return this.isMultiple;
179!
274
    }
275

276
    /**
340!
277
     * 设置多选时最大显示的标签数量,0 表示不限制
278
     * @type number
279
     */
1,220!
280
    @Input() @InputNumber() thyMaxTagCount = 0;
1,220✔
281

1,220✔
282
    /**
283
     * 是否仅允许选择叶子项
UNCOV
284
     * @default true
×
UNCOV
285
     */
×
286
    @Input()
287
    @InputBoolean()
UNCOV
288
    thyIsOnlySelectLeaf = true;
×
UNCOV
289

×
UNCOV
290
    /**
×
UNCOV
291
     * 是否支持搜索
×
292
     * @default false
UNCOV
293
     */
×
294
    @Input() @InputBoolean() thyShowSearch: boolean = false;
295

296
    /**
297
     * 值发生变化时触发,返回选择项的值
298
     * @type EventEmitter<any[]>
1,220✔
299
     */
164✔
300
    @Output() thyChange = new EventEmitter<any[]>();
301

1,056✔
302
    /**
303
     * 值发生变化时触发,返回选择项列表
304
     * @type EventEmitter<ThyCascaderOption[]>
1,296✔
305
     */
1,258✔
306
    @Output() thySelectionChange = new EventEmitter<ThyCascaderOption[]>();
291✔
307

308
    /**
309
     * 选择选项时触发
967✔
310
     */
311
    @Output() thySelect = new EventEmitter<{
312
        option: ThyCascaderOption;
313
        index: number;
38✔
314
    }>();
38✔
UNCOV
315

×
UNCOV
316
    /**
×
UNCOV
317
     * @private 暂无实现
×
318
     */
319
    @Output() thyDeselect = new EventEmitter<{
UNCOV
320
        option: ThyCascaderOption;
×
321
        index: number;
322
    }>();
323

38✔
324
    /**
325
     * 清空选项时触发
326
     */
327
    @Output() thyClear = new EventEmitter<void>();
29✔
328

29✔
329
    /**
29✔
330
     * 下拉选项展开和折叠状态事件
331
     */
332
    @Output() thyExpandStatusChange: EventEmitter<boolean> = new EventEmitter<boolean>();
333

37✔
334
    @ViewChildren('cascaderOptions', { read: ElementRef }) cascaderOptions: QueryList<ElementRef>;
14✔
335

71✔
336
    @ViewChildren('cascaderOptionContainers', { read: ElementRef }) cascaderOptionContainers: QueryList<ElementRef>;
337

338
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
14✔
339

29✔
340
    @ViewChild('trigger', { read: ElementRef, static: true }) trigger: ElementRef<any>;
26✔
341

26✔
342
    @ViewChild('input') input: ElementRef;
343

344
    @ViewChild('menu') menu: ElementRef;
345

346
    public dropDownPosition = 'bottom';
347
    public menuVisible = false;
106✔
348
    public isLoading = false;
106✔
349
    public showSearch = false;
103!
350
    public labelRenderText: string;
140✔
351
    public labelRenderContext: any = {};
352
    public isLabelRenderTemplate = false;
3✔
353
    public triggerRect: DOMRect;
354
    public columns: ThyCascaderOption[][] = [];
355
    public emptyStateText = '暂无可选项';
112✔
356

122✔
357
    public selectionModel: SelectionModel<SelectOptionBase>;
112✔
358
    private prefixCls = 'thy-cascader';
66✔
359
    private menuClassName: string;
360
    private columnClassName: string;
361
    private _menuColumnCls: any;
362
    private defaultValue: any[];
46✔
363
    private readonly destroy$ = new Subject<void>();
4✔
364
    private _menuCls: { [name: string]: any };
365
    private _labelCls: { [name: string]: any };
366
    private labelRenderTpl: TemplateRef<any>;
42✔
367
    private hostRenderer = useHostRenderer();
42✔
368
    private cascaderPosition: ConnectionPositionPair[];
369
    positions: ConnectionPositionPair[];
46!
370

46✔
371
    private value: any[];
372

373
    private selectedOptions: ThyCascaderOption[] = [];
374

375
    private activatedOptions: ThyCascaderOption[] = [];
376

377
    get selected(): SelectOptionBase | SelectOptionBase[] {
378
        this.cdkConnectedOverlay?.overlayRef?.updatePosition();
379
        return this.thyMultiple ? this.selectionModel.selected : this.selectionModel.selected[0];
46✔
380
    }
381

382
    private isMultiple = false;
UNCOV
383

×
384
    private prevSelectedOptions: Set<ThyCascaderOption> = new Set<ThyCascaderOption>();
385

386
    public menuMinWidth = 122;
36✔
387

35✔
388
    private searchText$ = new BehaviorSubject('');
35✔
389

35✔
390
    public searchResultList: ThyCascaderSearchOption[] = [];
35✔
391

35✔
392
    public isShowSearchPanel: boolean = false;
30✔
393

394
    /**
35✔
395
     * 解决搜索&多选的情况下,点击搜索项会导致 panel 闪烁
396
     * 由于点击后,thySelectedOptions变化,导致 thySelectControl
397
     * 又会触发 searchFilter 函数,即 resetSearch 会执行
398
     * 会导致恢复级联状态再变为搜索状态
35✔
399
     */
23✔
400
    private isSelectingSearchState: boolean = false;
401

12✔
402
    private flattenOptions: ThyCascaderSearchOption[] = [];
12✔
403

28✔
404
    private leafNodes: ThyCascaderSearchOption[] = [];
17✔
405

406
    private valueChange$ = new Subject();
407

408
    ngOnInit(): void {
409
        this.setClassMap();
247✔
410
        this.setMenuClass();
411
        this.setMenuColumnClass();
412
        this.setLabelClass();
142✔
413
        this.initPosition();
414
        this.initSearch();
415
        if (!this.selectionModel) {
416
            this.selectionModel = new SelectionModel<SelectOptionBase>(this.thyMultiple);
417
        }
418
        this.viewPortRuler
419
            .change(100)
420
            .pipe(takeUntil(this.destroy$))
446✔
421
            .subscribe(() => {
422
                if (this.menuVisible) {
423
                    this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
47✔
424
                    this.cdr.markForCheck();
425
                }
426
            });
427

428
        this.valueChange$.pipe(takeUntil(this.destroy$), debounceTime(100)).subscribe(() => {
429
            this.valueChange();
353✔
430
        });
431
    }
432

47✔
433
    private initSelectionModel() {
434
        if (this.selectionModel) {
435
            this.selectionModel.clear();
436
        } else {
437
            this.selectionModel = new SelectionModel(this.isMultiple);
438
        }
439
    }
82✔
440

441
    private initPosition() {
442
        this.cascaderPosition = EXPANDED_DROPDOWN_POSITIONS.map(item => {
443
            return { ...item };
444
        });
445
        this.cascaderPosition[0].offsetY = 4; // 左下
446
        this.cascaderPosition[1].offsetY = 4; // 右下
82✔
447
        this.cascaderPosition[2].offsetY = -4; // 右下
448
        this.cascaderPosition[3].offsetY = -4; // 右下
449
        this.positions = this.cascaderPosition;
28✔
450
    }
20✔
451

452
    private initOptions(index: number) {
8✔
453
        const vs = this.defaultValue;
454
        const load = () => {
455
            this.activateOnInit(index, vs[index]);
4!
456
            if (index < vs.length - 1) {
4✔
457
                this.initOptions(index + 1);
UNCOV
458
            }
×
459
            if (index === vs.length - 1) {
460
                this.afterWriteValue();
461
            }
2!
462
        };
2✔
463

UNCOV
464
        if (this.isLoaded(index) || !this.thyLoadData) {
×
465
            load();
466
        } else {
467
            const node = this.activatedOptions[index - 1] || {};
30✔
468
            this.loadChildren(node, index - 1, load, this.afterWriteValue.bind(this));
2✔
469
        }
470
    }
28!
471

28✔
472
    private activateOnInit(index: number, value: any): void {
473
        let option = this.findOption(value, index);
474
        if (!option) {
475
            option =
2!
UNCOV
476
                typeof value === 'object'
×
477
                    ? value
478
                    : {
2!
479
                          [`${this.thyValueProperty || 'value'}`]: value,
2✔
480
                          [`${this.thyLabelProperty || 'label'}`]: value
481
                      };
482
        }
483
        this.updatePrevSelectedOptions(option, true);
20!
UNCOV
484
        this.setActiveOption(option, index, false, false);
×
485
    }
486

20✔
487
    private updatePrevSelectedOptions(option: ThyCascaderOption, isActivateInit: boolean, index?: number) {
20✔
488
        if (isActivateInit) {
1✔
489
            if (this.thyIsOnlySelectLeaf && option.isLeaf) {
490
                set(option, 'selected', true);
491
            }
19✔
492
            this.prevSelectedOptions.add(option);
493
        } else {
494
            if (!this.thyMultiple) {
495
                const prevSelectedOptions = Array.from(this.prevSelectedOptions);
1✔
496
                while (prevSelectedOptions.length) {
1✔
497
                    set(prevSelectedOptions.pop(), 'selected', false);
1✔
498
                }
3✔
499
                this.prevSelectedOptions = new Set([]);
3✔
500
            }
3✔
501
            if (this.thyIsOnlySelectLeaf && !option.isLeaf && this.thyMultiple) {
502
                set(option, 'selected', this.isSelectedOption(option, index));
1✔
503
            } else {
3✔
504
                set(option, 'selected', !this.isSelectedOption(option, index));
3✔
505
            }
2✔
506
            if (this.thyIsOnlySelectLeaf && this.thyMultiple && option.parent) {
507
                this.updatePrevSelectedOptions(option.parent, false, index - 1);
508
            }
509
            this.prevSelectedOptions.add(option);
510
        }
1✔
511
    }
1!
512

1✔
513
    writeValue(value: any): void {
4✔
514
        if (!this.selectionModel) {
4✔
515
            this.initSelectionModel();
3✔
516
        }
517
        if (!this.isMultiple) {
518
            const vs = (this.defaultValue = toArray(value));
519
            if (vs.length) {
520
                this.initOptions(0);
1!
UNCOV
521
            } else {
×
522
                this.value = vs;
523
                this.activatedOptions = [];
524
                this.afterWriteValue();
525
            }
1✔
526
        } else {
527
            const values = toArray(value);
528
            this.selectionModel.clear();
529
            values.forEach(item => {
530
                const vs = (this.defaultValue = toArray(item));
531
                if (vs.length) {
532
                    this.initOptions(0);
533
                } else {
534
                    this.value = vs;
2,925!
UNCOV
535
                    this.activatedOptions = [];
×
536
                    this.afterWriteValue();
537
                }
2,925✔
538
            });
3,163✔
539
            this.cdr.detectChanges();
564✔
540
        }
541
    }
2,599✔
542

774✔
543
    afterWriteValue(): void {
544
        this.selectedOptions = this.activatedOptions;
545
        this.value = this.getSubmitValue(this.selectedOptions);
1,587✔
546
        this.addSelectedState(this.selectedOptions);
547
        this.buildDisplayLabel();
548
    }
2!
549

2✔
550
    private addSelectedState(selectOptions: ThyCascaderOption[]) {
551
        if (this.isMultiple && this.thyIsOnlySelectLeaf) {
2!
UNCOV
552
            selectOptions.forEach(opt => {
×
553
                if (opt.isLeaf) {
554
                    opt.selected = true;
2!
555
                    set(opt, 'selected', true);
1✔
556
                }
557
            });
1✔
558
            this.addParentSelectedState(selectOptions);
559
        }
560
    }
2!
561

2✔
562
    addParentSelectedState(selectOptions: ThyCascaderOption[]) {
563
        selectOptions.forEach(opt => {
2✔
564
            if (opt.children && opt.children.length && opt.children.every(i => i.selected)) {
1✔
565
                opt.selected = true;
566
                set(opt, 'selected', true);
1✔
567
                if (opt.parent) {
568
                    this.addParentSelectedState([opt.parent]);
569
                }
570
            }
1!
NEW
571
        });
×
572
    }
573

1✔
574
    setDisabledState(isDisabled: boolean): void {
575
        this.disabled = isDisabled;
576
    }
1!
577

1✔
578
    public positionChange(position: ConnectedOverlayPositionChange): void {
1✔
579
        const newValue = position.connectionPair.originY === 'bottom' ? 'bottom' : 'top';
580
        if (this.dropDownPosition !== newValue) {
581
            this.dropDownPosition = newValue;
582
            this.cdr.detectChanges();
5!
UNCOV
583
        }
×
584
    }
×
585

×
586
    private isLoaded(index: number): boolean {
×
587
        return this.columns[index] && this.columns[index].length > 0;
588
    }
589

26✔
590
    public getOptionLabel(option: ThyCascaderOption): any {
132✔
591
        return option[this.thyLabelProperty || 'label'];
132✔
592
    }
116✔
593

116✔
594
    public getOptionValue(option: ThyCascaderOption): any {
3✔
595
        return option[this.thyValueProperty || 'value'];
596
    }
597

132✔
598
    public isActivatedOption(option: ThyCascaderOption, index: number): boolean {
19✔
599
        if (!this.isMultiple || this.thyIsOnlySelectLeaf) {
600
            const activeOpt = this.activatedOptions[index];
132✔
601
            return activeOpt === option;
79✔
602
        } else {
88✔
603
            if (option.isLeaf) {
79✔
604
                return option.selected;
605
            } else {
53✔
606
                const selectedOpts = this.selectionModel.selected;
3✔
607
                const appearIndex = selectedOpts.findIndex(item => {
608
                    const selectedItem = helpers.get(item, `thyRawValue.value.${index}`);
609
                    return helpers.shallowEqual(selectedItem, option);
50!
UNCOV
610
                });
×
611
                return appearIndex >= 0;
612
            }
613
        }
132✔
614
    }
9✔
615

616
    public isHalfSelectedOption(option: ThyCascaderOption, index: number): boolean {
617
        if (!option.selected && this.thyIsOnlySelectLeaf && !option.isLeaf && !this.checkSelectedStatus(option, false)) {
618
            return true;
9✔
619
        }
9✔
620
        return false;
9!
621
    }
9✔
622

9✔
623
    public isSelectedOption(option: ThyCascaderOption, index: number): boolean {
9✔
624
        if (this.thyIsOnlySelectLeaf) {
8✔
625
            if (option.isLeaf) {
626
                return option.selected;
627
            } else {
1✔
628
                return this.checkSelectedStatus(option, true);
1✔
629
            }
1!
630
        } else {
1✔
631
            const selectedOpts = this.selectionModel.selected;
1✔
632
            const appearIndex = selectedOpts.findIndex(item => {
633
                if (item.thyRawValue.value.length - 1 === index) {
UNCOV
634
                    const selectedItem = helpers.get(item, `thyRawValue.value.${index}`);
×
635
                    return helpers.shallowEqual(selectedItem, option);
636
                } else {
637
                    return false;
1✔
638
                }
639
            });
9✔
640
            return appearIndex >= 0;
641
        }
9✔
642
    }
4✔
643

4✔
644
    public attached(): void {
645
        this.cdr.detectChanges();
646
        this.cdkConnectedOverlay.positionChange.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
647
            this.scrollActiveElementIntoView();
1✔
648
        });
1✔
649
    }
1✔
650

1✔
651
    private scrollActiveElementIntoView() {
652
        if (!isEmpty(this.selectedOptions)) {
1✔
653
            const activeOptions = this.cascaderOptions
1✔
654
                .filter(item => item.nativeElement.classList.contains('thy-cascader-menu-item-active'))
655
                // for multiple mode
1✔
656
                .slice(-this.cascaderOptionContainers.length);
1!
657

1✔
658
            this.cascaderOptionContainers.forEach((item, index) => {
659
                if (index <= activeOptions.length - 1) {
1✔
660
                    ScrollToService.scrollToElement(activeOptions[index].nativeElement, item.nativeElement);
661
                    this.cdr.detectChanges();
662
                }
2✔
663
            });
2✔
664
        }
4✔
665
    }
1✔
666

667
    private findOption(option: any, index: number): ThyCascaderOption {
668
        const options: ThyCascaderOption[] = this.columns[index];
669
        if (options) {
UNCOV
670
            const value = typeof option === 'object' ? this.getOptionValue(option) : option;
×
671
            return options.find(o => value === this.getOptionValue(o));
672
        }
673
        return null;
9✔
674
    }
9!
675

9✔
676
    private buildDisplayLabel(): void {
9✔
677
        const selectedOptions = [...this.selectedOptions];
9✔
678
        const labels: string[] = selectedOptions.map(o => this.getOptionLabel(o));
9✔
679
        if (labels.length === 0) {
1✔
680
            return;
681
        }
9✔
682
        let labelRenderContext;
9✔
683
        let labelRenderText;
684
        if (this.isLabelRenderTemplate) {
685
            labelRenderContext = { labels, selectedOptions };
686
        } else {
687
            labelRenderText = defaultDisplayRender.call(this, labels, selectedOptions);
9✔
688
            this.labelRenderText = labelRenderText;
15✔
689
        }
9✔
690
        if (this.labelRenderText || this.isLabelRenderTemplate) {
691
            const selectedData: SelectOptionBase = {
692
                thyRawValue: {
1!
693
                    value: selectedOptions,
1✔
694
                    labelText: labelRenderText,
1✔
695
                    labelRenderContext: labelRenderContext
696
                },
1✔
697
                thyValue: labels,
1✔
698
                thyLabelText: labelRenderText
1✔
699
            };
1✔
700
            this.selectionModel.select(selectedData);
1✔
701
        }
1✔
702
    }
1✔
703

704
    public isMenuVisible(): boolean {
705
        return this.menuVisible;
1✔
706
    }
1✔
707

1✔
708
    public setMenuVisible(menuVisible: boolean): void {
709
        if (this.menuVisible !== menuVisible) {
710
            this.menuVisible = menuVisible;
5✔
711

4✔
712
            this.initActivatedOptions();
4✔
713
            this.setClassMap();
3✔
714
            this.setMenuClass();
3!
715
            if (this.menuVisible) {
4✔
716
                this.triggerRect = this.trigger.nativeElement.getBoundingClientRect();
3✔
717
            }
718
            this.thyExpandStatusChange.emit(menuVisible);
3✔
719
        }
1✔
720
    }
721

722
    private initActivatedOptions() {
1✔
723
        if (isEmpty(this.selectedOptions) || !this.menuVisible) {
1✔
724
            return;
1!
725
        }
1✔
726
        this.activatedOptions = [...this.selectedOptions];
727
        this.activatedOptions.forEach((item, index) => {
728
            if (!isEmpty(item.children) && !item.isLeaf) {
729
                this.columns[index + 1] = item.children;
730
            }
1!
731
        });
732
    }
733

734
    public get menuCls(): any {
85✔
735
        return this._menuCls;
81✔
736
    }
81✔
737

18✔
738
    private setMenuClass(): void {
739
        this._menuCls = {
740
            [`${this.prefixCls}-menus`]: true,
741
            [`${this.prefixCls}-menus-hidden`]: !this.menuVisible,
742
            [`${this.thyMenuClassName}`]: this.thyMenuClassName,
119✔
743
            [`w-100`]: this.columns.length === 0
119!
744
        };
143✔
745
    }
746

119✔
747
    public get menuColumnCls(): any {
748
        return this._menuColumnCls;
749
    }
47✔
750

47✔
751
    private setMenuColumnClass(): void {
47✔
752
        this._menuColumnCls = {
47✔
753
            [`${this.prefixCls}-menu`]: true,
47✔
754
            [`${this.thyColumnClassName}`]: this.thyColumnClassName
47✔
755
        };
47✔
756
    }
47✔
757

47✔
758
    public get labelCls(): any {
47✔
759
        return this._labelCls;
47✔
760
    }
47✔
761

47✔
762
    private setLabelClass(): void {
47✔
763
        this._labelCls = {
47✔
764
            [`${this.prefixCls}-picker-label`]: true,
47✔
765
            [`${this.prefixCls}-show-search`]: false,
47✔
766
            [`${this.prefixCls}-focused`]: false
47✔
767
        };
47✔
768
    }
47✔
769

47✔
770
    private setClassMap(): void {
47✔
771
        const classMap = {
47✔
772
            [`${this.prefixCls}`]: true,
47✔
773
            [`${this.prefixCls}-picker`]: true,
47✔
774
            [`${this.prefixCls}-${this.thySize}`]: true,
47✔
775
            [`${this.prefixCls}-picker-disabled`]: this.disabled,
47✔
776
            [`${this.prefixCls}-picker-open`]: this.menuVisible
47✔
777
        };
47✔
778
        this.hostRenderer.updateClassByMap(classMap);
47✔
779
    }
47✔
780

47✔
781
    private isClickTriggerAction(): boolean {
47✔
782
        if (typeof this.thyTriggerAction === 'string') {
47✔
783
            return this.thyTriggerAction === 'click';
47✔
784
        }
47✔
785
        return this.thyTriggerAction.indexOf('click') !== -1;
47✔
786
    }
47✔
787

47✔
788
    private isHoverTriggerAction(): boolean {
47✔
789
        if (typeof this.thyTriggerAction === 'string') {
47✔
790
            return this.thyTriggerAction === 'hover';
791
        }
792
        return this.thyTriggerAction.indexOf('hover') !== -1;
793
    }
794

795
    private isHoverExpandTriggerAction(): boolean {
796
        if (typeof this.thyExpandTriggerAction === 'string') {
47✔
797
            return this.thyExpandTriggerAction === 'hover';
47✔
798
        }
47✔
799
        return this.thyExpandTriggerAction.indexOf('hover') !== -1;
47✔
800
    }
801

802
    @HostListener('click', ['$event'])
819!
803
    public toggleClick($event: Event) {
804
        if (this.disabled) {
805
            return;
7✔
806
        }
1✔
807
        if (this.isClickTriggerAction()) {
808
            this.setMenuVisible(!this.menuVisible);
7✔
809
        }
810
    }
811

47✔
812
    @HostListener('mouseover', ['$event'])
48✔
813
    public toggleHover($event: Event) {
814
        if (this.disabled) {
6✔
815
            return;
816
        }
6✔
817
        if (this.isHoverTriggerAction()) {
6✔
818
            this.setMenuVisible(!this.menuVisible);
819
        }
820
    }
18✔
821

38✔
822
    public clickOption(option: ThyCascaderOption, index: number, event: Event | boolean): void {
57✔
823
        if (option && option.disabled && !this.isMultiple) {
57✔
824
            return;
57✔
825
        }
57✔
826
        const isSelect = event instanceof Event ? !this.isMultiple && option.isLeaf : true;
57✔
827
        if (this.isMultiple && !option.isLeaf && this.thyIsOnlySelectLeaf && isSelect) {
57✔
828
            this.toggleAllChildren(option, index, event as boolean);
57✔
829
        } else {
830
            this.setActiveOption(option, index, isSelect);
831
        }
832
    }
833

834
    private toggleAllChildren(option: ThyCascaderOption, index: number, selected: boolean): void {
835
        const allLeafs: {
836
            option: ThyCascaderOption;
57✔
837
            index: number;
32✔
838
        }[] = this.getAllLeafs(option, index, selected);
839
        option.selected = selected;
840
        while (allLeafs.length) {
25✔
841
            const { option, index } = allLeafs.shift();
842
            option.selected = !selected;
843
            this.setActiveOption(option, index, true);
844
        }
845
        for (let i = 0; i < this.activatedOptions.length; i++) {
846
            const option = this.activatedOptions[i];
847
            if (isArray(option.children) && option.children.length) {
848
                this.setColumnData(option.children, i + 1);
849
            }
850
        }
851
    }
852

6✔
853
    private getAllLeafs(
6✔
854
        option: ThyCascaderOption,
29✔
855
        index: number,
4✔
856
        selected: boolean
857
    ): {
858
        option: ThyCascaderOption;
859
        index: number;
860
    }[] {
6✔
861
        let allLeafs: {
6✔
862
            option: ThyCascaderOption;
863
            index: number;
864
        }[] = [];
8✔
865
        if (option.children.length > 0) {
8✔
866
            for (const childOption of option.children) {
8✔
867
                childOption.parent = option;
8✔
868
                if (childOption.isLeaf && !childOption.selected === selected) {
8✔
869
                    allLeafs.push({
870
                        option: childOption,
871
                        index: index + 1
1✔
872
                    });
1!
873
                } else if (!childOption.isLeaf) {
×
874
                    allLeafs = allLeafs.concat(this.getAllLeafs(childOption, index + 1, selected));
×
875
                }
876
            }
×
877
        }
878
        return allLeafs;
1!
879
    }
×
880

×
881
    /**
×
882
     * 检查所有所有子项的选择状态, 有一个不符合预期,就直接返回 false
×
883
     * @param option
884
     * @param trueOrFalse
×
885
     * @private
886
     */
×
887
    private checkSelectedStatus(option: ThyCascaderOption, isSelected: boolean): boolean {
×
NEW
888
        if (option.isLeaf) {
×
NEW
889
            return;
×
890
        }
891
        for (const childOption of option.children) {
892
            if (isArray(childOption.children) && childOption.children.length && !this.checkSelectedStatus(childOption, isSelected)) {
893
                return false;
1✔
894
            }
3✔
895
            if (!childOption.selected === isSelected) {
896
                return false;
1✔
897
            }
898
        }
899
        return true;
900
    }
47✔
901

47✔
902
    public mouseoverOption(option: ThyCascaderOption, index: number, event: Event): void {
903
        if (event) {
1✔
904
            event.preventDefault();
905
        }
906

907
        if (option && option.disabled && !this.isMultiple) {
908
            return;
1✔
909
        }
910

911
        if (!this.isHoverExpandTriggerAction() && !(option && option.disabled && this.isMultiple)) {
912
            return;
913
        }
914
        this.setActiveOption(option, index, false);
915
    }
916

917
    public mouseleaveMenu(event: Event) {
918
        if (event) {
919
            event.preventDefault();
920
        }
921
        if (!this.isHoverTriggerAction()) {
922
            return;
923
        }
924
        this.setMenuVisible(!this.menuVisible);
925
    }
926

927
    onBlur(event?: FocusEvent) {
928
        // Tab 聚焦后自动聚焦到 input 输入框,此分支下直接返回,无需触发 onTouchedFn
929
        if (elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
930
            return;
931
        }
932
        this.onTouchedFn();
933
    }
934

935
    onFocus(event?: FocusEvent) {
936
        if (!elementMatchClosest(event?.relatedTarget as HTMLElement, ['.thy-cascader-menus', 'thy-cascader'])) {
937
            const inputElement: HTMLInputElement = this.elementRef.nativeElement.querySelector('input');
938
            inputElement.focus();
939
        }
940
    }
941

942
    public closeMenu(): void {
943
        if (this.menuVisible) {
944
            this.setMenuVisible(false);
945
            this.onTouchedFn();
946
            this.isShowSearchPanel = false;
1✔
947
            this.searchResultList = [];
948
        }
949
    }
950

1✔
951
    public setActiveOption(option: ThyCascaderOption, index: number, select: boolean, loadChildren: boolean = true): void {
952
        this.activatedOptions[index] = option;
953
        for (let i = index - 1; i >= 0; i--) {
954
            const originOption = this.activatedOptions[i + 1]?.parent;
1✔
955
            if (!this.activatedOptions[i] || originOption?.[this.thyValueProperty] !== this.activatedOptions[i]?.[this.thyValueProperty]) {
956
                this.activatedOptions[i] = originOption ?? this.activatedOptions[i];
957
            }
958
        }
959
        if (index < this.activatedOptions.length - 1) {
1✔
960
            this.activatedOptions = this.activatedOptions.slice(0, index + 1);
961
        }
962
        if (isArray(option.children) && option.children.length) {
963
            option.isLeaf = false;
1✔
964
            option.children.forEach(child => (child.parent = option));
965
            this.setColumnData(option.children, index + 1);
966
        } else if (!option.isLeaf && loadChildren) {
967
            this.loadChildren(option, index);
1✔
968
        } else {
969
            if (index < this.columns.length - 1) {
970
                this.columns = this.columns.slice(0, index + 1);
971
            }
1✔
972
        }
973
        if (select) {
974
            this.selectOption(option, index);
975
        }
976
    }
977

978
    private selectOption(option: ThyCascaderOption, index: number): void {
47✔
979
        this.thySelect.emit({ option, index });
980
        const isOptionCanSelect = this.thyChangeOnSelect && !this.isMultiple;
981
        if (option.isLeaf || !this.thyIsOnlySelectLeaf || isOptionCanSelect || this.shouldPerformSelection(option, index)) {
982
            this.selectedOptions = this.activatedOptions;
983
            this.updatePrevSelectedOptions(option, false, index);
984
            if (option.selected) {
985
                this.buildDisplayLabel();
986
            } else {
987
                const selectedItems = this.selectionModel.selected;
988
                const currentItem = selectedItems.find(item => {
989
                    if (item.thyRawValue.value.length - 1 === index) {
990
                        const selectedItem = helpers.get(item, `thyRawValue.value.${index}`);
991
                        return helpers.shallowEqual(selectedItem, option);
992
                    } else {
993
                        return false;
994
                    }
995
                });
996
                this.selectionModel.deselect(currentItem);
997
            }
998
            this.valueChange$.next();
999
        }
1000
        if ((option.isLeaf || !this.thyIsOnlySelectLeaf) && !this.thyMultiple) {
1001
            this.setMenuVisible(false);
1002
            this.onTouchedFn();
1003
        }
1004
    }
1005

1006
    public removeSelectedItem(event: { item: SelectOptionBase; $eventOrigin: Event }) {
1007
        const selectedItems = this.selectionModel.selected;
1008
        event.$eventOrigin.stopPropagation();
1009
        const currentItem = selectedItems.find(item => {
1010
            return helpers.shallowEqual(item.thyValue, event.item.thyValue);
1011
        });
1012
        this.deselectOption(currentItem);
1013
        this.selectionModel.deselect(currentItem);
1014
        // update selectedOptions
1015
        const updatedSelectedItems = this.selectionModel.selected;
1016
        if (isArray(updatedSelectedItems) && updatedSelectedItems.length) {
1017
            this.selectedOptions = updatedSelectedItems[updatedSelectedItems.length - 1].thyRawValue.value;
1018
        }
1019
        this.valueChange$.next();
1020
    }
1021

1022
    private deselectOption(option: SelectOptionBase) {
1023
        const value: ThyCascaderOption[] = option.thyRawValue.value;
1024
        value.forEach(item => {
1025
            if (item.isLeaf && item.selected) {
1026
                set(item, 'selected', false);
1027
            }
1028
        });
1029
    }
1030

1031
    private shouldPerformSelection(option: ThyCascaderOption, level: number): boolean {
1032
        return typeof this.thyChangeOn === 'function' ? this.thyChangeOn(option, level) === true : false;
1033
    }
1034

1035
    private valueChange(): void {
1036
        const value = this.getValues();
1037
        if (!arrayEquals(this.value, value)) {
1038
            this.defaultValue = null;
1039
            this.value = value;
1040
            this.onChangeFn(value);
1041
            if (this.selectionModel.isEmpty()) {
1042
                this.thyClear.emit();
1043
            }
1044
            this.thySelectionChange.emit(this.selectedOptions);
1045
            this.thyChange.emit(value);
1046
        }
1047
    }
1048

1049
    private getValues() {
1050
        let selectedItems: any[];
1051
        const selected = this.selectionModel.selected;
1052
        selectedItems = selected.map(item => this.getSubmitValue(item.thyRawValue.value));
1053
        return this.isMultiple ? selectedItems : selectedItems[0] ?? selectedItems;
1054
    }
1055

1056
    public clearSelection($event: Event): void {
1057
        if ($event) {
1058
            $event.stopPropagation();
1059
            $event.preventDefault();
1060
        }
1061
        this.labelRenderText = '';
1062
        this.labelRenderContext = {};
1063
        this.selectedOptions = [];
1064
        this.activatedOptions = [];
1065
        this.deselectAllSelected();
1066
        this.setMenuVisible(false);
1067
        this.valueChange$.next();
1068
    }
1069

1070
    private deselectAllSelected() {
1071
        const selectedOptions = this.selectionModel.selected;
1072
        selectedOptions.forEach(item => this.deselectOption(item));
1073
        this.selectionModel.clear();
1074
    }
1075

1076
    private loadChildren(option: ThyCascaderOption, index: number, success?: () => void, failure?: () => void): void {
1077
        if (this.thyLoadData) {
1078
            this.isLoading = true;
1079
            this.thyLoadData(option, index).then(
1080
                () => {
1081
                    option.loading = this.isLoading = false;
1082
                    if (option.children) {
1083
                        option.children.forEach(child => (child.parent = index < 0 ? undefined : option));
1084
                        this.setColumnData(option.children, index + 1);
1085
                    }
1086
                    if (success) {
1087
                        success();
1088
                    }
1089
                },
1090
                () => {
1091
                    option.loading = this.isLoading = false;
1092
                    option.isLeaf = true;
1093
                    if (failure) {
1094
                        failure();
1095
                    }
1096
                }
1097
            );
1098
        } else {
1099
            this.setColumnData(option.children || [], index + 1);
1100
        }
1101
    }
1102

1103
    private setColumnData(options: ThyCascaderOption[], index: number): void {
1104
        if (!arrayEquals(this.columns[index], options)) {
1105
            this.columns[index] = options;
1106
            if (index < this.columns.length - 1) {
1107
                this.columns = this.columns.slice(0, index + 1);
1108
            }
1109
        }
1110
    }
1111

1112
    private getSubmitValue(originOptions: ThyCascaderOption[]): any[] {
1113
        const values: any[] = [];
1114
        (originOptions || []).forEach(option => {
1115
            values.push(this.getOptionValue(option));
1116
        });
1117
        return values;
1118
    }
1119

1120
    constructor(private cdr: ChangeDetectorRef, private viewPortRuler: ViewportRuler, public elementRef: ElementRef) {
1121
        super();
1122
    }
1123

1124
    public trackByFn(index: number, item: ThyCascaderOption) {
1125
        return item?.value || item?._id || index;
1126
    }
1127

1128
    public searchFilter(searchText: string) {
1129
        if (!searchText && !this.isSelectingSearchState) {
1130
            this.resetSearch();
1131
        }
1132
        this.searchText$.next(searchText);
1133
    }
1134

1135
    private initSearch() {
1136
        this.searchText$
1137
            .pipe(
1138
                takeUntil(this.destroy$),
1139
                debounceTime(200),
1140
                distinctUntilChanged(),
1141
                filter(text => text !== '')
1142
            )
1143
            .subscribe(searchText => {
1144
                this.resetSearch();
1145

1146
                // local search
1147
                this.searchInLocal(searchText);
1148
                this.isShowSearchPanel = true;
1149
            });
1150
    }
1151

1152
    private forEachColumns(
1153
        currentLabel?: string[],
1154
        currentValue: Id[] = [],
1155
        currentRowValue: ThyCascaderOption[] = [],
1156
        list = this.columns[0]
1157
    ) {
1158
        list.forEach(item => {
1159
            const curOptionLabel = this.getOptionLabel(item);
1160
            const curOptionValue = this.getOptionValue(item);
1161
            const label: string[] = currentLabel ? [...currentLabel, curOptionLabel] : [curOptionLabel];
1162
            const valueList: Id[] = [...currentValue, curOptionValue];
1163
            const rowValueList: ThyCascaderOption[] = [...currentRowValue, item];
1164
            const isSelected = this.isSelectedOption(item, valueList.length - 1);
1165

1166
            this.flattenOptions.push({
1167
                labelList: label,
1168
                valueList,
1169
                selected: isSelected,
1170
                thyRowValue: rowValueList,
1171
                isLeaf: item.isLeaf,
1172
                disabled: item.disabled
1173
            });
1174
            if (item.children && item.children.length) {
1175
                this.forEachColumns(label, valueList, rowValueList, item.children);
1176
            } else {
1177
                this.leafNodes.push({
1178
                    labelList: label,
1179
                    valueList,
1180
                    selected: isSelected,
1181
                    thyRowValue: rowValueList,
1182
                    isLeaf: item.isLeaf,
1183
                    disabled: item.disabled
1184
                });
1185
            }
1186
        });
1187
    }
1188

1189
    private setSearchResultList(listOfOption: ThyCascaderSearchOption[], searchText: string) {
1190
        this.searchResultList = [];
1191
        listOfOption.forEach(item => {
1192
            if (!item.disabled && item.isLeaf && item.labelList.join().toLowerCase().indexOf(searchText.toLowerCase()) !== -1) {
1193
                this.searchResultList.push(item);
1194
            }
1195
        });
1196
    }
1197

1198
    private searchInLocal(searchText: string): void {
1199
        this.forEachColumns();
1200

1201
        this.setSearchResultList(this.thyIsOnlySelectLeaf ? this.leafNodes : this.flattenOptions, searchText);
1202
    }
1203

1204
    private resetSearch() {
1205
        this.isShowSearchPanel = false;
1206
        this.searchResultList = [];
1207
        this.leafNodes = [];
1208
        this.flattenOptions = [];
1209
        this.scrollActiveElementIntoView();
1210
    }
1211

1212
    public selectSearchResult(selectOptionData: ThyCascaderSearchOption): void {
1213
        const { thyRowValue: selectedOptions } = selectOptionData;
1214
        if (selectOptionData.selected) {
1215
            if (!this.isMultiple) {
1216
                this.closeMenu();
1217
            }
1218
            return;
1219
        }
1220
        if (this.isMultiple) {
1221
            this.isSelectingSearchState = true;
1222
            selectOptionData.selected = true;
1223
            selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
1224
                this.setActiveOption(item, index, index === selectedOptions.length - 1);
1225
            });
1226
            const originSearchResultList = this.searchResultList;
1227
            // 保持搜索选项
1228
            setTimeout(() => {
1229
                this.isShowSearchPanel = true;
1230
                this.searchResultList = originSearchResultList;
1231
                this.isSelectingSearchState = false;
1232
            });
1233
        } else {
1234
            selectedOptions.forEach((item: ThyCascaderOption, index: number) => {
1235
                this.setActiveOption(item, index, index === selectedOptions.length - 1);
1236
            });
1237

1238
            this.resetSearch();
1239
        }
1240
    }
1241

1242
    ngOnDestroy() {
1243
        this.destroy$.next();
1244
        this.destroy$.complete();
1245
    }
1246
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc