juxscript 1.1.187 → 1.1.189

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
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,94 @@ 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
+ if (nonNumericCount >= nonEmpty.length * 0.7 && i > 0) {
545
+ // i is index in toRows() but row 0 of the file was consumed as header,
546
+ // so the actual file row index is i + 1
547
+ return i + 1;
599
548
  }
600
549
  }
601
550
 
602
- return false;
551
+ return 0;
603
552
  }
604
553
 
605
- /**
606
- * UPDATED: Show settings modal using Modal component
607
- */
554
+ /* ═══════════════════════════════════════════════════
555
+ * RESHAPE MODAL
556
+ * ═══════════════════════════════════════════════════ */
557
+
608
558
  private _showReshapeModal(): void {
609
559
  if (!this._rawFileData) return;
610
560
 
611
- const isExcel = this._rawFileData.isExcel;
612
-
613
- if (isExcel) {
561
+ if (this._rawFileData.isExcel) {
614
562
  this._showExcelReshapeModal();
615
563
  } else {
616
564
  this._showCSVReshapeModal();
617
565
  }
618
566
  }
619
567
 
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
568
+ private _cleanupReshapeModal(): void {
627
569
  if (this._reshapeModal && this._reshapeModalRendered) {
628
570
  const oldEl = document.getElementById(`${this._id}-reshape-modal`);
629
571
  if (oldEl) oldEl.remove();
630
572
  this._reshapeModal = null;
631
573
  this._reshapeModalRendered = false;
632
574
  }
575
+ }
576
+
577
+ private async _showExcelReshapeModal(): Promise<void> {
578
+ if (!this._rawFileData?.file) return;
579
+
580
+ this._cleanupReshapeModal();
581
+
582
+ const suggestedRow = this._df ? this._detectLikelyHeaderRow(this._df) : 0;
633
583
 
634
- // Create fresh modal
635
584
  this._reshapeModal = new Modal(`${this._id}-reshape-modal`, {
636
585
  title: 'Excel Import Settings',
637
586
  size: 'large',
@@ -639,37 +588,36 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
639
588
  backdropClose: false
640
589
  });
641
590
 
642
- // Build modal content
643
- const modalContent = document.createElement('div');
644
- modalContent.innerHTML = `
591
+ const modalContentHTML = `
645
592
  <div style="margin-bottom: 1rem;">
646
593
  <label style="display: block; font-weight: 600; margin-bottom: 0.5rem; color: hsl(var(--foreground));">
647
594
  Header Row (0-based)
648
595
  </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%;"
596
+ <input
597
+ type="number"
598
+ id="${this._id}-header-row"
599
+ class="jux-input-element"
600
+ value="${suggestedRow}"
601
+ min="0"
602
+ max="50"
603
+ style="width: 100%;"
657
604
  />
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>.
605
+ <div class="jux-reshape-hint">
606
+ <strong>Detected issue:</strong> The current header row appears to contain metadata or empty values.
607
+ Row ${suggestedRow} looks like it contains the actual column headers.
608
+ Adjust the value above and check the preview below.
660
609
  </div>
661
610
  </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;">
611
+ <div class="jux-reshape-preview-container">
664
612
  <div style="font-weight: 600; margin-bottom: 0.5rem; color: hsl(var(--foreground));">
665
613
  Preview (first 10 rows)
666
614
  </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>
615
+ <div id="${this._id}-preview" class="jux-reshape-preview"></div>
668
616
  </div>
669
617
  `;
670
618
 
671
619
  this._reshapeModal
672
- .content(modalContent.innerHTML)
620
+ .content(modalContentHTML)
673
621
  .actions([
674
622
  {
675
623
  label: 'Cancel',
@@ -680,11 +628,11 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
680
628
  label: 'Apply & Re-import',
681
629
  variant: 'primary',
682
630
  click: async () => {
683
- const headerRowInput = document.getElementById(`${this._id}-header-row`) as HTMLInputElement;
684
- const headerRow = parseInt(headerRowInput.value) || 0;
631
+ const input = document.getElementById(`${this._id}-header-row`) as HTMLInputElement;
632
+ const headerRow = parseInt(input.value) || 0;
685
633
 
686
634
  this.state.loading = true;
687
- this._updateStatus('Re-parsing with new settings...', 'loading');
635
+ this._updateStatus('Re-parsing with new settings...', 'loading');
688
636
 
689
637
  try {
690
638
  const sheets = await this._driver.streamFileMultiSheet(this._rawFileData!.file, {
@@ -699,52 +647,50 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
699
647
  if (sheetNames.length > 1) {
700
648
  this._renderMultiSheet(sheets, this._rawFileData!.file.name);
701
649
  } else {
702
- this._showReshapeWarning = false; // Prevent recursive warning after manual fix
650
+ this._showReshapeWarning = false;
703
651
  this._setDataFrame(sheets[sheetNames[0]], this._rawFileData!.file.name);
704
652
  }
705
653
 
706
654
  this._reshapeModal!.closeModal();
707
655
  } catch (err: any) {
708
- this._updateStatus(`❌ ${err.message}`, 'error');
656
+ this._updateStatus(`Error: ${err.message}`, 'error');
709
657
  }
710
658
  }
711
659
  }
712
660
  ]);
713
661
 
714
- // Render modal to document.body and open it
715
662
  this._reshapeModal.render(document.body);
716
663
  this._reshapeModalRendered = true;
717
664
 
718
- // Wait a tick for DOM to update after render
719
665
  await new Promise(resolve => requestAnimationFrame(resolve));
720
666
 
721
667
  const headerRowInput = document.getElementById(`${this._id}-header-row`) as HTMLInputElement;
722
668
  const previewDiv = document.getElementById(`${this._id}-preview`)!;
723
669
 
724
- // Update preview on header row change
725
670
  const updatePreview = async () => {
726
671
  const headerRow = parseInt(headerRowInput?.value) || 0;
727
-
728
672
  try {
729
673
  const sheets = await this._driver.streamFileMultiSheet(this._rawFileData!.file, {
730
674
  headerRow,
731
- maxSheetSize: 10
675
+ maxSheetSize: headerRow + 20
732
676
  });
733
677
 
734
678
  const firstSheet = Object.values(sheets)[0];
735
679
  if (!firstSheet) {
736
- if (previewDiv) previewDiv.textContent = '⚠️ No data found';
680
+ if (previewDiv) previewDiv.textContent = 'No data found';
737
681
  return;
738
682
  }
739
683
 
740
684
  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}`;
685
+ const cols = Object.values(row).map(v => String(v ?? '').padEnd(20)).join(' | ');
686
+ return `${i === 0 ? '>> ' : ' '}${cols}`;
743
687
  }).join('\n');
