@datagrok/hit-triage 1.3.3 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@datagrok/hit-triage",
3
3
  "friendlyName": "HitTriage",
4
- "version": "1.3.3",
4
+ "version": "1.3.4",
5
5
  "author": {
6
6
  "name": "Davit Rizhinashvili",
7
7
  "email": "drizhinashvili@datagrok.ai"
package/src/app/consts.ts CHANGED
@@ -17,17 +17,17 @@ export const HTScriptPrefix = 'HTScript';
17
17
  export const HTQueryPrefix = 'HTQuery';
18
18
  export const ComputeQueryMolColName = 'molecules';
19
19
  export const i18n = {
20
- startNewCampaign: 'New campaign',
21
- createNewCampaign: 'New campaign',
20
+ startNewCampaign: 'New Campaign',
21
+ createNewCampaign: 'New Campaign',
22
22
  dataSourceFunction: 'Source',
23
- createNewTemplate: 'New template',
23
+ createNewTemplate: 'New Template',
24
24
  StartCampaign: 'Start',
25
25
  createTemplate: 'Create',
26
26
  createCampaign: 'Create',
27
27
  download: 'Download',
28
28
  cancel: 'Cancel',
29
- continueCampaigns: 'Continue a campaign',
30
- createNewCampaignHeader: 'New campaign',
29
+ continueCampaigns: 'Continue Campaign',
30
+ createNewCampaignHeader: 'New Campaign',
31
31
  selectTemplate: 'Template',
32
32
  } as const;
33
33
 
@@ -36,3 +36,11 @@ export const funcTypeNames = {
36
36
  function: 'function-package',
37
37
  query: 'data-query',
38
38
  } as const;
39
+
40
+ export const HDCampaignsGroupingLSKey = 'HDCampaignsGrouping';
41
+
42
+ export enum CampaignGroupingType {
43
+ None = 'None',
44
+ Template = 'Template',
45
+ Status = 'Status',
46
+ }
@@ -2,7 +2,7 @@
2
2
  import * as grok from 'datagrok-api/grok';
3
3
  import * as ui from 'datagrok-api/ui';
4
4
  import * as DG from 'datagrok-api/dg';
5
- import {AppName, HitDesignCampaign, HitDesignTemplate, HitTriageCampaignStatus, IFunctionArgs} from './types';
5
+ import {AppName, HitDesignCampaign, HitDesignTemplate, IFunctionArgs} from './types';
6
6
  import {HitDesignInfoView} from './hit-design-views/info-view';
7
7
  import {CampaignIdKey, CampaignJsonName, CampaignTableName,
8
8
  HTQueryPrefix, HTScriptPrefix, HitDesignCampaignIdKey,
@@ -36,6 +36,7 @@ export class HitDesignApp<T extends HitDesignTemplate = HitDesignTemplate> exten
36
36
  protected currentDesignViewId?: string;
37
37
  public mainView: DG.ViewBase;
38
38
  protected get version() {return this._campaign?.version ?? 0;};
39
+ public existingStatuses: string[] = [];
39
40
  constructor(c: DG.FuncCall, an: AppName = 'Hit Design',
40
41
  infoViewConstructor: (app: HitDesignApp) => HitDesignInfoView = (app) => new HitDesignInfoView(app)) {
41
42
  super(c, an);
@@ -112,7 +113,7 @@ export class HitDesignApp<T extends HitDesignTemplate = HitDesignTemplate> exten
112
113
 
113
114
  this.campaign.template.stages = uniqueStages;
114
115
  this.template.stages = uniqueStages;
115
- await this.saveCampaign(undefined, true);
116
+ await this.saveCampaign(true);
116
117
  }
117
118
 
118
119
  public async setTemplate(template: T, campaignId?: string) {
@@ -226,7 +227,7 @@ export class HitDesignApp<T extends HitDesignTemplate = HitDesignTemplate> exten
226
227
  this.dataFrame!.col(col.name)!.set(newValueIdx, col.get(0), false);
227
228
  }
228
229
  this.dataFrame!.fireValuesChanged();
229
- this.saveCampaign(undefined, false);
230
+ this.saveCampaign(false);
230
231
  }
231
232
 
232
233
  protected initDesignViewRibbons(view: DG.TableView, subs: Subscription[]) {
@@ -318,7 +319,7 @@ export class HitDesignApp<T extends HitDesignTemplate = HitDesignTemplate> exten
318
319
  this.dataFrame!.fireValuesChanged();
319
320
  } finally {
320
321
  ui.setUpdateIndicator(view.grid.root, false);
321
- this.saveCampaign(undefined, false);
322
+ this.saveCampaign(false);
322
323
  }
323
324
  }, () => null, this.campaign?.template!, true);
