yanweiyuan3
2023-08-09 588bc7829387dfc761cc25f06f77d4c81818bd10
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
/**
 * @file        S&OP List component to wrap common methods the team encounter during development
 * @description List class extending libappbase's ListBase.
 * All S&OP page objects inherit from our own class (inheriting e2e/libappbase), but we can propose common methods to them.
 * @author      Clarence (clarence.chan@3ds.com)
 * @copyright   Dassault Systèmes
 */
import { ListRow } from '../e2elib/lib/src/pageobjects/list/listrow.component';
import { DialogBase } from '../libappbase/dialogbase';
import { ColumnIDValueMap, ListBase } from '../libappbase/listbase';
import { lowercaseFirstLetter } from '../libappbase/utils';
import { Timeout } from '../libmp/appmp';
import { QUtils } from '../e2elib/lib/src/main/qutils.class';
import { ContextMenuItemSOP, ContextMenuSOP } from './contextmenusop';
import { Form } from '../e2elib/lib/src/pageobjects/form.component';
import { WebMessageBox } from '../libappbase/webmessagebox';
import { ButtonSOP } from './buttonsop';
import { QContextMenu } from '../e2elib/lib/api/pageobjects/qcontextmenu.component';
import { QToastButtonName } from '../e2elib/lib/api/types';
import { DataHierarchy, DataHierarchyProvider } from './datahierarchy';
 
/**
 * List row checkbox check/uncheck status, visually decorated to indicate if checkbox itself is checked or descendants row checkbox check/uncheck status
 * (example: checkbox decorated with light triangle if any descendants checked).
 */
export enum DataRowCheckBoxDecoration {
  DarkDecorated = 'DarkDecorated',
  LightDecorated = 'LightDecorated',
  None = 'None',
}
 
type listDataHierarchy = [DataHierarchyProvider, DataHierarchy];
 
export class ListSOP<DialogType extends DialogBase, ColumnType> extends ListBase {
  public readonly dlgCreateEdit: DialogType;
  /**
   * To retrieve row primary key column names when assert fails (for error message to be informative)
   */
  protected rowPrimaryColumnNames: ColumnType;
  /**
   * Provide the single column name which is for All constraints. In case team renamed this.
   */
  protected rowAllConstraintsColumnName: ColumnType;
  private readonly _cmMenuMap: Map<string, QContextMenu> = new Map();
 
  public static getFieldsKeyValueAsString(fields: any): string {
    const arr: string[] = [];
    for (const [key, value] of Object.entries(fields)) {
      arr.push(`${key} = "${value}"`);
    }
 
    return arr.join(', ');
  }
 
  public static getFieldsKeyValueArrayAsString(parents?: any[], delimiter: string = ', '): string {
    const arr: string[] = [];
    if (parents) {
      for (const x of parents) {
        arr.push(ListSOP.getFieldsKeyValueAsString(x));
      }
    }
    return arr.join(delimiter);
  }
 
  public constructor(componentPath: string, dialog: DialogType) {
    super(componentPath);
    this.dlgCreateEdit = dialog;
  }
 
  /**
   * Subclass to override. Based on query to DataHierarchyProvider, format into the subclass row and parent values format (as each subclass list has its own column definitions).
   *
   * @param _hierarchyProvider The object that holds the list hierarchy data (parent-child names).
   * @param _name The child row name to retrieve parent hierarchy information.
   * @returns Row and parent values based on the subclass column values.
   */
  public getHierarchy(_hierarchyProvider: DataHierarchyProvider, _name: DataHierarchy): [ColumnType, ColumnType[] | undefined] {
    throw Error('Subclass to override getHierarchy.');
  }
 
  /**
   * If no row provided, focus list (to allow action buttons to refresh the enable state) and click the action button.
   * If row(s) provided, the selection must be done prior to calling this method.
   *
   * @param actionButton Action button to click (e.g Create/Edit button). Always use button defined in view instead of instantiating one inside the list/dialog.
   * @param contextMenu (Optional) Context menu item name of the button to click.
   * @param rows (Optional) Selected row(s) to then click action bar button.
   * @returns The dialog that triggered from the action button click (e.g Create dialog when clicking Create action button)
   */
  public async clickActionButton<AltDialog extends Form>(actionButton: ButtonSOP, contextMenu?: string, rows?: ListRow | ListRow[], altDialog?: AltDialog): Promise<DialogType> {
    // If no row(s) provided, focus on the list
    if (rows === undefined) {
      await this.focus();
    }
 
    await actionButton.waitForScreenUpdate(Timeout.ButtonState);
 
    if (contextMenu) {
      await actionButton.clickDropdownAndSelectMenu(contextMenu);
    } else {
      await actionButton.click();
    }
 
    // If alternative dialog defined, wait for it before proceed next
    if (altDialog) {
      altDialog = await this.waitAndReturnDialog(altDialog);
    }
 
    // TODO: For now always return dialog assuming is create/edit that brings up dialog. Find generic way decide if need to e.g Delete doesn't require it.
    return this.getDialog();
  }
 