744
688
 
745
- if (previewDiv) previewDiv.textContent = `Columns: ${firstSheet.columns.join(' │ ')}\n${'─'.repeat(80)}\n${preview}`;
689
+ if (previewDiv) {
690
+ previewDiv.textContent = `Columns: ${firstSheet.columns.join(' | ')}\n${'─'.repeat(80)}\n${preview}`;
691
+ }
746
692
  } catch (err: any) {
747
- if (previewDiv) previewDiv.textContent = `⚠️ Error: ${err.message}`;
693
+ if (previewDiv) previewDiv.textContent = `Error: ${err.message}`;
748
694
  }
749
695
  };
750
696
 
@@ -754,21 +700,11 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
754
700
  this._reshapeModal.open();
755
701
  }
756
702
 
757
- /**
758
- * ✅ UPDATED: CSV reshape modal using Modal component
759
- */
760
703
  private _showCSVReshapeModal(): void {
761
704
  if (!this._rawFileData) return;
762
705
 
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
- }
706
+ this._cleanupReshapeModal();
770
707
 
771
- // Create fresh modal
772
708
  this._reshapeModal = new Modal(`${this._id}-reshape-modal`, {
773
709
  title: 'CSV Import Settings',
774
710
  size: 'large',
@@ -776,37 +712,32 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
776
712
  backdropClose: false
777
713
  });
