juxscript 1.1.187 → 1.1.188

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.
@@ -4,9 +4,8 @@ import { TabularDriver } from '../storage/TabularDriver.js';
4
4
  import { FileUpload } from './fileupload.js';
5
5
  import { Table } from './table.js';
6
6
  import { Tabs } from './tabs.js';
7
- import { Modal } from './modal.js'; // ✅ Import Modal
7
+ import { Modal } from './modal.js';
8
8
  import { renderIcon } from './icons.js';
9
- import { button } from './button.js'; // ✅ Import button factory
10
9
 
11
10
  const TRIGGER_EVENTS = [] as const;
12
11
  const CALLBACK_EVENTS = ['load', 'error', 'transform'] as const;
@@ -22,10 +21,10 @@ export interface DataFrameOptions {
22
21
  rowsPerPage?: number;
23
22
  showStatus?: boolean;
24
23
  icon?: string;
25
- maxSheetSize?: number; // ✅ NEW: Max rows per sheet (default: 100k)
26
- sheetChunkSize?: number; // ✅ NEW: Chunk size for large sheets (default: 10k)
27
- maxFileSize?: number; // ✅ NEW: Max file size in MB (default: 50MB)
28
- showReshapeWarning?: boolean; // ✅ NEW: Show warning when data looks malformed
24
+ maxSheetSize?: number;
25
+ sheetChunkSize?: number;
26
+ maxFileSize?: number;
27
+ showReshapeWarning?: boolean;
29
28
  style?: string;
30
29
  class?: string;
31
30
  }
@@ -41,8 +40,8 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
41
40
  private _df: DataFrame | null = null;
42
41
  private _driver: TabularDriver;
43
42
  private _table: Table | null = null;
44
- private _tabs: Tabs | null = null; // ✅ NEW: Tabs for multi-sheet Excel // @ts-ignore used for multi-sheet
45
- private _sheets: Map<string, DataFrame> = new Map(); // ✅ NEW: Store all sheets
43
+ private _tabs: Tabs | null = null;
44
+ private _sheets: Map<string, DataFrame> = new Map();
46
45
  private _tableOptions: {
47
46
  striped: boolean;
48
47
  hoverable: boolean;
@@ -51,19 +50,19 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
51
50
  paginated: boolean;
52
51
  rowsPerPage: number;
53
52
  };
54
- private _uploadRef: FileUpload | null = null; // @ts-ignore used for reference tracking
53
+ private _uploadRef: FileUpload | null = null;
55
54
  private _storageKey: string | null = null;
56
55
  private _pendingSource: (() => Promise<void>) | null = null;
57
56
  private _inlineUpload: { label: string; accept: string; icon: string } | null = null;
58
57
  private _showStatus: boolean = true;
59
58
  private _icon: string = '';
60
- private _maxSheetSize: number = 100000; // ✅ Default 100k rows
61
- private _sheetChunkSize: number = 10000; // ✅ Default 10k chunk
62
- private _maxFileSize: number = 50; // ✅ Default 50MB
59
+ private _maxSheetSize: number = 100000;
60
+ private _sheetChunkSize: number = 10000;
61
+ private _maxFileSize: number = 50;
63
62
  private _showReshapeWarning: boolean = true;
64
63
  private _rawFileData: { file: File; text?: string; isExcel?: boolean } | null = null;
65
- private _reshapeModal: Modal | null = null; // ✅ ADD THIS LINE
66
- private _reshapeModalRendered: boolean = false; // Track if modal already rendered
64
+ private _reshapeModal: Modal | null = null;
65
+ private _reshapeModalRendered: boolean = false;
67
66
 