  /**
   * Click action bar button which triggers a dialog prompt for user to choose Yes or No to proceed.
   *
   * @param actionButton Action button to click (e.g Create/Edit button). Always use button defined in view instead of instantiating one inside the list/dialog.
   * @param dismissPrompt Click Yes or No to dismiss dialog.
   */
  public async clickActionButtonAndDismissPrompt(actionButton: ButtonSOP, dismissPrompt: QToastButtonName = QToastButtonName.Yes): Promise<void> {
    await actionButton.click();
 
    const webMessageBox = new WebMessageBox();
    await webMessageBox.waitUntilPresent(false);
 
    if (dismissPrompt === QToastButtonName.Yes) {
      await webMessageBox.selectYes();
    } else {
      await webMessageBox.selectNo();
    }
    await webMessageBox.waitUntilHidden();
  }
 
  /**
   * Collapse the child row and all its parent rows.
   *
   * @param columns Most inner row.
   * @param parentValues One or more parent rows if any.
   */
  public async collapseChildRowAndParents(columns: ColumnType, parentValues?: ColumnType[]): Promise<void> {
    if (parentValues) {
      // Collapse the immediate parent and go to next parent
      for (let i = parentValues.length - 1; i >= 0; i--) {
        // If root row, there's no parent thus we pass undefined
        // Else get all its parent (slice 2nd argument i is not inclusive)
        const curParent = i === 0 ? undefined : parentValues.slice(0, i);
 
        const curRow = await this.getRow(parentValues[i], curParent);
        await curRow.collapseRow();
      }
    } else {
      const rootRow = await this.getRow(columns);
      await rootRow.collapseRow();
    }
  }
 
  /**
   * Open dialog for edit via double click.
   *
   * @param columns The row to retrieve
   * @param parentValues (Optional) The parent row to identify the target row
   * @return the dialog that is opened
   */
  public async doubleClickRow(columns: ColumnType, parentValues?: ColumnType[]): Promise<DialogType> {
    const row = await this.getRow(columns, parentValues);
    await row.doubleClick();
    return this.getDialog();
  }
 
  /**
   * Expand root or children row in hierarchy list.
   *
   * @param columns The row to retrieve
   * @param parentValues [Optional] One or more parent row to identify the target row
   */
  public async expandRow(columns: ColumnType, parentValues?: ColumnType[]): Promise<void> {
    if (parentValues === undefined) {
      // Cannot use getRow() as parentValues not defined and listbase getRowByValueInHierarchy expects parents always
      const row = await this.getRow(columns);
      await row.expandRow();
    } else {
      // Lazy and save lines of code, call getRow which uses getRowByValueInHierarchy (which will expand in hierarchy list)
      await this.getRow(columns, parentValues);
    }
  }
 
  /**
   * Convert List interface field (key) to string.
   * Use case, when invoke e2e List/ListBase method which argument is column name (string), we need convert the interface field (key) to string.
   * This is compromise as we need the List interface to be used as argument for other methods such as verifyRowExist, etc. Thus this minor
   * inconvinence to need to extract/convert List interface field (key) to its string equivalent (colum name).
   *
   * @param interfaceColumn The interface field to extract column name as string
   * @returns The column name as string which can be use to invoke e2e List/ListBase methods.
   * @example
   * interface ListStrategyColumn { Name?: string }
   *
   * <List>.extractColumnName({Name: ''}); // Just provide empty value, does not matter
   * This returns the Name as string 'Name'
   */
  public extractColumnName(interfaceColumn: ColumnType): string {
    const keys = Object.keys(interfaceColumn);
    // TODO: Cannot use expect as we may want to use this method in "it" description as expect can only use within "it"/beforeall, etc
    // See if need explore other ways to check and warn user if more than 1 interface fields defined?
    // expect(keys.length).toBe(1, 'Configuration error: Can only define 1 field from the List interface as we want to extract 1 interface field to convert to string.');
 
    return keys[0];
  }
 
  public async getDialog(): Promise<DialogType> {
    return this.waitAndReturnDialog(this.dlgCreateEdit);
  }
 
  /**
   * Get a list row by defining one or more columns with the option to search based on parent row(s).
   *
   * @param targetRowValues The row to retrieve. Pass in listDataHierarchy if data is hierarchical in nature and parentValues argument will be ignored.
   * @param parentValues One or more parent row to identify the target row
   * @returns ListRow representing the target row
   */
  public async getRow(targetRowValues: ColumnType | listDataHierarchy, parentValues?: ColumnType[]): Promise<ListRow> {
    [targetRowValues, parentValues] = this.getRowAndParentColumnTypes(targetRowValues, parentValues);
 
    // Get the target row mapping format that e2elib List recognizes
    const columnToValueMaps: ColumnIDValueMap[] = this.getColumnIDValueMapFromColumnType(targetRowValues);
 
    // If parent defined (hierarchical list), get the parent(s) mapping format that e2elib List recogizes
    if (parentValues) {
      const parents: ColumnIDValueMap[][] = [];
      // Can define to get row based on more than 1 parent row
      for (const parentValue of parentValues) {
        const parentRow: ColumnIDValueMap[] = this.getColumnIDValueMapFromColumnType(parentValue);
        parents.push(parentRow);
      }
      return this.getRowByValueInHierarchy(columnToValueMaps, parents);
    }
 
    // If no parent defined, use the simpler API
    return this.getRowByValue(columnToValueMaps);
  }
 
  /**
   * Get a list rows by defining one or more columns with the option to search based on parent row(s).
   *
   * @param targetRowValues The rows to retrieve
   * @param parentValues One or more parent row to identify the target rows
   * @returns ListRows representing the target rows
   */
  public async getRows(targetRowValues: ColumnType[], parentValues?: ColumnType[]): Promise<ListRow[]> {
    const rows: ListRow[] = [];
    for (const x of targetRowValues) {
      const r = await this.getRow(x, parentValues);
      rows.push(r);
    }
 
    return rows;
  }
 