778
714
 
779
- // Build modal content
780
- const modalContent = document.createElement('div');
781
- modalContent.innerHTML = `
715
+ const modalContentHTML = `
782
716
  <div style="margin-bottom: 1rem;">
783
717
  <label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">Delimiter</label>
784
718
  <select id="${this._id}-delimiter" class="jux-input-element" style="width: 100%;">
785
719
  <option value=",">Comma (,)</option>
786
720
  <option value="|">Pipe (|)</option>
787
- <option value="\t">Tab (\\t)</option>
721
+ <option value="&#9;">Tab (\\t)</option>
788
722
  <option value=";">Semicolon (;)</option>
789
723
  </select>
790
724
  </div>
791
-
792
725
  <div style="margin-bottom: 1rem;">
793
726
  <label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">Header Row (0-based)</label>
794
727
  <input type="number" id="${this._id}-header-row" class="jux-input-element" value="0" min="0" max="50" style="width: 100%;" />
795
728
  </div>
796
-
797
729
  <div style="margin-bottom: 1rem;">
798
730
  <label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">Skip Rows Before Header</label>
799
731
  <input type="number" id="${this._id}-skip-rows" class="jux-input-element" value="0" min="0" max="50" style="width: 100%;" />
800
732
  </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;">
733
+ <div class="jux-reshape-preview-container">
803
734
  <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>
735
+ <div id="${this._id}-preview" class="jux-reshape-preview"></div>
805
736
  </div>
806
737
  `;
807
738
 
808
739
  this._reshapeModal
809
- .content(modalContent.innerHTML)
740
+ .content(modalContentHTML)
810
741
  .actions([
811
742
  {
812
743
  label: 'Cancel',
@@ -823,12 +754,12 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
823
754
  const headerRowInput = document.getElementById(`${this._id}-header-row`) as HTMLInputElement;
824
755
  const skipRowsInput = document.getElementById(`${this._id}-skip-rows`) as HTMLInputElement;
825
756
 
826
- const delim = delimiterSelect.value === '\\t' ? '\t' : delimiterSelect.value;
757
+ const delim = delimiterSelect.value;
827
758
  const headerRow = parseInt(headerRowInput.value) || 0;
828
759
  const skipRows = parseInt(skipRowsInput.value) || 0;
829
760
 
830
761
  this.state.loading = true;
831
- this._updateStatus('Re-parsing with new settings...', 'loading');
762
+ this._updateStatus('Re-parsing with new settings...', 'loading');
832
763
 
833
764
  try {
834
765
  const df = this._driver.parseCSV(this._rawFileData.text, {
@@ -839,42 +770,38 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
839
770
  });
840
771
 
841
772
  await this._driver.store(this._rawFileData.file.name, df, { source: this._rawFileData.file.name });
842
- this._showReshapeWarning = false; // Prevent recursive warning after manual fix
773
+ this._showReshapeWarning = false;
843
774
  this._setDataFrame(df, this._rawFileData.file.name);
844
775
 
845
776
  this._reshapeModal!.closeModal();
846
777
  } catch (err: any) {
847
- this._updateStatus(`❌ ${err.message}`, 'error');
778
+ this._updateStatus(`Error: ${err.message}`, 'error');
848
779
  }
849
780
  }
850
781
  }
851
782
  ]);
852
783
 
853
- // Render modal to document.body and open it
854
784
  this._reshapeModal.render(document.body);
855
785
  this._reshapeModalRendered = true;
856
786
 
857
- // Use requestAnimationFrame to ensure DOM is ready
858
787
  requestAnimationFrame(() => {
859
788
  const delimiterSelect = document.getElementById(`${this._id}-delimiter`) as HTMLSelectElement;
860
789
  const headerRowInput = document.getElementById(`${this._id}-header-row`) as HTMLInputElement;
861
790
  const skipRowsInput = document.getElementById(`${this._id}-skip-rows`) as HTMLInputElement;
862
791
  const previewDiv = document.getElementById(`${this._id}-preview`)!;
863
792
 
864
- // Auto-detect initial values
865
793
  if (this._rawFileData?.text) {
866
794
  const detected = (this._driver as any)._detectDelimiter(this._rawFileData.text);
867
- if (delimiterSelect) delimiterSelect.value = detected === '\t' ? '\\t' : detected;
795
+ if (delimiterSelect) delimiterSelect.value = detected;
868
796
 
869
- const headerRow = (this._driver as any)._detectHeaderRow(this._rawFileData.text, detected);
870
- if (headerRowInput) headerRowInput.value = String(headerRow);
797
+ const detectedHeaderRow = (this._driver as any)._detectHeaderRow(this._rawFileData.text, detected);
798
+ if (headerRowInput) headerRowInput.value = String(detectedHeaderRow);
871
799
  }
872
800
 
873
- // Update preview on changes
874
- const updatePreview = async () => {
801
+ const updatePreview = () => {
875
802
  if (!this._rawFileData?.text) return;
876
803
 
877
- const delim = delimiterSelect?.value === '\\t' ? '\t' : (delimiterSelect?.value || ',');
804
+ const delim = delimiterSelect?.value || ',';
878
805
  const headerRow = parseInt(headerRowInput?.value) || 0;
879
806
  const skipRows = parseInt(skipRowsInput?.value) || 0;
880
807
 
@@ -888,13 +815,15 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
888
815
  });
889
816
 
890
817
  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}`;