324
325
  };
@@ -328,7 +329,7 @@ export class HitDesignApp<T extends HitDesignTemplate = HitDesignTemplate> exten
328
329
  const permissionsButton = ui.iconFA('share', async () => {
329
330
  await (new PermissionsDialog(this.campaign?.permissions)).show((res) => {
330
331
  this.campaign!.permissions = res;
331
- this.saveCampaign(undefined, true);
332
+ this.saveCampaign(true);
332
333
  });
333
334
  }, 'Edit campaign permissions');
334
335
  const tilesButton = ui.bigButton('Progress tracker', () => {
@@ -340,8 +341,13 @@ export class HitDesignApp<T extends HitDesignTemplate = HitDesignTemplate> exten
340
341
  if (dialogContent) {
341
342
  const dlg = ui.dialog('Submit');
342
343
  dlg.add(dialogContent);
343
- dlg.addButton('Save', ()=>{this.saveCampaign(); dlg.close();});
344
- dlg.addButton('Submit', ()=>{this._submitView?.submit(); dlg.close();});
344
+ dlg.addButton('Save', () => {
345
+ this._campaign!.status = this._submitView!.getStatus();
346
+ this.saveCampaign();
347
+ dlg.close();
348
+ });
349
+ if (this.template?.submit?.fName && this.template?.submit?.package && DG.Func.find({name: this.template.submit.fName, package: this.template.submit.package})?.length > 0)
350
+ dlg.addButton('Submit', ()=>{this._submitView?.submit(); dlg.close();});
345
351
  dlg.show();
346
352
  }
347
353
  });