  /**
   * Get ListRows based on the row indexes (0-based). e2elib does not have method to retrieve multiple rows based on indexes.
   *
   * @param indexes Row numbers to retrieve (e.g [0, 1]).
   * @returns One or more ListRows.
   */
  public async getRowsByIndex(indexes: number[]): Promise<ListRow[]> {
    const rows: ListRow[] = [];
    for (const i of indexes) {
      const row = await this.getRowByIndex(i);
      rows.push(row);
    }
 
    return rows;
  }
 
  /**
   * Get row image attribute name (resize column if needed).
   *
   * @param row List row.
   * @param columnName Image attribute column name.
   * @returns The image name for the row.
   */
  public async getRowImageAttribute(row: ListRow, columnName: string): Promise<string> {
    const recordCellValues: Record<string, string> = await this.getCellValuesFromRow(row, [columnName]);
    const [, imageName] = Object.entries(recordCellValues)[0];
 
    return imageName;
  }
 
  /**
   * Get the row's primary key column values, to output during assert failure for easier debugging.
   *
   * @param row ListRow object to retrieve the primary key column values.
   * @returns String in the format 'Row with column 'x': value = y, column 'x2': value = y2'
   */
  public async getRowPrimaryColumnNames(row: ListRow): Promise<string> {
    const names: string[] = [];
 
    if (this.rowPrimaryColumnNames) {
      const recordCellValues: Record<string, string> = await this.getCellValuesFromRow(row, Object.keys(this.rowPrimaryColumnNames));
      for (const [cellName, cellValue] of Object.entries(recordCellValues)) {
        names.push(`column '${cellName}': value = ${cellValue}`);
      }
    } else {
      names.push('<no primary column names defined, please set when instantiating List>');
    }
 
    return `Row with ${  names.join()}`;
  }
 
  /**
   * Right click on list white space (empty area).
   * Use case right click on empty area and for script script to verify list menu/action bar button not enabled as no row selected.
   */
  public async rightClickOnWhiteSpace(): Promise<void> {
    await QUtils.rightClickElementWithMenu(this.listArea);
  }
 
  /**
   * Select all rows (via Ctrl+A shortcut), right click and select context menu.
   * Example usage: Select all rows to delete via context menu.
   *
   * @param contextMenu Context menu item name.
   */
  public async selectAllAndSelectContextMenu(contextMenu: ContextMenuItemSOP): Promise<void> {
    await this.focus(); // Focus list before press Ctrl+A shortcut to select all rows
    await QUtils.keyBoardAction(['a'], true);
 
    // Ensure there's selected rows
    const selectedRows = await this.getInboundSelectedRows();
    expect(selectedRows.length > 0).toBe(true, 'Expected all rows to be selected using Ctrl+A shortcut.');
 
    // Right click first row and select menu
    const row = await this.getRowByIndex(0);
    await this.selectContextMenu(contextMenu, row);
  }
 
  /**
   * Right click on row(s) if provided else right click on list and select context menu.
   * Typical use to click create context menu to bring up create/edit dialog as well as right click one or more rows to edit/delete/copy.
   *
   * @param contextMenu Context menu to click.
   * @param rows [Optional] One or more rows to right click before selecting context menu.
   * @param altDialog [Optional] Alternative dialog to use instead of the main dialog set during constructor. Typical use case if list triggers few different dialogs.
   * This cater also for WebMessageBox (the dialog prompt to request user to confirm deletion for example).
   * @returns Tuple consisting of main dialog and alternative dialog (which can be undefined).
   */
  public async selectContextMenu<AltDialog extends Form>(contextMenu: ContextMenuItemSOP, rows?: ListRow | ListRow[], altDialog?: AltDialog): Promise<[DialogType, AltDialog | undefined]> {
    // e2e rightClick needs 1 row to right click on, thus if 1 or more rows defined, get the first row
    const row = Array.isArray(rows) ? rows[0] : rows;
 
    if (row) {
      const qmodifiers = Array.isArray(rows) ? { ctrl: true } : undefined;
      await row.rightClick(qmodifiers, this.findCreateContextMenu(contextMenu.ContextMenu), contextMenu.Name);
    } else {
      await this.rightClick(undefined, this.findCreateContextMenu(contextMenu.ContextMenu), contextMenu.Name);
    }
 
    const dlg = await this.getDialog();
    if (altDialog) {
      altDialog = await this.waitAndReturnDialog(altDialog);
    }
 
    // Return tuple (1st dialog = main dialog set during constructor, 2nd dialog only used if list triggers more than 1 dialog thus possibly undefined)
    return [dlg, altDialog];
  }
 
  /**
   * Right click on list or row and select context menu. A web message box prompts and define whether to click Yes or No.
   *
   * @param contextMenu Context menu item to click.
   * @param rows One or more row to right click on.
   * @param dismissPrompt Click Yes or No to dismiss dialog.
   */
  public async selectContextMenuAndDismissPrompt(contextMenu: ContextMenuItemSOP, rows?: ListRow | ListRow[], dismissPrompt: QToastButtonName = QToastButtonName.Yes): Promise<void> {
    const webMessageBox = new WebMessageBox();
    await this.selectContextMenu(contextMenu, rows, webMessageBox);
 
    await webMessageBox.waitUntilPresent(false);
    if (dismissPrompt === QToastButtonName.Yes) {
      await webMessageBox.selectYes();
    } else {
      await webMessageBox.selectNo();
    }
    await webMessageBox.waitUntilHidden();
  }
 