68
67
  constructor(id: string, options: DataFrameOptions = {}) {
69
68
  super(id, {
@@ -111,7 +110,7 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
111
110
  this._storageKey = key;
112
111
  const loadFn = async () => {
113
112
  this.state.loading = true;
114
- this._updateStatus('Loading...', 'loading');
113
+ this._updateStatus('Loading...', 'loading');
115
114
  try {
116
115
  const df = await this._driver.loadByName(key);
117
116
  if (!df) {
@@ -124,7 +123,7 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
124
123
  } catch (err: any) {
125
124
  this._triggerCallback('error', err.message, null, this);
126
125
  this.state.loading = false;
127
- this._updateStatus('❌ ' + err.message, 'error');
126
+ this._updateStatus(err.message, 'error');
128
127
  }
129
128
  };
130
129
  if (this._table) { loadFn(); } else { this._pendingSource = loadFn; }
@@ -136,63 +135,7 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
136
135
  this._pendingSource = async () => {
137
136
  upload.bind('change', async (files: File[]) => {
138
137
  if (!files || files.length === 0) return;
139
- const file = files[0];
140
-
141
- // ✅ Check file size
142
- const fileSizeMB = file.size / (1024 * 1024);
143
- if (fileSizeMB > this._maxFileSize) {
144
- this._updateStatus(`❌ File too large (${fileSizeMB.toFixed(1)}MB). Max: ${this._maxFileSize}MB`, 'error');
145
- return;
146
- }
147
-
148
- this.state.loading = true;
149
- this._updateStatus('⏳ Parsing ' + file.name + '...', 'loading');
150
-
151
- try {
152
- const isExcel = file.name.toLowerCase().endsWith('.xlsx') ||
153
- file.name.toLowerCase().endsWith('.xls');
154
-
155
- if (isExcel) {
156
- // ✅ Store raw file for reshape
157
- this._rawFileData = { file, isExcel: true };
158
-
159
- // ✅ Pass chunking options for large files
160
- const sheets = await this._driver.streamFileMultiSheet(file, {
161
- maxSheetSize: this._maxSheetSize,
162
- sheetChunkSize: this._sheetChunkSize,
163
- onProgress: (loaded, total) => {
164
- const pct = total ? Math.round((loaded / total) * 100) : 0;
165
- this._updateStatus(`⏳ Parsing ${file.name}... ${pct}%`, 'loading');
166
- }
167
- });
168
-
169
- const sheetNames = Object.keys(sheets);
170
-
171
- await this._driver.store(file.name, sheets[sheetNames[0]], { source: file.name });
172
-
173
- if (sheetNames.length > 1) {
174
- this._renderMultiSheet(sheets, file.name);
175
- } else {
176
- this._setDataFrame(sheets[sheetNames[0]], file.name);
177
- }
178
- } else {
179
- // ✅ CSV/TSV: Store raw text for reshaping
180
- const text = await file.text();
181
- this._rawFileData = { file, text, isExcel: false };
182
-
183
- const df = this._driver.parseCSV(text, {
184
- autoDetectDelimiter: true,
185
- hasHeader: true
186
- });
187
-
188
- await this._driver.store(file.name, df, { source: file.name });
189
- this._setDataFrame(df, file.name);
190
- }
191
- } catch (err: any) {
192
- this._triggerCallback('error', err.message, null, this);
193
- this.state.loading = false;
194
- this._updateStatus('❌ ' + err.message, 'error');
195
- }
138
+ await this._handleFile(files[0]);
196
139
  });
197
140
  };
198
141
  return this;
@@ -201,14 +144,14 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
201
144
  fromData(data: Record<string, any>[] | Record<string, any[]>): this {
202
145
  const loadFn = async () => {
203
146
  this.state.loading = true;
204
- this._updateStatus('Loading data...', 'loading');
147
+ this._updateStatus('Loading data...', 'loading');
205
148
  try {
206
149
  const df = new DataFrame(data);
207
150
  this._setDataFrame(df, 'inline data');
208
151
  } catch (err: any) {
209
152
  this._triggerCallback('error', err.message, null, this);
210
153
  this.state.loading = false;
211
- this._updateStatus('❌ ' + err.message, 'error');
154
+ this._updateStatus(err.message, 'error');
212
155
  }
213
156
  };
214
157
  if (this._table) { loadFn(); } else { this._pendingSource = loadFn; }
@@ -296,38 +239,71 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
296
239
  filterable(v: boolean): this { this._tableOptions.filterable = v; return this; }
297
240
  paginated(v: boolean): this { this._tableOptions.paginated = v; return this; }
298
241
  rowsPerPage(v: number): this { this._tableOptions.rowsPerPage = v; return this; }
242
+ maxSheetSize(v: number): this { this._maxSheetSize = v; return this; }
243
+ sheetChunkSize(v: number): this { this._sheetChunkSize = v; return this; }
244
+ maxFileSize(mb: number): this { this._maxFileSize = mb; return this; }
299
245
 
300
- /**
301
- * NEW: Set max rows per sheet (prevents memory issues with huge Excel files)
302
- */
303
- maxSheetSize(v: number): this {
304
- this._maxSheetSize = v;
305
- return this;
306
- }
246
+ /* ═══════════════════════════════════════════════════
247
+ * FILE HANDLING (shared between fromUpload & withUpload)
248
+ * ═══════════════════════════════════════════════════ */
307
249
 
308
- /**
309
- * NEW: Set chunk size for processing large sheets
310
- */
311
- sheetChunkSize(v: number): this {
312
- this._sheetChunkSize = v;
313
- return this;
314
- }
250
+ private async _handleFile(file: File): Promise<void> {
251
+ const fileSizeMB = file.size / (1024 * 1024);
252
+ if (fileSizeMB > this._maxFileSize) {
253
+ this._updateStatus(`File too large (${fileSizeMB.toFixed(1)}MB). Max: ${this._maxFileSize}MB`, 'error');
254
+ return;
255
+ }
315
256
 
316
- /**
317
- * NEW: Set max file size in MB
318
- */
319
- maxFileSize(mb: number): this {
320
- this._maxFileSize = mb;
321
- return this;
257
+ this.state.loading = true;
258
+ this._updateStatus('Parsing ' + file.name + '...', 'loading');
259
+
260
+ try {
261
+ const isExcel = file.name.toLowerCase().endsWith('.xlsx') ||
262
+ file.name.toLowerCase().endsWith('.xls');
263
+
264
+ if (isExcel) {
265
+ this._rawFileData = { file, isExcel: true };
266
+
267
+ const sheets = await this._driver.streamFileMultiSheet(file, {
268
+ maxSheetSize: this._maxSheetSize,
269
+ sheetChunkSize: this._sheetChunkSize,
270
+ onProgress: (loaded, total) => {
271
+ const pct = total ? Math.round((loaded / total) * 100) : 0;
272
+ this._updateStatus(`Parsing ${file.name}... ${pct}%`, 'loading');
273
+ }
274
+ });
275
+
276
+ const sheetNames = Object.keys(sheets);
277
+ await this._driver.store(file.name, sheets[sheetNames[0]], { source: file.name });
278
+
279
+ if (sheetNames.length > 1) {
280
+ this._renderMultiSheet(sheets, file.name);
281
+ } else {
282
+ this._setDataFrame(sheets[sheetNames[0]], file.name);
283
+ }
284
+ } else {
285
+ const text = await file.text();
286
+ this._rawFileData = { file, text, isExcel: false };
287
+
288
+ const df = this._driver.parseCSV(text, {
289
+ autoDetectDelimiter: true,
290
+ hasHeader: true
291
+ });
292
+
293
+ await this._driver.store(file.name, df, { source: file.name });
294
+ this._setDataFrame(df, file.name);
295
+ }
296
+ } catch (err: any) {
297
+ this._triggerCallback('error', err.message, null, this);
298
+ this.state.loading = false;
299
+ this._updateStatus('Error: ' + err.message, 'error');
300
+ }
322
301
  }
323
302
 
324
303
  /* ═══════════════════════════════════════════════════
325
304
  * MULTI-SHEET RENDERING
326
305
  * ═══════════════════════════════════════════════════ */
327
306
 
328
- /**
329
- * ✅ FIXED: Render multiple Excel sheets as tabs
330
- */
331
307
  private _renderMultiSheet(sheets: Record<string, DataFrame>, sourceName: string): void {
332
308
  this.state.loading = false;
333
309
  this._sheets.clear();
@@ -335,42 +311,35 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
335
311
  const wrapper = document.getElementById(this._id);
336
312
  if (!wrapper) return;
337
313
 
338
- // Clear existing table if any
339
314
  const existingTable = wrapper.querySelector('.jux-table-wrapper');
340
315
  if (existingTable) existingTable.remove();
341
316
 
342
- // Store all sheets
343
317
  Object.entries(sheets).forEach(([name, df]) => {
344
318
  this._sheets.set(name, df);
345
319
  });
346
320
 
347
321
  const sheetNames = Object.keys(sheets);
348
322
 
349
- // ✅ FIX: Create tabs with EMPTY content first (just placeholders)
350
323
  const tabs = new Tabs(`${this._id}-tabs`, {
351
324
  tabs: sheetNames.map(name => ({
352
325
  id: name,
353
326
  label: name,
354
- content: '' // Empty - we'll fill after render
327
+ content: ''
355
328
  })),
356
329
  activeTab: sheetNames[0]
357
330
  });
358
331
 
359
332
  this._tabs = tabs;
360
333
 
361
- // Render tabs container
362
334
  const tabsContainer = document.createElement('div');
363
335
  tabsContainer.className = 'jux-dataframe-tabs';
364
336
  wrapper.appendChild(tabsContainer);
365
337
 
366
- // ✅ Render tabs NOW (creates all tab panels in DOM)
367
338
  tabs.render(tabsContainer);
368
339
 
369
- // ✅ NOW populate each tab with its DataFrame table (panels exist now)
370
340
  sheetNames.forEach(sheetName => {
371
341
  const df = sheets[sheetName];
372
342
 
373
- // Create table
374
343
  const table = new Table(`${this._id}-table-${sheetName}`, {
375
344
  striped: this._tableOptions.striped,
376
345
  hoverable: this._tableOptions.hoverable,
@@ -380,25 +349,14 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
380
349
  rowsPerPage: this._tableOptions.rowsPerPage
381
350
  });
382
351
 
383
- // Convert columns to ColumnDef[]
384
- const columnDefs = df.columns.map(col => ({
385
- key: col,
386
- label: col
387
- }));
388
-
352
+ const columnDefs = df.columns.map(col => ({ key: col, label: col }));
389
353
  table.columns(columnDefs).rows(df.toRows());
390
354
 
391
- // ✅ Find the tab panel (it exists now because tabs.render() was called)
392
355
  const tabPanel = document.getElementById(`${this._id}-tabs-${sheetName}-panel`);
393
- if (!tabPanel) {
394
- console.error(`Tab panel not found: ${this._id}-tabs-${sheetName}-panel`);
395
- return;
396
- }
356
+ if (!tabPanel) return;
397
357
 
398
- // ✅ Render table directly into tab panel
399
358
  table.render(tabPanel);
400
359
 
401
- // Add filter if enabled
402
360
  if (this._tableOptions.filterable) {
403
361
  const filterContainer = document.createElement('div');
404
362
  filterContainer.className = 'jux-dataframe-filter';
@@ -421,19 +379,15 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
421
379
 
422
380
  input.addEventListener('input', () => {
423
381
  const text = input.value.toLowerCase();
424
- if (!text) {
425
- table.rows(df.toRows());
426
- return;
427
- }
428
- const filtered = df.filter((row) => {
429
- return Object.values(row).some(v =>
382
+ if (!text) { table.rows(df.toRows()); return; }
383
+ const filtered = df.filter((row) =>
384
+ Object.values(row).some(v =>
430
385
  v !== null && v !== undefined && String(v).toLowerCase().includes(text)
431
- );
432
- });
386
+ )
387
+ );
433
388
  table.rows(filtered.toRows());
434
389
  });
435
390
 
436
- // ✅ Insert filter BEFORE the table wrapper (which exists now)
437
391
  const tableWrapper = tabPanel.querySelector('.jux-table-wrapper');
438
392
  if (tableWrapper) {
439
393
  tabPanel.insertBefore(filterContainer, tableWrapper);
@@ -441,16 +395,13 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
441
395
  }
442
396
  });
443
397
 
444
- // Update status
445
398
  const totalRows = Object.values(sheets).reduce((sum, df) => sum + df.height, 0);
446
399
  this._updateStatus(
447
400
  `${sourceName} — ${sheetNames.length} sheets, ${totalRows} total rows`,
448
401
  'success'
449
402
  );
450
403
 
451
- // Set first sheet as active DataFrame
452
404
  this._df = sheets[sheetNames[0]];
453
-
454
405
  this._triggerCallback('load', this._df, null, this);
455
406
  }
456
407
 
@@ -494,31 +445,24 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
494
445
  this._df = df.select(...cleanCols);
495
446
  }
496
447
 
497
- // Update the table with new data
498
448
  if (this._table && this._df) {
499
- const columnDefs = this._df.columns.map(col => ({
500
- key: col,
501
- label: col
502
- }));
449
+ const columnDefs = this._df.columns.map(col => ({ key: col, label: col }));
503
450
  this._table.columns(columnDefs).rows(this._df.toRows());
504
451
  }
505
452
 
506
- // ✅ Detect malformed data
507
453
  const isMalformed = this._detectMalformedData(this._df!);
508
454
 
509
- // ✅ Show warning if malformed
510
455
  if (isMalformed && this._showReshapeWarning && this._rawFileData) {
511
456
  this._updateStatus(
512
- `⚠️ ${sourceName} — ${this._df!.height} rows × ${this._df!.width} cols (Data may be malformed — headers may be on wrong row)`,
457
+ `${sourceName} — ${this._df!.height} rows × ${this._df!.width} cols (Data may be malformed — headers may be on wrong row)`,
513
458
  'warning'
514
459
  );
515
460
 
516
- // Add Fix Import Settings button after a tick to ensure status DOM is ready
517
461
  requestAnimationFrame(() => {
518
462
  const statusEl = document.getElementById(`${this._id}-status`);
519
463
  if (statusEl) {
520
464
  const settingsBtn = document.createElement('button');
521
- settingsBtn.textContent = '⚙️ Fix Import Settings';
465
+ settingsBtn.textContent = 'Fix Import Settings';
522
466
  settingsBtn.className = 'jux-button jux-button-sm jux-button-warning';
523
467
  settingsBtn.style.marginLeft = '0.5rem';
524
468
  settingsBtn.addEventListener('click', () => this._showReshapeModal());
@@ -531,13 +475,12 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
531
475
  'success'
532
476
  );
533
477
 
534
- // ✅ Still add Settings button for manual adjustment
535
478
  if (this._showReshapeWarning && this._rawFileData) {
536
479
  requestAnimationFrame(() => {
537
480
  const statusEl = document.getElementById(`${this._id}-status`);
538
481
  if (statusEl) {
539
482
  const settingsBtn = document.createElement('button');
540
- settingsBtn.textContent = '⚙️ Settings';
483
+ settingsBtn.textContent = 'Settings';
541
484
  settingsBtn.className = 'jux-button jux-button-sm jux-button-ghost';
542
485
  settingsBtn.style.marginLeft = '0.5rem';
543
486
  settingsBtn.addEventListener('click', () => this._showReshapeModal());
@@ -550,88 +493,93 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
550
493
  this._triggerCallback('load', this._df, null, this);
551
494
  }
552
495
 
553
- /**
554
- * NEW: Detect if data looks malformed
555
- */
496
+ /* ═══════════════════════════════════════════════════
497
+ * MALFORMED DATA DETECTION
498
+ * ═══════════════════════════════════════════════════ */
499
+
556
500
  private _detectMalformedData(df: DataFrame): boolean {
557
501
  const columns = df.columns;
558
502
  const rows = df.toRows();
559
503
 
560
- // Check 1: Columns have generic names like "__EMPTY", "_1", "col_0"
561
504
  const hasGenericColumns = columns.some(col =>
562
505
  col.startsWith('__EMPTY') ||
563
- col.startsWith('_') ||
506
+ col.match(/^_\d+$/) ||
564
507
  col.match(/^col_\d+$/)
565
508
  );
566
-
567
509
  if (hasGenericColumns) return true;
568
510
 
569
- // Check 2: First row values look like metadata (e.g., "Exported On:", "12/4/2025")
570
511
  if (rows.length > 0) {
571
512
  const firstRow = rows[0];
572
513
  const values = Object.values(firstRow);
514
+ const nonEmpty = values.filter(v => v !== null && v !== undefined && String(v).trim() !== '');
515
+
516
+ if (nonEmpty.length < columns.length * 0.5) return true;
517
+
573
518
  const hasMetadata = values.some(v =>
574
519
  String(v).includes('Exported') ||
575
520
  String(v).includes('Generated') ||
576
521
  String(v).includes('Report')
577
522
  );
578
-
579
523
  if (hasMetadata) return true;
580
524
  }
581
525
 
582
- // Check 3: Row 2 or 3 has values that look like headers (mostly strings, no numbers)
583
- if (rows.length >= 3) {
584
- const secondRow = rows[1];
585
- const thirdRow = rows[2];
526
+ return false;
527
+ }
528
+
529
+ private _detectLikelyHeaderRow(df: DataFrame): number {
530
+ const rows = df.toRows();
531
+
532
+ for (let i = 0; i < Math.min(rows.length, 10); i++) {
533
+ const row = rows[i];
534
+ const values = Object.values(row);
535
+ const nonEmpty = values.filter(v => v !== null && v !== undefined && String(v).trim() !== '');
586
536
 
587
- const checkRow = (row: Record<string, any>) => {
588
- const values = Object.values(row);
589
- const nonNumeric = values.filter(v => {
590
- const str = String(v).trim();
591
- return isNaN(Number(str)) && str !== '';
592
- }).length;
537
+ if (nonEmpty.length < values.length * 0.5) continue;
593
538
 
594
- return nonNumeric >= values.length * 0.7; // 70% non-numeric
595
- };
539
+ const nonNumericCount = nonEmpty.filter(v => {
540
+ const str = String(v).trim();
541
+ return isNaN(Number(str)) && str !== '';
542
+ }).length;
596
543
 
597
- if (checkRow(secondRow) || checkRow(thirdRow)) {
598
- return true;
544
+ // Row looks like headers if 70%+ non-numeric and not already the header row
545
+ if (nonNumericCount >= nonEmpty.length * 0.7 && i > 0) {
546
+ return i;
599
547
  }
600
548
  }
601
549
 
602
- return false;
550
+ return 0;
603
551
  }
604
552
 
605
- /**
606
- * UPDATED: Show settings modal using Modal component
607
- */
553
+ /* ═══════════════════════════════════════════════════
554
+ * RESHAPE MODAL
555
+ * ═══════════════════════════════════════════════════ */
556
+
608
557
  private _showReshapeModal(): void {
609
558
  if (!this._rawFileData) return;
610
559
 
611
- const isExcel = this._rawFileData.isExcel;
612
-
613
- if (isExcel) {
560
+ if (this._rawFileData.isExcel) {
614
561
  this._showExcelReshapeModal();
615
562
  } else {
616
563
  this._showCSVReshapeModal();
617
564
  }
618
565
  }
619
566
 
620
- /**
621
- * ✅ UPDATED: Excel reshape modal using Modal component
622
- */
623
- private async _showExcelReshapeModal(): Promise<void> {
624
- if (!this._rawFileData?.file) return;
625
-
626
- // Remove old modal from DOM if it was previously rendered
567
+ private _cleanupReshapeModal(): void {
627
568
  if (this._reshapeModal && this._reshapeModalRendered) {
628
569
  const oldEl = document.getElementById(`${this._id}-reshape-modal`);
629
570
  if (oldEl) oldEl.remove();
630
571
  this._reshapeModal = null;
631
572
  this._reshapeModalRendered = false;
632
573
  }
574
+ }
575
+
576
+ private async _showExcelReshapeModal(): Promise<void> {
577
+ if (!this._rawFileData?.file) return;
578
+
579
+ this._cleanupReshapeModal();
580
+
581
+ const suggestedRow = this._df ? this._detectLikelyHeaderRow(this._df) : 0;
633
582
 
634
- // Create fresh modal
635
583
  this._reshapeModal = new Modal(`${this._id}-reshape-modal`, {
636
584
  title: 'Excel Import Settings',
637
585
  size: 'large',
@@ -639,37 +587,36 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
639
587
  backdropClose: false
640
588
  });
641
589
 
642
- // Build modal content
643
- const modalContent = document.createElement('div');
644
- modalContent.innerHTML = `
590
+ const modalContentHTML = `
645
591
  <div style="margin-bottom: 1rem;">
646
592
  <label style="display: block; font-weight: 600; margin-bottom: 0.5rem; color: hsl(var(--foreground));">
647
593
  Header Row (0-based)
648
594
  </label>
649
- <input
650
- type="number"
651
- id="${this._id}-header-row"
652
- class="jux-input-element"
653
- value="0"
654
- min="0"
655
- max="50"
656
- style="width: 100%;"
595
+ <input
596
+ type="number"
597
+ id="${this._id}-header-row"
598
+ class="jux-input-element"
599
+ value="${suggestedRow}"
600
+ min="0"
601
+ max="50"
602
+ style="width: 100%;"
657
603
  />
658
- <div style="font-size: 0.875rem; color: hsl(var(--muted-foreground)); margin-top: 0.5rem; padding: 0.5rem; background: hsl(var(--warning) / 0.1); border-radius: var(--radius); border: 1px solid hsl(var(--warning) / 0.3);">
659
- ⚠️ <strong>Detected issue:</strong> Row 0 contains metadata ("Exported On:"), row 2 contains actual headers ("340B ID"). Try setting this to <strong>2</strong>.
604
+ <div class="jux-reshape-hint">
605
+ <strong>Detected issue:</strong> The current header row appears to contain metadata or empty values.
606
+ Row ${suggestedRow} looks like it contains the actual column headers.
607
+ Adjust the value above and check the preview below.
660
608
  </div>
661
609
  </div>
662
-
663
- <div style="border: 1px solid hsl(var(--border)); border-radius: var(--radius); padding: 1rem; background: hsl(var(--muted) / 0.3); max-height: 400px; overflow-y: auto;">
610
+ <div class="jux-reshape-preview-container">
664
611
  <div style="font-weight: 600; margin-bottom: 0.5rem; color: hsl(var(--foreground));">
665
612
  Preview (first 10 rows)
666
613
  </div>
667
- <div id="${this._id}-preview" style="font-family: 'JetBrains Mono', 'Courier New', monospace; font-size: 0.75rem; white-space: pre; color: hsl(var(--foreground)); line-height: 1.5;"></div>
614
+ <div id="${this._id}-preview" class="jux-reshape-preview"></div>
668
615
  </div>
669
616
  `;
670
617
 
671
618
  this._reshapeModal
672
- .content(modalContent.innerHTML)
619
+ .content(modalContentHTML)
673
620
  .actions([
674
621
  {
675
622
  label: 'Cancel',
@@ -680,11 +627,11 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
680
627
  label: 'Apply & Re-import',
681
628
  variant: 'primary',
682
629
  click: async () => {
683
- const headerRowInput = document.getElementById(`${this._id}-header-row`) as HTMLInputElement;
684
- const headerRow = parseInt(headerRowInput.value) || 0;
630
+ const input = document.getElementById(`${this._id}-header-row`) as HTMLInputElement;
631
+ const headerRow = parseInt(input.value) || 0;
685
632
 
686
633
  this.state.loading = true;
687
- this._updateStatus('Re-parsing with new settings...', 'loading');
634
+ this._updateStatus('Re-parsing with new settings...', 'loading');
688
635
 
689
636
  try {
690
637
  const sheets = await this._driver.streamFileMultiSheet(this._rawFileData!.file, {
@@ -699,32 +646,28 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
699
646
  if (sheetNames.length > 1) {
700
647
  this._renderMultiSheet(sheets, this._rawFileData!.file.name);
701
648
  } else {
702
- this._showReshapeWarning = false; // Prevent recursive warning after manual fix
649
+ this._showReshapeWarning = false;
703
650
  this._setDataFrame(sheets[sheetNames[0]], this._rawFileData!.file.name);
704
651
  }
705
652
 
706
653
  this._reshapeModal!.closeModal();
707
654
  } catch (err: any) {
708
- this._updateStatus(`❌ ${err.message}`, 'error');
655
+ this._updateStatus(`Error: ${err.message}`, 'error');
709
656
  }
710
657
  }
711
658
  }
712
659
  ]);
713
660
 
714
- // Render modal to document.body and open it
715
661
  this._reshapeModal.render(document.body);
716
662
  this._reshapeModalRendered = true;
717
663
 
718
- // Wait a tick for DOM to update after render
719
664
  await new Promise(resolve => requestAnimationFrame(resolve));
720
665
 
721
666
  const headerRowInput = document.getElementById(`${this._id}-header-row`) as HTMLInputElement;
722
667
  const previewDiv = document.getElementById(`${this._id}-preview`)!;
723
668
 
724
- // Update preview on header row change
725
669
  const updatePreview = async () => {
726
670
  const headerRow = parseInt(headerRowInput?.value) || 0;
727
-
728
671
  try {
729
672
  const sheets = await this._driver.streamFileMultiSheet(this._rawFileData!.file, {
730
673
  headerRow,
@@ -733,18 +676,20 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
733
676
 
734
677
  const firstSheet = Object.values(sheets)[0];
735
678
  if (!firstSheet) {
736
- if (previewDiv) previewDiv.textContent = '⚠️ No data found';
679
+ if (previewDiv) previewDiv.textContent = 'No data found';
737
680
  return;
738
681
  }
739
682
 
740
683
  const preview = firstSheet.toRows().slice(0, 10).map((row, i) => {
741
- const cols = Object.values(row).map(v => String(v).padEnd(20)).join(' ');
742
- return `${i === 0 ? '📌 ' : ' '}${cols}`;
684
+ const cols = Object.values(row).map(v => String(v ?? '').padEnd(20)).join(' | ');
685
+ return `${i === 0 ? '>> ' : ' '}${cols}`;
743
686
  }).join('\n');
744
687
 
745
- if (previewDiv) previewDiv.textContent = `Columns: ${firstSheet.columns.join(' │ ')}\n${'─'.repeat(80)}\n${preview}`;
688
+ if (previewDiv) {
689
+ previewDiv.textContent = `Columns: ${firstSheet.columns.join(' | ')}\n${'─'.repeat(80)}\n${preview}`;
690
+ }
746
691
  } catch (err: any) {
747
- if (previewDiv) previewDiv.textContent = `⚠️ Error: ${err.message}`;
692
+ if (previewDiv) previewDiv.textContent = `Error: ${err.message}`;
748
693
  }
749
694
  };
750
695
 
@@ -754,21 +699,11 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
754
699
  this._reshapeModal.open();
755
700
  }
756
701
 
757
- /**
758
- * ✅ UPDATED: CSV reshape modal using Modal component
759
- */
760
702
  private _showCSVReshapeModal(): void {
761
703
  if (!this._rawFileData) return;
762
704
 
763
- // Remove old modal from DOM if it was previously rendered
764
- if (this._reshapeModal && this._reshapeModalRendered) {
765
- const oldEl = document.getElementById(`${this._id}-reshape-modal`);
766
- if (oldEl) oldEl.remove();
767
- this._reshapeModal = null;
768
- this._reshapeModalRendered = false;
769
- }
705
+ this._cleanupReshapeModal();
770
706
 
771
- // Create fresh modal
772
707
  this._reshapeModal = new Modal(`${this._id}-reshape-modal`, {
773
708
  title: 'CSV Import Settings',
774
709
  size: 'large',
@@ -776,9 +711,7 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
776
711
  backdropClose: false
777
712
  });
778
713
 
779
- // Build modal content
780
- const modalContent = document.createElement('div');
781
- modalContent.innerHTML = `
714
+ const modalContentHTML = `
782
715
  <div style="margin-bottom: 1rem;">
783
716
  <label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">Delimiter</label>
784
717
  <select id="${this._id}-delimiter" class="jux-input-element" style="width: 100%;">
@@ -788,25 +721,22 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
788
721
  <option value=";">Semicolon (;)</option>
789
722
  </select>
790
723
  </div>
791
-
792
724
  <div style="margin-bottom: 1rem;">
793
725
  <label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">Header Row (0-based)</label>
794
726
  <input type="number" id="${this._id}-header-row" class="jux-input-element" value="0" min="0" max="50" style="width: 100%;" />
795
727
  </div>
796
-
797
728
  <div style="margin-bottom: 1rem;">
798
729
  <label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">Skip Rows Before Header</label>
799
730
  <input type="number" id="${this._id}-skip-rows" class="jux-input-element" value="0" min="0" max="50" style="width: 100%;" />
800
731
  </div>
801
-
802
- <div style="border: 1px solid hsl(var(--border)); border-radius: var(--radius); padding: 1rem; background: hsl(var(--muted) / 0.3); max-height: 400px; overflow-y: auto;">
732
+ <div class="jux-reshape-preview-container">
803
733
  <div style="font-weight: 600; margin-bottom: 0.5rem; color: hsl(var(--foreground));">Preview</div>
804
- <div id="${this._id}-preview" style="font-family: 'JetBrains Mono', 'Courier New', monospace; font-size: 0.75rem; white-space: pre; color: hsl(var(--foreground)); line-height: 1.5;"></div>
734
+ <div id="${this._id}-preview" class="jux-reshape-preview"></div>
805
735
  </div>
806
736
  `;
807
737
 
808
738
  this._reshapeModal
809
- .content(modalContent.innerHTML)
739
+ .content(modalContentHTML)
810
740
  .actions([
811
741
  {
812
742
  label: 'Cancel',
@@ -828,7 +758,7 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
828
758
  const skipRows = parseInt(skipRowsInput.value) || 0;
829
759
 
830
760
  this.state.loading = true;
831
- this._updateStatus('Re-parsing with new settings...', 'loading');
761
+ this._updateStatus('Re-parsing with new settings...', 'loading');
832
762
 
833
763
  try {
834
764
  const df = this._driver.parseCSV(this._rawFileData.text, {
@@ -839,39 +769,35 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
839
769
  });
840
770
 
841
771
  await this._driver.store(this._rawFileData.file.name, df, { source: this._rawFileData.file.name });
842
- this._showReshapeWarning = false; // Prevent recursive warning after manual fix
772
+ this._showReshapeWarning = false;
843
773
  this._setDataFrame(df, this._rawFileData.file.name);
844
774
 
845
775
  this._reshapeModal!.closeModal();
846
776
  } catch (err: any) {
847
- this._updateStatus(`❌ ${err.message}`, 'error');
777
+ this._updateStatus(`Error: ${err.message}`, 'error');
848
778
  }
849
779
  }
850
780
  }
851
781
  ]);
852
782
 
853
- // Render modal to document.body and open it
854
783
  this._reshapeModal.render(document.body);
855
784
  this._reshapeModalRendered = true;
856
785
 
857
- // Use requestAnimationFrame to ensure DOM is ready
858
786
  requestAnimationFrame(() => {
859
787
  const delimiterSelect = document.getElementById(`${this._id}-delimiter`) as HTMLSelectElement;
860
788
  const headerRowInput = document.getElementById(`${this._id}-header-row`) as HTMLInputElement;
861
789
  const skipRowsInput = document.getElementById(`${this._id}-skip-rows`) as HTMLInputElement;
862
790
  const previewDiv = document.getElementById(`${this._id}-preview`)!;
863
791
 
864
- // Auto-detect initial values
865
792
  if (this._rawFileData?.text) {
866
793
  const detected = (this._driver as any)._detectDelimiter(this._rawFileData.text);
867
794
  if (delimiterSelect) delimiterSelect.value = detected === '\t' ? '\\t' : detected;
868
795
 
869
- const headerRow = (this._driver as any)._detectHeaderRow(this._rawFileData.text, detected);
870
- if (headerRowInput) headerRowInput.value = String(headerRow);
796
+ const detectedHeaderRow = (this._driver as any)._detectHeaderRow(this._rawFileData.text, detected);
797
+ if (headerRowInput) headerRowInput.value = String(detectedHeaderRow);
871
798
  }
872
799
 
873
- // Update preview on changes
874
- const updatePreview = async () => {
800
+ const updatePreview = () => {
875
801
  if (!this._rawFileData?.text) return;
876
802
 
877
803
  const delim = delimiterSelect?.value === '\\t' ? '\t' : (delimiterSelect?.value || ',');
@@ -888,13 +814,15 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
888
814
  });
889
815
 
890
816
  const preview = df.toRows().map((row, i) => {
891
- const cols = Object.values(row).map(v => String(v).padEnd(20)).join(' ');
892
- return `${i === 0 ? '📌 ' : ' '}${cols}`;
817
+ const cols = Object.values(row).map(v => String(v ?? '').padEnd(20)).join(' | ');
818
+ return `${i === 0 ? '>> ' : ' '}${cols}`;
893
819
  }).join('\n');
894
820
 
895
- if (previewDiv) previewDiv.textContent = `Columns: ${df.columns.join(' │ ')}\n${'─'.repeat(80)}\n${preview}`;
821
+ if (previewDiv) {
822
+ previewDiv.textContent = `Columns: ${df.columns.join(' | ')}\n${'─'.repeat(80)}\n${preview}`;
823
+ }
896
824
  } catch (err: any) {
897
- if (previewDiv) previewDiv.textContent = `⚠️ Error: ${err.message}`;
825
+ if (previewDiv) previewDiv.textContent = `Error: ${err.message}`;
898
826
  }
899
827
  };
900
828
 
@@ -903,17 +831,16 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
903
831
  if (skipRowsInput) skipRowsInput.addEventListener('input', updatePreview);
904
832
 
905
833
  updatePreview();
906
-
907
834
  this._reshapeModal!.open();
908
835
  });
909
836
  }
910
837
 
911
- update(_prop: string, _value: any): void { }
912
-
913
838
  /* ═══════════════════════════════════════════════════
914
- * RENDER
839
+ * UPDATE & RENDER
915
840
  * ═══════════════════════════════════════════════════ */
916
841
 
842
+ update(_prop: string, _value: any): void { }
843
+
917
844
  render(targetId?: string | HTMLElement | BaseComponent<any>): this {
918
845
  const container = this._setupContainer(targetId);
919
846
  const { style, class: className } = this.state;
@@ -934,52 +861,12 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
934
861
  }
935
862
 
936
863
  const upload = new FileUpload(`${this._id}-upload`, uploadOpts);
937
-
938
864
  this._uploadRef = upload;
939
- // ✅ FIX: Use the SAME logic as fromUpload() to handle multi-sheet
865
+
940
866
  this._pendingSource = async () => {
941
867
  upload.bind('change', async (files: File[]) => {
942
868
  if (!files || files.length === 0) return;
943
- const file = files[0];
944
- this.state.loading = true;
945
- this._updateStatus('⏳ Parsing ' + file.name + '...', 'loading');
946
-
947
- try {
948
- const isExcel = file.name.toLowerCase().endsWith('.xlsx') ||
949
- file.name.toLowerCase().endsWith('.xls');
950
-
951
- if (isExcel) {
952
- // ✅ Store raw file for reshape
953
- this._rawFileData = { file, isExcel: true };
954
-
955
- const sheets = await this._driver.streamFileMultiSheet(file, {
956
- maxSheetSize: this._maxSheetSize,
957
- sheetChunkSize: this._sheetChunkSize,
958
- onProgress: (loaded, total) => {
959
- const pct = total ? Math.round((loaded / total) * 100) : 0;
960
- this._updateStatus(`⏳ Parsing ${file.name}... ${pct}%`, 'loading');
961
- }
962
- });
963
-
964
- const sheetNames = Object.keys(sheets);
965
-
966
- await this._driver.store(file.name, sheets[sheetNames[0]], { source: file.name });
967
-
968
- if (sheetNames.length > 1) {
969
- this._renderMultiSheet(sheets, file.name);
970
- } else {
971
- this._setDataFrame(sheets[sheetNames[0]], file.name);
972
- }
973
- } else {
974
- const df = await this._driver.streamFile(file);
975
- await this._driver.store(file.name, df, { source: file.name });
976
- this._setDataFrame(df, file.name);
977
- }
978
- } catch (err: any) {
979
- this._triggerCallback('error', err.message, null, this);
980
- this.state.loading = false;
981
- this._updateStatus('❌ ' + err.message, 'error');
982
- }
869
+ await this._handleFile(files[0]);
983
870
  });
984
871
  };
985
872