juxscript 1.1.165 → 1.1.167

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.
@@ -3,6 +3,7 @@ import { DataFrame } from '../storage/DataFrame.js';
3
3
  import { TabularDriver } from '../storage/TabularDriver.js';
4
4
  import { FileUpload } from './fileupload.js';
5
5
  import { Table } from './table.js';
6
+ import { renderIcon } from './icons.js';
6
7
 
7
8
  const TRIGGER_EVENTS = [] as const;
8
9
  const CALLBACK_EVENTS = ['load', 'error', 'transform'] as const;
@@ -16,6 +17,8 @@ export interface DataFrameOptions {
16
17
  filterable?: boolean;
17
18
  paginated?: boolean;
18
19
  rowsPerPage?: number;
20
+ showStatus?: boolean;
21
+ icon?: string;
19
22
  style?: string;
20
23
  class?: string;
21
24
  }
@@ -42,6 +45,9 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
42
45
  private _uploadRef: FileUpload | null = null;
43
46
  private _storageKey: string | null = null;
44
47
  private _pendingSource: (() => Promise<void>) | null = null;
48
+ private _inlineUpload: { label: string; accept: string; icon: string } | null = null;
49
+ private _showStatus: boolean = true;
50
+ private _icon: string = '';
45
51
 
46
52
  constructor(id: string, options: DataFrameOptions = {}) {
47
53
  super(id, {
@@ -61,11 +67,14 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
61
67
  options.storeName ?? 'frames'
62
68
  );
63
69
 
70
+ this._showStatus = options.showStatus ?? true;
71
+ this._icon = options.icon ?? '';
72
+
64
73
  this._tableOptions = {
65
74
  striped: options.striped ?? true,
66
75
  hoverable: options.hoverable ?? true,
67
76
  sortable: options.sortable ?? true,
68
- filterable: options.filterable ?? true,
77
+ filterable: options.filterable ?? false, // defer until data loaded
69
78
  paginated: options.paginated ?? true,
70
79
  rowsPerPage: options.rowsPerPage ?? 25
71
80
  };
@@ -78,79 +87,98 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
78
87
  * DATA SOURCES
79
88
  * ═══════════════════════════════════════════════════ */
80
89
 
81
- /**
82
- * Load from IndexedDB by storage key
83
- */
84
90
  fromStorage(key: string): this {
85
91
  this._storageKey = key;
86
- this._pendingSource = async () => {
92
+ const loadFn = async () => {
87
93
  this.state.loading = true;
94
+ this._updateStatus('⏳ Loading...', 'loading');
88
95
  try {
89
- let df = await this._driver.loadByName(key);
96
+ const df = await this._driver.loadByName(key);
90
97
  if (!df) {
91
98
  this._triggerCallback('error', 'No table found with key: ' + key, null, this);
92
99
  this.state.loading = false;
100
+ this._updateStatus('No table found: ' + key, 'empty');
93
101
  return;
94
102
  }
95
103
  this._setDataFrame(df, 'storage: ' + key);
96
104
  } catch (err: any) {
97
105
  this._triggerCallback('error', err.message, null, this);
98
106
  this.state.loading = false;
107
+ this._updateStatus('❌ ' + err.message, 'error');
99
108
  }
100
109
  };
110
+ if (this._table) { loadFn(); } else { this._pendingSource = loadFn; }
101
111
  return this;
102
112
  }
103
113
 
104
- /**
105
- * Load from a FileUpload component — auto-wires change event
106
- */
107
114
  fromUpload(upload: FileUpload): this {
108
115
  this._uploadRef = upload;
109
116
  this._pendingSource = async () => {
110
- // Wire upload's change event to parse incoming files
111
117
  upload.bind('change', async (files: File[]) => {
112
118
  if (!files || files.length === 0) return;
113
119
  const file = files[0];
114
120
  this.state.loading = true;
115
-
121
+ this._updateStatus('⏳ Parsing ' + file.name + '...', 'loading');
116
122
  try {
117
123
  const df = await this._driver.streamFile(file);
118
- // Auto-persist to IndexedDB
119
124
  await this._driver.store(file.name, df, { source: file.name });
120
- this._setDataFrame(df, 'upload: ' + file.name);
125
+ this._setDataFrame(df, file.name);
121
126
  } catch (err: any) {
122
127
  this._triggerCallback('error', err.message, null, this);
123
128
  this.state.loading = false;
129
+ this._updateStatus('❌ ' + err.message, 'error');
124
130
  }
125
131
  });
126
132
  };
127
133
  return this;
128
134
  }
129
135
 
130
- /**
131
- * Load from raw data — array of objects or Record<string, any[]>
132
- */
133
136
  fromData(data: Record<string, any>[] | Record<string, any[]>): this {
134
- this._pendingSource = async () => {
137
+ const loadFn = async () => {
135
138
  this.state.loading = true;
139
+ this._updateStatus('⏳ Loading data...', 'loading');
136
140
  try {
137
141
  const df = new DataFrame(data);
138
142
  this._setDataFrame(df, 'inline data');
139
143
  } catch (err: any) {
140
144
  this._triggerCallback('error', err.message, null, this);
141
145
  this.state.loading = false;
146
+ this._updateStatus('❌ ' + err.message, 'error');
142
147
  }
143
148
  };
149
+ if (this._table) { loadFn(); } else { this._pendingSource = loadFn; }
150
+ return this;
151
+ }
152
+
153
+ /**
154
+ * Add an inline file upload control.
155
+ * @param label - Button label (default: 'Upload File')
156
+ * @param accept - File types (default: '.csv,.tsv,.txt,.xlsx,.xls')
157
+ * @param icon - Upload icon name (default: 'upload'). Pass '' to hide icon.
158
+ */
159
+ withUpload(
160
+ label: string = 'Upload File',
161
+ accept: string = '.csv,.tsv,.txt,.xlsx,.xls',
162
+ icon: string = 'upload'
163
+ ): this {
164
+ this._inlineUpload = { label, accept, icon };
144
165
  return this;
145
166
  }
146
167
 
147
168
  /* ═══════════════════════════════════════════════════
148
- * TRANSFORM API (returns new DataFrameComponent view)
169
+ * UI TOGGLES
170
+ * ═══════════════════════════════════════════════════ */
171
+
172
+ /** Show/hide the status bar */
173
+ showStatus(v: boolean): this { this._showStatus = v; return this; }
174
+
175
+ /** Set a custom icon for the status bar */
176
+ statusIcon(v: string): this { this._icon = v; return this; }
177
+
178
+ /* ═══════════════════════════════════════════════════
179
+ * TRANSFORM API
149
180
  * ═══════════════════════════════════════════════════ */
150
181
 
151
- /**
152
- * Apply a transform to the current DataFrame and update the table
153
- */
154
182
  apply(fn: (df: DataFrame) => DataFrame): this {
155
183
  if (!this._df) return this;
156
184
  const result = fn(this._df);
@@ -159,51 +187,30 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
159
187
  return this;
160
188
  }
161
189
 
162
- /**
163
- * Filter rows
164
- */
165
190
  filter(predicate: (row: Record<string, any>, index: number) => boolean): this {
166
191
  return this.apply(df => df.filter(predicate));
167
192
  }
168
193
 
169
- /**
170
- * Select columns
171
- */
172
194
  select(...cols: string[]): this {
173
195
  return this.apply(df => df.select(...cols));
174
196
  }
175
197
 
176
- /**
177
- * Sort by column
178
- */
179
198
  sort(col: string, descending?: boolean): this {
180
199
  return this.apply(df => df.sort(col, descending));
181
200
  }
182
201
 
183
- /**
184
- * Show first N rows
185
- */
186
202
  head(n: number = 5): this {
187
203
  return this.apply(df => df.head(n));
188
204
  }
189
205
 
190
- /**
191
- * Show last N rows
192
- */
193
206
  tail(n: number = 5): this {
194
207
  return this.apply(df => df.tail(n));
195
208
  }
196
209
 
197
- /**
198
- * Add a computed column
199
- */
200
210
  withColumn(name: string, fn: (row: Record<string, any>, index: number) => any): this {
201
211
  return this.apply(df => df.withColumn(name, fn));
202
212
  }
203
213
 
204
- /**
205
- * Where clause
206
- */
207
214
  where(col: string, op: '==' | '!=' | '>' | '<' | '>=' | '<=' | 'contains' | 'startsWith' | 'endsWith', value: any): this {
208
215
  return this.apply(df => df.where(col, op, value));
209
216
  }
@@ -212,41 +219,15 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
212
219
  * ACCESSORS
213
220
  * ═══════════════════════════════════════════════════ */
214
221
 
215
- /** Get the underlying DataFrame */
216
222
  get df(): DataFrame | null { return this._df; }
217
-
218
- /** Get the underlying TabularDriver */
219
223
  get driver(): TabularDriver { return this._driver; }
220
-
221
- /** Get the internal Table component */
222
224
  get table(): Table | null { return this._table; }
225
+ describe(): Record<string, any> | null { return this._df?.describe() ?? null; }
226
+ toCSV(delimiter?: string): string { return this._df?.toCSV(delimiter) ?? ''; }
227
+ toRows(): Record<string, any>[] { return this._df?.toRows() ?? []; }
228
+ get shape(): [number, number] { return this._df?.shape ?? [0, 0]; }
229
+ get columns(): string[] { return this._df?.columns ?? []; }
223
230
 
224
- /** Get describe() stats */
225
- describe(): Record<string, any> | null {
226
- return this._df?.describe() ?? null;
227
- }
228
-
229
- /** Export to CSV string */
230
- toCSV(delimiter?: string): string {
231
- return this._df?.toCSV(delimiter) ?? '';
232
- }
233
-
234
- /** Export to row objects */
235
- toRows(): Record<string, any>[] {
236
- return this._df?.toRows() ?? [];
237
- }
238
-
239
- /** Get shape */
240
- get shape(): [number, number] {
241
- return this._df?.shape ?? [0, 0];
242
- }
243
-
244
- /** Get column names */
245
- get columns(): string[] {
246
- return this._df?.columns ?? [];
247
- }
248
-
249
- /** Save current DataFrame to IndexedDB */
250
231
  async save(key?: string): Promise<string | null> {
251
232
  if (!this._df) return null;
252
233
  const name = key ?? this._storageKey ?? this._id;
@@ -268,6 +249,30 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
268
249
  * INTERNAL
269
250
  * ═══════════════════════════════════════════════════ */
270
251
 
252
+ private _updateStatus(text: string, type: 'loading' | 'success' | 'error' | 'empty' = 'empty'): void {
253
+ const el = document.getElementById(`${this._id}-status`);
254
+ if (!el) return;
255
+
256
+ el.className = 'jux-dataframe-status';
257
+ if (type) el.classList.add(`jux-dataframe-status-${type}`);
258
+
259
+ // Clear and rebuild
260
+ el.innerHTML = '';
261
+
262
+ if (this._icon && type === 'success') {
263
+ const iconEl = renderIcon(this._icon);
264
+ iconEl.style.width = '16px';
265
+ iconEl.style.height = '16px';
266
+ iconEl.style.marginRight = '6px';
267
+ iconEl.style.verticalAlign = 'middle';
268
+ el.appendChild(iconEl);
269
+ }
270
+
271
+ const span = document.createElement('span');
272
+ span.textContent = text;
273
+ el.appendChild(span);
274
+ }
275
+
271
276
  private _setDataFrame(df: DataFrame, sourceName: string): void {
272
277
  this._df = df;
273
278
  this.state.loaded = true;
@@ -276,13 +281,22 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
276
281
  this.state.rowCount = df.height;
277
282
  this.state.colCount = df.width;
278
283
 
279
- // Clean __EMPTY columns from xlsx artifacts
280
284
  const cleanCols = df.columns.filter(c => !c.startsWith('__EMPTY'));
281
285
  if (cleanCols.length < df.columns.length) {
282
286
  this._df = df.select(...cleanCols);
283
287
  }
284
288
 
285
289
  this._updateTable();
290
+ this._updateStatus(
291
+ `${sourceName} — ${this._df!.height} rows × ${this._df!.width} cols`,
292
+ 'success'
293
+ );
294
+
295
+ // Enable filter now that data exists
296
+ if (this._tableOptions.filterable) {
297
+ this._showFilterInput();
298
+ }
299
+
286
300
  this._triggerCallback('load', this._df, null, this);
287
301
  }
288
302
 
@@ -291,6 +305,55 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
291
305
  this._table.columns(this._df.columns).rows(this._df.toRows());
292
306
  }
293
307
 
308
+ private _showFilterInput(): void {
309
+ const wrapper = document.getElementById(this._id);
310
+ if (!wrapper) return;
311
+ // Only add once
312
+ if (wrapper.querySelector('.jux-dataframe-filter')) return;
313
+
314
+ const filterContainer = document.createElement('div');
315
+ filterContainer.className = 'jux-dataframe-filter';
316
+
317
+ const input = document.createElement('input');
318
+ input.type = 'text';
319
+ input.placeholder = 'Filter rows...';
320
+ input.className = 'jux-input-element jux-dataframe-filter-input';
321
+
322
+ const iconEl = renderIcon('search');
323
+ iconEl.style.width = '16px';
324
+ iconEl.style.height = '16px';
325
+
326
+ const iconWrap = document.createElement('span');
327
+ iconWrap.className = 'jux-dataframe-filter-icon';
328
+ iconWrap.appendChild(iconEl);
329
+
330
+ filterContainer.appendChild(iconWrap);
331
+ filterContainer.appendChild(input);
332
+
333
+ // Insert before the table
334
+ const tableWrapper = wrapper.querySelector('.jux-table-wrapper');
335
+ if (tableWrapper) {
336
+ wrapper.insertBefore(filterContainer, tableWrapper);
337
+ } else {
338
+ wrapper.appendChild(filterContainer);
339
+ }
340
+
341
+ input.addEventListener('input', () => {
342
+ if (!this._df) return;
343
+ const text = input.value.toLowerCase();
344
+ if (!text) {
345
+ this._table?.rows(this._df.toRows());
346
+ return;
347
+ }
348
+ const filtered = this._df.filter((row) => {
349
+ return Object.values(row).some(v =>
350
+ v !== null && v !== undefined && String(v).toLowerCase().includes(text)
351
+ );
352
+ });
353
+ this._table?.rows(filtered.toRows());
354
+ });
355
+ }
356
+
294
357
  update(prop: string, value: any): void { }
295
358
 
296
359
  /* ═══════════════════════════════════════════════════
@@ -307,53 +370,76 @@ export class DataFrameComponent extends BaseComponent<DataFrameState> {
307
370
  if (className) wrapper.className += ` ${className}`;
308
371
  if (style) wrapper.setAttribute('style', style);
309
372
 
310
- // Status bar
311
- const statusBar = document.createElement('div');
312
- statusBar.className = 'jux-dataframe-status';
313
- statusBar.id = `${this._id}-status`;
314
- statusBar.style.cssText = 'font-size:13px;color:#888;margin-bottom:8px;';
315
- statusBar.textContent = 'No data loaded.';
316
- wrapper.appendChild(statusBar);
373
+ // Inline upload
374
+ if (this._inlineUpload) {
375
+ const uploadOpts: any = {
376
+ label: this._inlineUpload.label,
377
+ accept: this._inlineUpload.accept,
378
+ };
379
+ if (this._inlineUpload.icon) {
380
+ uploadOpts.icon = this._inlineUpload.icon;
381
+ }
317
382
 
318
- container.appendChild(wrapper);
383
+ const upload = new FileUpload(`${this._id}-upload`, uploadOpts);
384
+
385
+ this._uploadRef = upload;
386
+ this._pendingSource = async () => {
387
+ upload.bind('change', async (files: File[]) => {
388
+ if (!files || files.length === 0) return;
389
+ const file = files[0];
390
+ this.state.loading = true;
391
+ this._updateStatus('⏳ Parsing ' + file.name + '...', 'loading');
392
+ try {
393
+ const df = await this._driver.streamFile(file);
394
+ await this._driver.store(file.name, df, { source: file.name });
395
+ this._setDataFrame(df, file.name);
396
+ } catch (err: any) {
397
+ this._triggerCallback('error', err.message, null, this);
398
+ this.state.loading = false;
399
+ this._updateStatus('❌ ' + err.message, 'error');
400
+ }
401
+ });
402
+ };
403
+
404
+ const uploadContainer = document.createElement('div');
405
+ uploadContainer.className = 'jux-dataframe-upload';
406
+ uploadContainer.id = `${this._id}-upload-container`;
407
+ wrapper.appendChild(uploadContainer);
408
+ container.appendChild(wrapper);
409
+ upload.render(uploadContainer);
410
+ } else {
411
+ container.appendChild(wrapper);
412
+ }
319
413
 
320
- // Create internal table
321
- this._table = new Table(`${this._id}-table`, {
414
+ // Status bar (conditional)
415
+ if (this._showStatus) {
416
+ const statusBar = document.createElement('div');
417
+ statusBar.className = 'jux-dataframe-status jux-dataframe-status-empty';
418
+ statusBar.id = `${this._id}-status`;
419
+ statusBar.textContent = 'No data loaded.';
420
+ wrapper.appendChild(statusBar);
421
+ }
422
+
423
+ // Table — filterable is false initially; we enable it after data loads
424
+ const tbl = new Table(`${this._id}-table`, {
322
425
  striped: this._tableOptions.striped,
323
426
  hoverable: this._tableOptions.hoverable,
324
427
  sortable: this._tableOptions.sortable,
325
- filterable: this._tableOptions.filterable,
428
+ filterable: false, // we handle filtering ourselves
326
429
  paginated: this._tableOptions.paginated,
327
430
  rowsPerPage: this._tableOptions.rowsPerPage
328
431
  });
329
- this._table.render(wrapper);
330
-
331
- // Watch state for status updates
332
- const origUpdate = this.update.bind(this);
333
- this.update = (prop: string, value: any) => {
334
- origUpdate(prop, value);
335
- if (prop === 'loaded' || prop === 'loading' || prop === 'rowCount' || prop === 'colCount' || prop === 'sourceName') {
336
- const el = document.getElementById(`${this._id}-status`);
337
- if (el) {
338
- if (this.state.loading) {
339
- el.textContent = '⏳ Loading...';
340
- } else if (this.state.loaded) {
341
- el.textContent = `✅ ${this.state.sourceName} — ${this.state.rowCount} rows × ${this.state.colCount} cols`;
342
- } else {
343
- el.textContent = 'No data loaded.';
344
- }
345
- }
346
- }
347
- };
432
+ tbl.render(wrapper);
433
+ this._table = tbl;
348
434
 
349
435
  // Execute pending data source
350
436
  if (this._pendingSource) {
351
- this._pendingSource();
437
+ const fn = this._pendingSource;
352
438
  this._pendingSource = null;
439
+ fn();
353
440
  }
354
441
 
355
442
  this._wireStandardEvents(wrapper);
356
-
357
443
  return this;
358
444
  }
359
445
  }