  /**
   * Select a row based on one or more columns.
   *
   * @param columns The columns criteria to select a row. Derived class to provide actual interface to govern what columns to be select per list.
   * @returns The selected row.
   */
  public async selectRow(columns: ColumnType, parentValues?: ColumnType[], holdCtrl?: boolean): Promise<ListRow> {
    const columnToValueMaps: ColumnIDValueMap[] = this.getColumnIDValueMapFromColumnType(columns);
 
    let parentRow: ListRow | undefined;
    if (parentValues) {
      const immediateParentRow = parentValues.pop() as ColumnType; // Take immediate parent (remove it from the array)
 
      // Guard against only 1 parent hierarchy (as after pop will have empty parentValues)
      parentValues = parentValues.length === 0 ? undefined : parentValues;
 
      parentRow = await this.getRow(immediateParentRow, parentValues);
    }
 
    await this.selectListRowsByValue([columnToValueMaps], holdCtrl, undefined, undefined, parentRow);
    // Not using getHighlightedRows and getInboundSelectedRows as the above select too fast for get 'selected' rows to be correct
    // It wrongly select the 1st row in list, so we use getRow() to be absolutely sure
    return this.getRow(columns);
  }
 
  /**
   * Select one or more rows. Method name to distinguish with e2e selectRows.
   *
   * @param columns The columns criteria to select a row. Derived class to provide actual interface to govern what columns to be select per list.
   * @returns Selected row(s).
   */
  public async selectListRows(columns: ColumnType[]): Promise<ListRow[]> {
    const rows: ListRow[] = [];
    for (let x = 0; x < columns.length; x++) {
      // 2nd row onwards press Ctrl to multi-select
      const row = x >= 1 ? await this.selectRow(columns[x], undefined, true) : await this.selectRow(columns[x]);
      rows.push(row);
    }
 
    return rows;
  }
 
  /**
   * Verify that the children of the parent row is either ALL checked or unchecked
   *
   * @param isChecked True if all children are checked, false if all childrens are unchecked
   * @param targetRowValues The rows to retrieve
   * @param parentValues The parent row
   */
  public async verifyChildrenRowsChecked(isChecked: Boolean, targetRowValues: ColumnType, parentValues?: ColumnType[]): Promise<void> {
    const [effectiveTargetRowValues, effectiveParentValues] = this.getRowAndParentColumnTypes(targetRowValues, parentValues);
    const parentRow = await this.getRow(effectiveTargetRowValues, effectiveParentValues);
    const numberOfChild = await parentRow.getChildRowCount();
    for (let i = 0; i < numberOfChild; i++) {
      const currentChild = await parentRow.getChildRow(i);
      await this.verifyRowChecked(currentChild, undefined, isChecked);
    }
  }
 
  /**
   * Select rows and right click to assert if context menu is enabled.
   *
   * @param contextMenu Context menu name to verify.
   * @param rows One or most ListRow to select and right click.
   * @param errorMessage [Optional] Custom error when assert fails, else use default message.
   */
  public async verifyContextMenuEnabled(contextMenu: ContextMenuItemSOP, rows?: ListRow[], errorMessage?: string, expectedTooltip?: string): Promise<void> {
    await this.verifyContextMenuEnabledHelper(true, contextMenu, rows, errorMessage, expectedTooltip);
  }
 
  /**
   * Select rows and right click to assert if context menu is disabled.
   *
   * @param contextMenu Context menu name to verify.
   * @param rows One or most ListRow to select and right click.
   * @param errorMessage [Optional] Custom error when assert fails, else use default message.
   */
  public async verifyContextMenuDisabled(contextMenu: ContextMenuItemSOP, rows?: ListRow[], errorMessage?: string, expectedTooltip?: string): Promise<void> {
    await this.verifyContextMenuEnabledHelper(false, contextMenu, rows, errorMessage, expectedTooltip);
  }
 
  /**
   * Verify specific row cell(s) if rendered as bold (technical term, computed element font weight >= 700).
   * Example: Verify a row's Name and ID columns are bold.
   *
   * @param row ListRow to verify.
   * @param columns The columns for the row to verify.
   */
  public async verifyRowCellBold(row: ListRow, columns: ColumnType): Promise<void> {
    const errorMsg: string[] = [];
 
    for (const [colName, expectedBold] of Object.entries(columns)) {
      const expectedCellBold = expectedBold === 'true';
      const fontWeight = await (await row.getCell(colName)).element.getCssValue('font-weight');
      const cssBold = Number(fontWeight);
 
      // https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight
      const matchExpectation = expectedCellBold ? cssBold >= 700 : cssBold < 700;
      if (!matchExpectation) {
        const errorCell = expectedCellBold ? 'expected = bold, actual = not bold' : 'expected = not bold, actual = bold';
        errorMsg.push(`Column ${colName}: ${errorCell}.`);
      }
    }
 
    if (errorMsg.length >= 1) {
      throw Error(`${await this.getRowPrimaryColumnNames(row)}. ${errorMsg.join('<br>')}`);
    }
  }
 
