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

atinc / ngx-tethys / 72b1ae71-5bf2-488d-85fa-e5298804ce26

28 Nov 2023 07:09AM UTC coverage: 90.303%. Remained the same
72b1ae71-5bf2-488d-85fa-e5298804ce26

Pull #2923

circleci

smile1016
feat(cascader): multi-select support template
Pull Request #2923: feat(select):multi-select selected options support template #INFR-10631

5316 of 6547 branches covered (0.0%)

Branch coverage included in aggregate %.

13 of 14 new or added lines in 2 files covered. (92.86%)

40 existing lines in 3 files now uncovered.

13253 of 14016 relevant lines covered (94.56%)

975.86 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!
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✔
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!
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
     * 自定义浮层列类名
223
     * @type string
×
224
     */
×
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
199✔
264
     */
199✔
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
     * 是否仅允许选择叶子项
284
     * @default true
×
285
     */
×
286
    @Input()
287
    @InputBoolean()
288
    thyIsOnlySelectLeaf = true;
×
289

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

296
    @Input('thySelectedOptionRender') selectedOptionDisplayRef: TemplateRef<any>;
297

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

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

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

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

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

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

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

338
    @ViewChildren('cascaderOptionContainers', { read: ElementRef }) cascaderOptionContainers: QueryList<ElementRef>;
14✔
339

29✔
340
    @ViewChild(CdkConnectedOverlay, { static: true }) cdkConnectedOverlay: CdkConnectedOverlay;
26✔
341

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

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

346
    @ViewChild('menu') menu: ElementRef;
347

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

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

373
    private value: any[];
374

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

377
    private activatedOptions: ThyCascaderOption[] = [];
378

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

×
384
    private isMultiple = false;
385

386
    private prevSelectedOptions: Set<ThyCascaderOption> = new Set<ThyCascaderOption>();
36✔
387

35✔
388
    public menuMinWidth = 122;
35✔
389

35✔
390
    private searchText$ = new BehaviorSubject('');
35✔
391

35✔
392
    public searchResultList: ThyCascaderSearchOption[] = [];
30✔
393

394
    public isShowSearchPanel: boolean = false;
35✔
395

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

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

406
    private leafNodes: ThyCascaderSearchOption[] = [];
407

408
    private valueChange$ = new Subject();
409

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

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

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

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

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

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

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

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

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

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

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

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

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

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

588
    private isLoaded(index: number): boolean {
589
        return this.columns[index] && this.columns[index].length > 0;
26✔
590
    }
132✔
591

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

596
    public getOptionValue(option: ThyCascaderOption): any {
597
        return option[this.thyValueProperty || 'value'];
132✔
598
    }
19✔
599

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

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

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

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

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

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

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

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

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

710
    public setMenuVisible(menuVisible: boolean): void {
5✔
711
        if (this.menuVisible !== menuVisible) {
4✔
712
            this.menuVisible = menuVisible;
4✔
713

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

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

81✔
736
    public get menuCls(): any {
81✔
737
        return this._menuCls;
18✔
738
    }
739

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

749
    public get menuColumnCls(): any {
47✔
750
        return this._menuColumnCls;
47✔
751
    }
47✔
752

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1200
    private searchInLocal(searchText: string): void {
1201
        this.forEachColumns();
1202

1203
        this.setSearchResultList(this.thyIsOnlySelectLeaf ? this.leafNodes : this.flattenOptions, searchText);
1204
    }
1205

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

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

1240
            this.resetSearch();
1241
        }
1242
    }
1243

1244
    ngOnDestroy() {
1245
        this.destroy$.next();
1246
        this.destroy$.complete();
1247
    }
1248
}
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