818
+ const cols = Object.values(row).map(v => String(v ?? '').padEnd(20)).join(' | ');
819
+ return `${i === 0 ? '>> ' : ' '}${cols}`;
893
820
  }).join('\n');
894
821
 
895
- if (previewDiv) previewDiv.textContent = `Columns: ${df.columns.join(' │ ')}\n${'─'.repeat(80)}\n${preview}`;
822
+ if (previewDiv) {
823
+ previewDiv.textContent = `Columns: ${df.columns.join(' | ')}\n${'─'.repeat(80)}\n${preview}`;
824
+ }
896
825
  } catch (err: any) {
897
- if (previewDiv) previewDiv.textContent = `⚠️ Error: ${err.message}`;
826
+ if (previewDiv) previewDiv.textContent = `Error: ${err.message}`;
898
827
  }
899
828
  };
900
829
 
@@ -903,17 +832,16 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
903
832
  if (skipRowsInput) skipRowsInput.addEventListener('input', updatePreview);
904
833
 
905
834
  updatePreview();
906
-
907
835
  this._reshapeModal!.open();
908
836
  });
909
837
  }
910
838
 
911
- update(_prop: string, _value: any): void { }
912
-
913
839
  /* ═══════════════════════════════════════════════════
914
- * RENDER
840
+ * UPDATE & RENDER
915
841
  * ═══════════════════════════════════════════════════ */
916
842
 
843
+ update(_prop: string, _value: any): void { }
844
+
917
845
  render(targetId?: string | HTMLElement | BaseComponent<any>): this {
918
846
  const container = this._setupContainer(targetId);
919
847
  const { style, class: className } = this.state;
@@ -934,52 +862,12 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
934
862
  }
935
863
 
936
864
  const upload = new FileUpload(`${this._id}-upload`, uploadOpts);
937
-
938
865
  this._uploadRef = upload;
939
- // ✅ FIX: Use the SAME logic as fromUpload() to handle multi-sheet
866
+
940
867
  this._pendingSource = async () => {
941
868
  upload.bind('change', async (files: File[]) => {
942
869
  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
- }
870
+ await this._handleFile(files[0]);
983
871
  });
984
872
  };
985
873