  /**
   * Verify if a row exists in list. Can use one or more columns as criteria.
   *
   * @param columns The columns criteria to use. Derived class to provide actual interface to govern what columns can be used.
   */
  public async verifyRowExists(columns: ColumnType, parentValues?: ColumnType[]): Promise<void> {
    await this.verifyRowExistHelper(true, columns, parentValues);
  }
 
  public async verifyRowNotExist(columns: ColumnType, parentValues?: ColumnType[]): Promise<void> {
    await this.verifyRowExistHelper(false, columns, parentValues);
  }
 
  /**
   * Verify if the row is checked or unchecked.
   *
   * @param targetRowValues The row to retrieve.
   * @param parentValues One or more parent row to identify the target row.
   */
  public async verifyRowChecked(targetRowValues: ColumnType | ListRow | null, parentValues?: ColumnType[], expectedCheck: Boolean = true): Promise<void> {
    if (targetRowValues === null) {
      throw Error('No row have been identified. Please make sure a row is provided.');
    }
    const errorSuffix = expectedCheck ? 'checked' : 'unchecked';
    const isCheck = targetRowValues instanceof ListRow ? await targetRowValues.isChecked() : await (await this.getRow(targetRowValues, parentValues)).isChecked();
    expect(isCheck).toBe(expectedCheck, `Expect row ${ListSOP.getFieldsKeyValueAsString(targetRowValues)} to be ${errorSuffix}.`);
  }
 
  /**
   * Verify list row checkbox visual decoration to indicate if checkbox itself is checked or descendants row checkbox check/uncheck status
   * Example : Checkbox decorated with light triangle if any descendants checked
   * Checkbox decorated with dark triangle if Row checkbox is checked
   *
   * @param targetRowValues The target row checkbox to be varified. Pass in listDataHierarchy if data is hierarchical in nature and parentValues argument will be ignored.
   * @param expectedDecoration The decoration in the checkbox, example : DataRowCheckBoxDecoration.LightDecorated
   * @param parentValues All the parent of the target row
   */
  public async verifyRowCheckBoxDecoration(targetRowValues: ColumnType | listDataHierarchy, expectedDecoration: DataRowCheckBoxDecoration, parentValues?: ColumnType[]): Promise<void> {
    const [effectiveTargetRowValues, effectiveParentValues] = this.getRowAndParentColumnTypes(targetRowValues, parentValues);
    const row = await this.getRow(effectiveTargetRowValues, effectiveParentValues);
    switch (expectedDecoration) {
      case DataRowCheckBoxDecoration.None:
        expect(await row.isDecorated()).toBe(false, `Verify row checkbox for ${ListSOP.getFieldsKeyValueAsString(targetRowValues)}. Expected checkbox to not have decorate.`);
        return;
      case DataRowCheckBoxDecoration.LightDecorated:
        expect(await row.isLightDecorated()).toBe(true, `Verify row checkbox for ${ListSOP.getFieldsKeyValueAsString(targetRowValues)}. Expected checkbox to have light blue triangle decoration at bottom right.`);
        return;
      case DataRowCheckBoxDecoration.DarkDecorated:
        expect(await row.isDarkDecorated()).toBe(true, `Verify row checkbox for ${ListSOP.getFieldsKeyValueAsString(targetRowValues)}. Expected checkbox to have dark blue triangle decoration at bottom right.`);
        return;
      default:
        throw Error(`Invalid DataRowCheckBoxDecoration value '${expectedDecoration}' passed to verify row checkbox decoration.`);
    }
  }
 
  /**
   * Verify row's All Constraints column.
   *
   * @param row Row to verify.
   * @param hasConstraint Expected to have violation or not.
   */
  public async verifyRowHasConstraintViolation(row: ListRow, hasConstraint: boolean = true): Promise<void> {
    if (!this.rowAllConstraintsColumnName) {
      throw Error(`Please define 'rowAllConstraintsColumnName' during ${this.componentName} constructor. This represents the all constraints column name.`);
    }
 
    // Use libappbase method as it will resize the column if not visible (else some column will show as ... which fails the test)
    const recordCellValues: Record<string, string> = await this.getCellValuesFromRow(row, Object.keys(this.rowAllConstraintsColumnName));
 
    // Get the first result (index 0) as we assume only 1 constraint column in list
    const [, actualHasConstraint] = Object.entries(recordCellValues)[0];
    const expectedImgName = hasConstraint ? 'SADSMILEY3D' : 'EMPTY';
    expect(actualHasConstraint).toBe(expectedImgName, `Verify all constraints column for ${  await this.getRowPrimaryColumnNames(row)}`);
  }
 
  /**
   * Verify row is a root level row in list (no parent row).
   *
   * @param row Row to verify.
   */
  public async verifyRowHasNoParentRow(row: ListRow): Promise<void> {
    const parentRow = await row.getParentRow();
    if (parentRow !== null) {
      throw Error(`Expect '${await this.getRowPrimaryColumnNames(row)}' to have no parent row, currently belongs to parent '${await this.getRowPrimaryColumnNames(parentRow)}'.`);
    }
  }
 
  /**
   * Verify row contains the ondraw image.
   *
   * @param row Row to verify.
   * @param imgName Image name to verify if exists as part of row ondraw images.
   * @returns True if ondraw image is found.
   */
  public async verifyRowHasOnDrawImage(row: ListRow, imgName: string): Promise<boolean> {
    const rowOnDrawImages = await row.getOndrawImageElements();
    const foundImage = rowOnDrawImages.indexOf(imgName) !== -1;
 
    expect(foundImage).toBe(true, `Not able find ondraw image '${imgName}' for ${await this.getRowPrimaryColumnNames(row)}. Current row ondraw image(s) = ${rowOnDrawImages.join(', ')}`);
 
    return foundImage;
  }
 