@@ -476,7 +482,7 @@ export class HitDesignApp<T extends HitDesignTemplate = HitDesignTemplate> exten
476
482
  view?.grid && subs.push(view.grid.onCellValueEdited.subscribe(async (gc) => {
477
483
  try {
478
484
  if (gc.tableColumn?.name === TileCategoriesColName) {
479
- await this.saveCampaign(undefined, false);
485
+ await this.saveCampaign(false);
480
486
  return;
481
487
  }
482
488
  if (gc.tableColumn?.name !== this.molColName)
@@ -610,7 +616,7 @@ export class HitDesignApp<T extends HitDesignTemplate = HitDesignTemplate> exten
610
616
 
611
617
  if (this._campaign)
612
618
  this._campaign!.savePath = this._filePath;
613
- await this.saveCampaign(undefined, true);
619
+ await this.saveCampaign(true);
614
620
  ui.empty(pathDiv);
615
621
  const folderPath = getFolderPath();
616
622
  link = ui.link(folderPath,
@@ -647,7 +653,7 @@ export class HitDesignApp<T extends HitDesignTemplate = HitDesignTemplate> exten
647
653
  };
648
654
  }
649
655
 
650
- async saveCampaign(status?: HitTriageCampaignStatus, notify = true): Promise<HitDesignCampaign> {
656
+ async saveCampaign(notify = true): Promise<HitDesignCampaign> {
651
657
  const campaignId = this.campaignId!;
652
658
  const templateName = this.template!.name;
653
659
  const enrichedDf = this.dataFrame!;
@@ -665,7 +671,7 @@ export class HitDesignApp<T extends HitDesignTemplate = HitDesignTemplate> exten
665
671
  const campaign: HitDesignCampaign = {
666
672
  name: campaignName,
667
673
  templateName,
668
- status: status ?? this.campaign?.status ?? 'In Progress',
674
+ status: this.campaign?.status ?? 'In Progress',
669
675
  createDate: this.campaign?.createDate ?? toFormatedDateString(new Date()),
670
676
  campaignFields: this.campaign?.campaignFields ?? this.campaignProps,
671
677
  columnSemTypes,
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-len */
1
2
  import * as grok from 'datagrok-api/grok';
2
3
  import * as ui from 'datagrok-api/ui';
3
4
  import * as DG from 'datagrok-api/dg';
@@ -5,10 +6,13 @@ import {u2} from '@datagrok-libraries/utils/src/u2';
5
6
  import {HitDesignApp} from '../hit-design-app';
6
7
  import {_package} from '../../package';
7
8
  import $ from 'cash-dom';
8
- import {CampaignJsonName, HitDesignCampaignIdKey, i18n} from '../consts';
9
+ import {CampaignGroupingType, CampaignJsonName, HitDesignCampaignIdKey, i18n} from '../consts';
9
10
  import {HitDesignCampaign, HitDesignTemplate} from '../types';
10
11
  import {addBreadCrumbsToRibbons, checkEditPermissions,
11
- checkViewPermissions, loadCampaigns, modifyUrl, popRibbonPannels} from '../utils';
12
+ checkViewPermissions, getGroupedCampaigns, getSavedCampaignsGrouping,
13
+ loadCampaigns, modifyUrl, popRibbonPannels,
14
+ processGroupingTable,
15
+ setSavedCampaignsGrouping} from '../utils';
12
16
  import {newHitDesignCampaignAccordeon} from '../accordeons/new-hit-design-campaign-accordeon';
13
17
  import {newHitDesignTemplateAccordeon} from '../accordeons/new-hit-design-template-accordeon';
14
18
  import {HitBaseView} from '../base-view';
@@ -17,6 +21,7 @@ import {defaultPermissions, PermissionsDialog} from '../dialogs/permissions-dial
17
21
  export class HitDesignInfoView
18
22
  <T extends HitDesignTemplate = HitDesignTemplate, K extends HitDesignApp = HitDesignApp>
19
23
  extends HitBaseView<T, K> {
24
+ currentSorting: string = 'None';
20
25
  constructor(app: K) {
21
26
  super(app);
22
27
  this.name = 'Hit Design';
@@ -48,6 +53,7 @@ export class HitDesignInfoView
48
53
  ui.setUpdateIndicator(this.root, true);
49
54
  try {
50
55
  const continueCampaignsHeader = ui.h1(i18n.continueCampaigns);
56
+
51
57
  const createNewCampaignHeader = ui.h1(i18n.createNewCampaignHeader, {style: {marginLeft: '10px'}});
52
58
  const appHeader = this.getAppHeader();
53
59
 
@@ -56,10 +62,38 @@ export class HitDesignInfoView
56
62
  const contentDiv = ui.div([templatesDiv, campaignAccordionDiv], 'ui-form');
57
63
 
58
64
  const campaignsTable = await this.getCampaignsTable();
65
+ const tableRoot = ui.div([campaignsTable], {style: {position: 'relative'}});
66
+
67
+ const sortIcon = ui.iconFA('sort', () => {
68
+ const menu = DG.Menu.popup();
69
+ Object.values(CampaignGroupingType).forEach((i) => {
70
+ menu.item(i, async () => {
71
+ setSavedCampaignsGrouping(i as CampaignGroupingType);
72
+ ui.setUpdateIndicator(tableRoot, true);
73
+ try {
74
+ const t = await this.getCampaignsTable();
75
+ ui.setUpdateIndicator(tableRoot, false);
76
+ ui.empty(tableRoot);
77
+ tableRoot.appendChild(t);
78
+ } catch (e) {
79
+ grok.shell.error('Failed to update campaigns table');
80
+ console.error(e);
81
+ } finally {
82
+ ui.setUpdateIndicator(tableRoot, false);
83
+ }
84
+ });
85
+ menu.show({element: sortingHeader, x: 100, y: sortingHeader.offsetTop + 30});
86
+ });
87
+ });
88
+ sortIcon.style.marginBottom = '12px';
89
+ sortIcon.style.marginLeft = '5px';
90
+ sortIcon.style.fontSize = '15px';
91
+ ui.tooltip.bind(sortIcon, () => `Group Campaigns. Current: ${this.currentSorting}`);
92
+ const sortingHeader = ui.divH([continueCampaignsHeader, sortIcon], {style: {alignItems: 'center'}});
59
93
  $(this.root).empty();
60
94
  this.root.appendChild(ui.div([
61
- ui.divV([appHeader, continueCampaignsHeader], {style: {marginLeft: '10px'}}),
62
- campaignsTable,
95
+ ui.divV([appHeader, sortingHeader], {style: {marginLeft: '10px'}}),
96
+ tableRoot,
63
97
  createNewCampaignHeader,
64
98
  contentDiv,
65
99
  ]));
@@ -178,7 +212,10 @@ export class HitDesignInfoView
178
212
 
179
213
  private async getCampaignsTable() {
180
214
  const campaignNamesMap = await loadCampaigns(this.app.appName, this.deletedCampaigns);
181
-
215
+ const grouppingMode = getSavedCampaignsGrouping();
216
+ const grouppedCampaigns = getGroupedCampaigns<HitDesignCampaign>(Object.values(campaignNamesMap), grouppingMode);
217
+ this.currentSorting = grouppingMode;
218
+ this.app.existingStatuses = Array.from(new Set(Object.values(campaignNamesMap).map((c) => c.status).filter((s) => !!s)));
182
219
  const deleteAndShareCampaignIcons = (info: HitDesignCampaign) => {
183
220
  const deleteIcon = ui.icons.delete(async () => {
184
221
  ui.dialog('Delete campaign')
@@ -226,6 +263,7 @@ export class HitDesignInfoView
226
263
  ['Name', 'Created', 'Molecules', 'Status', '']);
227
264
  table.style.color = 'var(--grey-5)';
228
265
  table.style.marginLeft = '24px';
266
+ processGroupingTable(table, grouppedCampaigns);
229
267
  return table;
230
268
  }
231
269
 
@@ -239,7 +277,7 @@ export class HitDesignInfoView
239
277
  this.app.dataFrame = camp.df;
240
278
  await this.app.setTemplate(template);
241
279
  this.app.campaignProps = camp.campaignProps;
242
- await this.app.saveCampaign(undefined, false);
280
+ await this.app.saveCampaign(false);
243
281
  if (template.layoutViewState && this.app.campaign)
244
282
  this.app.campaign.layout = template.layoutViewState;
245
283
  });
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-len */
1
2
  import * as ui from 'datagrok-api/ui';
2
3
  import * as grok from 'datagrok-api/grok';
3
4
  import * as DG from 'datagrok-api/dg';
@@ -7,19 +8,54 @@ import {HitDesignTemplate} from '../types';
7
8
  import {HitBaseView} from '../base-view';
8
9
 
9
10
  export class HitDesignSubmitView extends HitBaseView<HitDesignTemplate, HitDesignApp> {
11
+ private statusInput: DG.InputBase<string | undefined>;
12
+ private statusSuggestionsMenu: DG.Menu;
13
+ content: HTMLDivElement;
10
14
  constructor(app: HitDesignApp) {
11
15
  super(app);
12
16
  this.name = 'Submit';
17
+ this.statusInput = ui.input.string('Status', {value: this.app.campaign?.status, nullable: false});
18
+ this.statusSuggestionsMenu = DG.Menu.popup();
19
+ this.statusInput.root.style.marginLeft = '12px';
20
+ this.content = ui.div();
21
+
22
+ this.statusInput.onChanged.subscribe(() => {
23
+ this.statusSuggestionsMenu.clear();
24
+ const status = (this.statusInput.value ?? '').toLowerCase();
25
+ // eslint-disable-next-line max-len
26
+ const similarStatuses = this.app.existingStatuses.filter((s) => s?.toLowerCase()?.includes(status) && s?.toLowerCase() !== status).filter((_, i) => i < 5);
27
+ if (similarStatuses.length) {
28
+ similarStatuses.forEach((s) => {
29
+ this.statusSuggestionsMenu.item(s, () => {
30
+ this.statusInput.value = s;
31
+ this.statusSuggestionsMenu.root.remove();
32
+ this.statusInput.root.focus();
33
+ });
34
+ });
35
+ if (this.content.parentElement?.parentElement) {
36
+ const xOffset = this.statusInput.root.offsetLeft + (this.statusInput.input?.offsetLeft ?? 0);
37
+ const yOffset = this.statusInput.root.offsetTop + this.statusInput.root.offsetHeight + this.content.parentElement.offsetTop;
38
+ this.statusSuggestionsMenu.show({element: this.content.parentElement.parentElement!, x: xOffset, y: yOffset});
39
+ }
40
+ } else
41
+ this.statusSuggestionsMenu.root.remove();
42
+ });
43
+ }
44
+
45
+ public getStatus() {
46
+ return this.statusInput.value ?? this.app.campaign?.status ?? 'No Status';
13
47
  }
14
48
 
15
49
  render(): HTMLDivElement {
16
- ui.empty(this.root);
50
+ this.statusInput.value = this.app.campaign?.status ?? '';
51
+ ui.empty(this.content);
17
52
 
18
- const content = ui.divV([
53
+ this.content = ui.divV([
19
54
  ui.h1('Summary'),
20
55
  ui.div([ui.tableFromMap(this.app.getSummary())]),
56
+ this.statusInput.root,
21
57
  ]);
22
- return content;
58
+ return this.content;
23
59
  }
24
60
 
25
61
  onActivated(): void {
@@ -37,8 +73,8 @@ export class HitDesignSubmitView extends HitBaseView<HitDesignTemplate, HitDesig
37
73
  }
38
74
  const filteredDf = DG.DataFrame.fromCsv(this.app.dataFrame!.toCsv({filteredRowsOnly: true}));
39
75
  await submitFn.apply({df: filteredDf, molecules: this.app.molColName});
40
- this.app.campaign && (this.app.campaign.status = 'Submitted');
41
- this.app.saveCampaign('Submitted');
76
+ this.app.campaign!.status = this.getStatus();
77
+ this.app.saveCampaign();
42
78
  grok.shell.info('Submitted successfully.');
43
79
  }
44
80
  }
@@ -4,4 +4,8 @@ img[alt='hitDesignReadmeImg'] {
4
4
 
5
5
  .hit-design-stage-editing-dialog {
6
6
  z-index: 10000 !important;
7
+ }
8
+
9
+ .hit-design-groupped-campaigns-table .d4-accordion-pane-content.expanded {
10
+ margin: 0 !important;
7
11
  }
@@ -154,7 +154,7 @@ export class PeptiHitApp extends HitDesignApp<PeptiHitTemplate> {
154
154
  view?.grid && subs.push(view.grid.onCellValueEdited.subscribe(async (gc) => {
155
155
  try {
156
156
  if (gc.tableColumn?.name === TileCategoriesColName) {
157
- await this.saveCampaign(undefined, false);
157
+ await this.saveCampaign(false);
158
158
  return;
159
159
  }
160
160
  if (gc.tableColumn?.name !== this.helmColName)
@@ -22,7 +22,7 @@ export class PeptiHitInfoView extends HitDesignInfoView<PeptiHitTemplate, PeptiH
22
22
  this.app.dataFrame = camp.df;
23
23
  await this.app.setTemplate(template);
24
24
  this.app.campaignProps = camp.campaignProps;
25
- await this.app.saveCampaign(undefined, false);
25
+ await this.app.saveCampaign(false);
26
26
  if (template.layoutViewState && this.app.campaign)
27
27
  this.app.campaign.layout = template.layoutViewState;
28
28
  });
package/src/app/types.ts CHANGED
@@ -100,7 +100,7 @@ export type HitTriageTemplateSubmit = {
100
100
  package: string
101
101
  };
102
102
 
103
- export type HitTriageCampaignStatus = 'In Progress' | 'Submitted';
103
+ export type HitTriageCampaignStatus = string;
104
104
 
105
105
  export type HitTriageCampaign = {
106
106
  name: string,
package/src/app/utils.ts CHANGED
@@ -1,9 +1,10 @@
1
+ /* eslint-disable max-len */
1
2
  import * as grok from 'datagrok-api/grok';
2
3
  import * as ui from 'datagrok-api/ui';
3
4
  import * as DG from 'datagrok-api/dg';
4
5
  import {Subscription} from 'rxjs';
5
- import {CampaignJsonName, ComputeQueryMolColName} from './consts';
6
- import {AppName, CampaignsType, TriagePermissions} from './types';
6
+ import {CampaignGroupingType, CampaignJsonName, ComputeQueryMolColName, HDCampaignsGroupingLSKey} from './consts';
7
+ import {AppName, CampaignsType, HitDesignCampaign, HitTriageCampaign, TriagePermissions} from './types';
7
8
  import {_package} from '../package';
8
9
 
9
10
  export const toFormatedDateString = (d: Date): string => {
@@ -164,3 +165,82 @@ export async function checkEditPermissions(authorId: string, permissions: Triage
164
165
  export async function checkViewPermissions(authorId: string, permissions: TriagePermissions): Promise<boolean> {
165
166
  return checkPermissions(authorId, Array.from(new Set([...permissions.view, ...permissions.edit])));
166
167
  }
168
+
169
+ export function getLocalStorageValue<T = string>(key: string): T | null {
170
+ return localStorage.getItem(key) as unknown as T;
171
+ }
172
+
173
+ export function setLocalStorageValue(key: string, value: string) {
174
+ localStorage.setItem(key, value as unknown as string);
175
+ }
176
+
177
+ export function getSavedCampaignsGrouping(): CampaignGroupingType {
178
+ return getLocalStorageValue<CampaignGroupingType>(HDCampaignsGroupingLSKey) ?? CampaignGroupingType.None;
179
+ }
180
+
181
+ export function setSavedCampaignsGrouping(value: CampaignGroupingType) {
182
+ setLocalStorageValue(HDCampaignsGroupingLSKey, value);
183
+ }
184
+
185
+ export const getGroupingKey = <T extends HitDesignCampaign | HitTriageCampaign = HitDesignCampaign>(grouping: CampaignGroupingType, campaign: T): string => {
186
+ switch (grouping) {
187
+ case CampaignGroupingType.Template:
188
+ return campaign.template?.key ?? campaign.templateName;
189
+ case CampaignGroupingType.Status:
190
+ return campaign.status;
191
+ default:
192
+ return '';
193
+ }
194
+ };
195
+
196
+ export function getGroupedCampaigns<T extends HitDesignCampaign | HitTriageCampaign = HitDesignCampaign>(campaigns: T[], grouping: CampaignGroupingType):
197
+ {[key: string]: T[]} {
198
+ if (grouping === CampaignGroupingType.None)
199
+ return {'': campaigns};
200
+ const groupedCampaigns: {[key: string]: T[]} = {};
201
+ for (const campaign of campaigns) {
202
+ const key = getGroupingKey(grouping, campaign);
203
+ if (!groupedCampaigns[key])
204
+ groupedCampaigns[key] = [];
205
+ groupedCampaigns[key].push(campaign);
206
+ }
207
+ return groupedCampaigns;
208
+ }
209
+
210
+ export function processGroupingTable<T extends HitDesignCampaign | HitTriageCampaign = HitDesignCampaign>(table: HTMLTableElement, groupedCampaigns: {[key: string]: T[]}, numCols = 6) {
211
+ table.classList.add('hit-design-groupped-campaigns-table');
212
+ const keys = Object.keys(groupedCampaigns);
213
+ if (keys.length < 2)
214
+ return table;
215
+ const body = table.getElementsByTagName('tbody')[0];
216
+ const rows = Array.from(table.getElementsByTagName('tr')).filter((row) => !row.classList.contains('header'));
217
+ let curRow = 0;
218
+
219
+ const setState = (expanded: boolean, start: number, end: number) => {
220
+ for (let i = start; i < end; i++)
221
+ rows[i]?.style && (rows[i].style.display = expanded ? 'table-row' : 'none');
222
+ };
223
+
224
+
225
+ for (const key of keys) {
226
+ const row = rows[curRow];
227
+ if (!row)
228
+ break;
229
+ const l = groupedCampaigns[key].length;
230
+ const startRow = curRow;
231
+ const endRow = curRow + l;
232
+ const acc = ui.accordion(`Hit-Design-campaigns-group-${key}`);
233
+ const pane = acc.addPane(key, () => {return ui.div();}, undefined, undefined, false);
234
+ pane.root.style.marginLeft = '-24px';
235
+ pane.root.onclick = () => {
236
+ setState(pane.expanded, startRow, endRow);
237
+ };
238
+ setState(pane.expanded, startRow, endRow);
239
+ const newRow = body.insertRow(0);
240
+ const newCell = newRow.insertCell(0);
241
+ newCell.appendChild(pane.root);
242
+ newCell.colSpan = numCols;
243
+ body.insertBefore(newRow, row);
244
+ curRow += l;
245
+ }
246
+ }