  public async verifyRowValues(row: ListRow, expectedColumnValues: ColumnType): Promise<void> {
    // Use LibAppBase method to get the target row's cell values that we interested (based on passed in interface)
    // The LibAppbase method needs array like type so we pass in the ColumnType (interface) keys
    const recordCellValues: Record<string, string> = await this.getCellValuesFromRow(row, Object.keys(expectedColumnValues));
    const expectedColumnValuesEntries = Object.entries(expectedColumnValues);
 
    // For each cell value, compare against expected values
    const errorMessages: string[] = [];
    for (const [cellName, cellValue] of Object.entries(recordCellValues)) {
      let expectedValue = '';
      for (const [expectedColName, expectedColValue] of expectedColumnValuesEntries) {
        // Libappbase's ListBase.getCellValuesFromRow() convert 1st letter to lowercase and remove space between words
        // Thus we need do the same before comparing if the column names matches
        let expectedColName2 = expectedColName.replace(/\s/, '');
        expectedColName2 = lowercaseFirstLetter(expectedColName2);
        if (expectedColName2 === cellName) {
          expectedValue = expectedColValue;
        }
      }
 
      if (cellValue !== expectedValue) {
        // TODO: 'cellName' returned from LibAppBase method changse the first letter to lowercase (and remove spaces between column name), but OK for now until there's concern
        errorMessages.push(`Column '${cellName}': expected value = ${expectedValue}, actual = ${cellValue}`);
      }
    }
 
    if (errorMessages.length > 0) {
      errorMessages.unshift(await this.getRowPrimaryColumnNames(row)); // Insert row primary key column names to easy identify the issue
    }
 
    expect(errorMessages.join('<br>')).toBe('');
  }
 
  /**
   * Verify total row matches, optionally providing a failure message to append if expect fails.
   *
   * @param expectedTotal Expected total row count.
   * @param additionalFailureMessage [Optional] Failure message to append (use to define scenario specific information).
   */
  public async verifyTotalRow(expectedTotal: number, additionalFailureMessage?: string): Promise<void> {
    const appendFailureMessage = additionalFailureMessage ? additionalFailureMessage : '';
 
    expect(await this.getRowCount()).toBe(expectedTotal, `Verify list "${await this.getComponentLabel()}" total row fail. ${appendFailureMessage}`);
  }
 
  /// // Private methods goes here
 
  /**
   * Convert interface key-value pairs of column name and value into e2elib ColumnIDValueMap format.
   *
   * @param columns The interface key-value pairs of column name and value.
   * @returns ColumnIDValueMap format to be used to invoke operations on e2elib List
   */
  private getColumnIDValueMapFromColumnType(columns: ColumnType): ColumnIDValueMap[] {
    const columnToValueMaps: ColumnIDValueMap[] = [];
    let onDrawImageName = '';
    for (const [colName, colValue] of Object.entries(columns)) {
      // If ondrawimage, do not create as column-value pair as we will append to the first column-value later on
      if (colName === 'OndrawImageName') {
        onDrawImageName = colValue as string;
      } else {
        columnToValueMaps.push({ columnID: colName, value: colValue});
      }
 
      // If ondraw image defined, find the first column to append the additional ondraw image name (assumption first column indeed has the ondraw image)
      if (onDrawImageName !== '') {
        columnToValueMaps[0].additionAttribute = [{attribute: 'OndrawImageName', value: onDrawImageName}];
      }
    }
 
    return columnToValueMaps;
  }
 
  /**
   * Returns the context menu from stored map if previously created, else create, store and return it.
   *
   * @param name Context menu name.
   * @returns Context menu from stored map.
   */
  private findCreateContextMenu(name: string): QContextMenu | undefined {
    let cmMenu: QContextMenu | undefined;
    if (this._cmMenuMap.has(name)) {
      cmMenu = this._cmMenuMap.get(name);
    } else {
      cmMenu = new QContextMenu(name);
      this._cmMenuMap.set(name, cmMenu);
    }
 
    return cmMenu;
  }
 
  /**
   * Return parent row(s) column names and values, to be used in assert error.
   * Simple format: <column name 1>,<column value 1>,<column name 2>,<column value 2>....
   *
   * @param columns Array of interface key-value pairs of column name and value. Each array element denotes a parent row.
   * @returns Parent row(s) column names and values.
   */
  private getFormattedParentRows(columns: ColumnType[] | undefined): string {
    const value: string[] = [];
    for (const c of columns!) {
      value.push(Object.entries(c).join()); // <column name 1>,<column value 1>,<column name 2>,<column value 2>....
    }
 
    return `<br>Parent row(s) = ${  value.join('<br>')}`; // Put a newline for each parent row
  }
 
  private getRowAndParentColumnTypes(targetRowValues: ColumnType | listDataHierarchy, parentValues?: ColumnType[]): [ColumnType, ColumnType[] | undefined] {
    let thisRow: ColumnType;
    let parentRows: ColumnType[] | undefined;
    // Cast argument to check if it's listDataHierarchy
    const isArgListDataHierarchy = (targetRowValues as listDataHierarchy)[0] !== undefined;
 
    if (isArgListDataHierarchy) {
      [thisRow, parentRows] = this.getHierarchy(targetRowValues[0], targetRowValues[1]);
    } else {
      thisRow = targetRowValues as ColumnType;
      parentRows = parentValues;
    }
 
    return [thisRow, parentRows];
  }
 
  private async verifyRowExistHelper(checkExist: boolean, columns: ColumnType, parentValues?: ColumnType[]): Promise<void> {
    const columnToValueMaps: ColumnIDValueMap[] = this.getColumnIDValueMapFromColumnType(columns);
 
    // If parent defined (hierarchical list), get the parent(s) mapping format that e2elib List recogizes
    let parents!: ColumnIDValueMap[][];
    if (parentValues) {
      // Can define to get row based on more than 1 parent row
      parents = [];
      for (const parentValue of parentValues) {
        const parentRow: ColumnIDValueMap[] = this.getColumnIDValueMapFromColumnType(parentValue);
        parents.push(parentRow);
      }
    }
 
    const hasRow = await this.hasRow(columnToValueMaps, parents);
    const errorMsgParentRows = !hasRow && parentValues ? this.getFormattedParentRows(parentValues) : ''; // If row not found, output parent row(s) if parent defined
    const errorMsgPrefix = checkExist ? 'Cannot find row for' : 'Expected not to find row for';
 
    expect(hasRow).toBe(checkExist, `${errorMsgPrefix} ${Object.entries(columns).join()}${errorMsgParentRows}.`); // Simple way to output the key-value pairs separated by comma (e.g Start,14-Jan-2021,Rate,0.567)
  }
 
  /**
   * Select rows and right click to assert if context menu is enabled/disabled.
   *
   * @param checkEnabled Boolean indicating whether context menu is expected to be enabled or not.
   * @param contextMenu Context menu name to verify.
   * @param rows One or most ListRow to select and right click.
   * @param errorMessage [Optional] Custom error when assert fails, else use default message.
   * @param expectedTooltip [Optional] Tooltip to assert.
   */
  private async verifyContextMenuEnabledHelper(checkEnabled: boolean, contextMenu: ContextMenuItemSOP, rows?: ListRow[], errorMessage?: string, expectedTooltip?: string): Promise<void> {
    const cmMenu = this.findCreateContextMenu(contextMenu.ContextMenu);
    try {
      if (rows) {
        await this.selectRows(rows);
        await rows[0].rightClick({ ctrl: true }, cmMenu);
      } else {
        // If no row provided, right click on list
        await this.rightClickOnWhiteSpace();
      }
 
      // After select and right click, need let context menu enabed/disabled properly
      await this.waitForScreenUpdate(Timeout.ButtonState);
 
      const failMessageSuffix = checkEnabled ? 'enabled' : 'disabled';
      const failMessage = errorMessage ? errorMessage : `Expected context menu ${contextMenu} to be ${failMessageSuffix}.`;
      // Only query tooltip if an expected tooltip defined to prevent error tooltip not visible thrown
      const [isEnabled, disabledTooltip] = await ContextMenuSOP.verifyIsMenuItemClickable(cmMenu!, contextMenu.Name, expectedTooltip !== undefined);
      expect(isEnabled).toBe(checkEnabled, failMessage);
 
      // If defined expected tooltip and currently menu disabled, to assert the tooltip matches
      if (expectedTooltip && !isEnabled) {
        expect(disabledTooltip).toBe(expectedTooltip, `Verify fail for menu '${contextMenu}' disabled tooltip.`);
      }
 
      await cmMenu!.dismiss(); // Dismiss context menu else subsequent actions will fail as menu blocks the list
    } catch (e) {
      const isMenuVisible = await cmMenu!.isVisible();
      if (isMenuVisible) {
        // Dismiss context menu else subsequent actions will fail as menu blocks the list
        await cmMenu!.dismiss();
      }
 
      throw e; // Re-throw error else it will silently disappear
    }
  }
 
  private async waitAndReturnDialog<T extends Form>(dlg: T): Promise<T> {
    await dlg.waitForScreenUpdate();
    return dlg;
  }
}
 
// Step description to re-use in spec file to prevent scriptor re-write each time
const stepList = {
  doubleClickRow: (listName: string, fields: any): string => `In list ${listName}, double click row ${ListSOP.getFieldsKeyValueAsString(fields)}.`,
  dropRowsOnTargetRow: (sourceListName: string, sourceRows: any[], targetRow: any, targetListName?: string): string => {
    const targetList = targetListName ? ` in target list ${targetListName}` : '';
    return `In list ${sourceListName}, drag row(s) ${ListSOP.getFieldsKeyValueArrayAsString(sourceRows)} onto row ${ListSOP.getFieldsKeyValueAsString(targetRow)}${targetList}.`;
  },
  dropRowOnWhiteSpace: (sourceListName: string, sourceRows: any[], targetListName: string): string => {
    return `From list "${sourceListName}", drag row(s) ${ListSOP.getFieldsKeyValueArrayAsString(sourceRows)} and drop onto whitespace in list "${targetListName}".`;
  },
  expandRowInList: (listName: string, row: string | any[]): string => {
    // If array, we will output "parent > child level 1 > child level 2 and so on...."
    const values = typeof row === 'string' ? row : ListSOP.getFieldsKeyValueArrayAsString(row, ' > ');
 
    return `In list "${listName}", click the small arrow to expand row "${values}".`;
  },
  focus: (listName: string): string => `Focus list "${listName}".`,
  focusListClickActionButton: (listName: string, actionButton: string): string => `Focus list "${listName}" and click action bar button ${actionButton}.`,
  rightClickSelectMenu: (listName: string, menuLabel: string): string => `In list "${listName}", right click select menu "${menuLabel}".`,
  selectAllRows: (listName: string): string => `In list ${listName}, select all rows.`,
  selectRow: (listName: string, fields: any): string => `In list ${listName}, select row ${ListSOP.getFieldsKeyValueAsString(fields)}.`,
  selectAllRowsAndClickMenu: (listName: string, menuLabel: string): string => `In list "${listName}", select all rows and right click select menu "${menuLabel}".`,
  selectRows: (listName: string, fields: any[]): string => {
    const fieldsArr: string[] = [];
    fields.forEach((x: any) => fieldsArr.push(ListSOP.getFieldsKeyValueAsString(x)));
 
    const result = `In list ${listName}, select rows ${fieldsArr.join(' || ')}.`;
    return result;
  },
  selectRowAndClickMenu: (listName: string, fields: any, menuLabel: string): string => `In list "${listName}", select row "${ListSOP.getFieldsKeyValueAsString(fields)}" and right click select menu "${menuLabel}".`,
  selectRowsAndClickMenu: (listName: string, fields: any[], menuLabel: string): string => `In list "${listName}", select rows "${ListSOP.getFieldsKeyValueArrayAsString(fields, ' | ')}" and right click select menu "${menuLabel}".`,
  selectRowNumAndClickActionButton: (listName: string, rowNum: number, actionButton: string): string => `In list "${listName}", select row number ${rowNum} and click action bar button ${actionButton}.`,
  selectRowNumAndClickMenu: (listName: string, rowNum: number, menuLabel: string): string => `In list ${listName}, select row number ${rowNum} and right click select menu "${menuLabel}".`,
  selectRowsByIndex: (listName: string, rowIndexes: number[]): string => `In list ${listName}, select rows with index ${rowIndexes.join(', ')}.`,
  toggleRowCheckbox: (listName: string, fields: any, expectedChecked: boolean): string => `In list ${listName}, toggle row checked = ${expectedChecked} for ${ListSOP.getFieldsKeyValueAsString(fields)}.`,
  verifyCheckBoxRowDecoration: (listName: string, rowName: string, expectedDecoration: DataRowCheckBoxDecoration): string => `In list ${listName}, verify that the checkbox's bottom right of current row = ${rowName} is ${expectedDecoration}.`,
  verifyCheckBoxRowNoDecoration: (listName: string, rowName: string): string => `In list ${listName}, verify that the checkbox's bottom right of current row = ${rowName} is not decorated.`,
  verifyChildrenRowsChecked: (listName: string, fields: string): string => `In list ${listName}, verify all children checkbox of parent row = ${fields} have been checked.`,
  verifyChildrenRowsUnchecked: (listName: string, fields: string): string => `In list ${listName}, verify all children checkbox of parent row = ${fields} have been unchecked.`,
  verifyContextMenuDisabled: (listName: string, menuLabel: string, expectedTooltip?: string): string => {
    const finalExpectedTooltip = expectedTooltip ? ` with precondition "${expectedTooltip}"` : '';
    return `In list ${listName}, verify menu '${menuLabel}' disabled${finalExpectedTooltip}.`;
  },
  verifyRowChecked: (listName: string, fields: any): string => `In list ${listName}, verify row ${ListSOP.getFieldsKeyValueAsString(fields)} is checked.`,
  verifyRowConstraintViolation: (listName: string, fields: any, expectedHasViolation: boolean): string => `In list ${listName}, verify constraint violation = ${expectedHasViolation} for row ${ListSOP.getFieldsKeyValueAsString(fields)}.`,
  // Some test will select row index without knowing specific, thus next step will just verify row deleted without explicit specify details.
  verifyRowDeleted: (listName: string): string => `In list ${listName}, verify row deleted.`,
  verifyRowExists: (listName: string, fields: any, parents?: any[]): string => `In list ${listName}, verify row exists for ${ListSOP.getFieldsKeyValueAsString(fields)} and parent = ${ListSOP.getFieldsKeyValueArrayAsString(parents)}.`,
  verifyRowNotExist: (listName: string, fields: any, parents?: any[]): string => `In list ${listName}, verify row not exist for ${ListSOP.getFieldsKeyValueAsString(fields)} and parent = ${ListSOP.getFieldsKeyValueArrayAsString(parents)}.`,
  verifyRowValues: (listName: string, fields: any, verifyValues: any): string => `In list ${listName}, verify row ${ListSOP.getFieldsKeyValueAsString(fields)} with columns value equal ${ListSOP.getFieldsKeyValueAsString(verifyValues)}.`,
  verifyRowValuesForRowNum: (listName: string, rowNum: number, fields: any): string => `In list ${listName}, verify row number ${rowNum} values equal ${ListSOP.getFieldsKeyValueAsString(fields)}.`,
  verifyTotalRow: (listName: string, expectedTotal: number): string => `In list ${listName}, verify total row = ${expectedTotal}.`,
  verifyRowHasOnDrawImage: (listName: string, fields: any, image: string): string => `In list ${listName}, verify row ${ListSOP.getFieldsKeyValueAsString(fields)} has on draw image "${image}".`,
};
 
export { stepList as StepList };