Browse Source

Merge pull request #1205 from afshin/csv

CSV widget refactor
Steven Silvester 8 years ago
parent
commit
b5f25b0259

+ 1 - 1
src/completer/widget.ts

@@ -195,7 +195,7 @@ class CompleterWidget extends Widget {
   }
 
   /**
-   * Handle `after_attach` messages for the widget.
+   * Handle `after-attach` messages for the widget.
    */
   protected onAfterAttach(msg: Message): void {
     document.addEventListener('keydown', this, USE_CAPTURE);

+ 3 - 3
src/csvwidget/index.css

@@ -35,7 +35,7 @@
     background: transparent;
 }
 
-.jp-CSVWidget-toolbar .jp-CSVWidget-toolbarDropdown {
+.jp-CSVToolbar .jp-CSVToolbar-dropdown {
     flex: 0 0 auto;
     padding-left: 8px;
     padding-right: 8px;
@@ -52,12 +52,12 @@
 }
 
 
-.jp-CSVWidget-table {
+.jp-CSVTable {
   flex: 1 1 auto;
   overflow: auto;
 }
 
-.jp-CSVWidget-table.jp-RenderedHTMLCommon table {
+.jp-CSVTable.jp-RenderedHTMLCommon table {
   margin-left: 0px;
   margin-right: 0px;
 }

+ 187 - 0
src/csvwidget/table.ts

@@ -0,0 +1,187 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import * as dsv from 'd3-dsv';
+
+import {
+  clearSignalData, defineSignal, ISignal
+} from 'phosphor/lib/core/signaling';
+
+import {
+  h, VNode
+} from 'phosphor/lib/ui/vdom';
+
+import {
+  VDomModel, VDomWidget
+} from '../common/vdom';
+
+import {
+  HTML_COMMON_CLASS
+} from '../renderers/widget';
+
+
+/**
+ * The class name added to a csv table widget.
+ */
+const CSV_TABLE_CLASS = 'jp-CSVTable';
+
+/**
+ * The hard limit on the number of rows to display.
+ */
+const DISPLAY_LIMIT = 1000;
+
+
+/**
+ * A CSV table content model.
+ */
+export
+class CSVModel extends VDomModel {
+  /**
+   * Instantiate a CSV model.
+   */
+  constructor(options: CSVModel.IOptions = {}) {
+    super();
+    this._content = options.content;
+    this._delimiter = options.delimiter || ',';
+  }
+
+  /**
+   * A signal emitted when the parsed value's rows exceed the display limit. It
+   * emits the length of the parsed value.
+   */
+  readonly maxExceeded: ISignal<this, CSVModel.IOverflow>;
+
+  /**
+   * The raw model content.
+   */
+  get content(): string {
+    return this._content;
+  }
+  set content(content: string) {
+    if (this._content === content) {
+      return;
+    }
+    this._content = content;
+    this.stateChanged.emit(void 0);
+  }
+
+  /**
+   * The CSV delimiter value.
+   */
+  get delimiter(): string {
+    return this._delimiter;
+  }
+  set delimiter(delimiter: string) {
+    if (this._delimiter === delimiter) {
+      return;
+    }
+    this._delimiter = delimiter;
+    this.stateChanged.emit(void 0);
+  }
+
+  /**
+   * Dispose this model.
+   */
+  dispose(): void {
+    if (this.isDisposed) {
+      return;
+    }
+    super.dispose();
+    clearSignalData(this);
+  }
+
+  /**
+   * Parse the content using the model's delimiter.
+   *
+   * #### Notes
+   * This method will always return parsed content that has at most the display
+   * limit worth of rows, currently maxing out at 1000 rows.
+   */
+  parse(): dsv.DSVParsedArray<dsv.DSVRowString> {
+    let output = dsv.dsvFormat(this._delimiter).parse(this._content);
+    if (output.length > DISPLAY_LIMIT) {
+      output.splice(0, DISPLAY_LIMIT);
+      this.maxExceeded.emit({
+        available: output.length,
+        maximum: DISPLAY_LIMIT
+      });
+    }
+    return output;
+  }
+
+  private _content: string = '';
+  private _delimiter: string = '';
+}
+
+
+// Define the signals for the `CSVModel` class.
+defineSignal(CSVModel.prototype, 'maxExceeded');
+
+
+/**
+ * A namespace for `CSVModel` statics.
+ */
+export
+namespace CSVModel {
+  /**
+   * The value emitted when there are more data rows than what can be displayed.
+   */
+  export
+  interface IOverflow {
+    /**
+     * The actual number of rows in the data.
+     */
+    available: number;
+
+    /**
+     * The maximum number of items that can be displayed.
+     */
+    maximum: number;
+  }
+
+  /**
+   * Instantiation options for CSV models.
+   */
+  export
+  interface IOptions {
+    /**
+     * The raw model content.
+     */
+    content?: string;
+
+    /**
+     * The CSV delimiter value.
+     *
+     * #### Notes
+     * If this value is not set, it defaults to `','`.
+     */
+    delimiter?: string;
+  }
+}
+
+/**
+ * A CSV table content widget.
+ */
+export
+class CSVTable extends VDomWidget<CSVModel> {
+  /**
+   * Instantiate a new CSV table widget.
+   */
+  constructor() {
+    super();
+    this.addClass(CSV_TABLE_CLASS);
+    this.addClass(HTML_COMMON_CLASS);
+  }
+
+  /**
+   * Render the content as virtual DOM nodes.
+   */
+  protected render(): VNode | VNode[] {
+    let rows = this.model.parse();
+    let cols = rows.columns || [];
+    return h.table([
+      h.thead(cols.map(col => h.th(col))),
+      h.tbody(rows.map(row => h.tr(cols.map(col => h.td(row[col])))))
+    ]);
+  }
+}

+ 131 - 0
src/csvwidget/toolbar.ts

@@ -0,0 +1,131 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import {
+  each, zip
+} from 'phosphor/lib/algorithm/iteration';
+
+import {
+  Message
+} from 'phosphor/lib/core/messaging';
+
+import {
+  clearSignalData, defineSignal, ISignal
+} from 'phosphor/lib/core/signaling';
+
+import {
+  Widget
+} from 'phosphor/lib/ui/widget';
+
+
+/**
+ * The class name added to a csv toolbar widget.
+ */
+const CSV_TOOLBAR_CLASS = 'jp-CSVToolbar';
+
+/**
+ * The class name added to a csv toolbar's dropdown element.
+ */
+const CSV_TOOLBAR_DROPDOWN_CLASS = 'jp-CSVToolbar-dropdown';
+
+
+/**
+ * A widget for CSV widget toolbars.
+ */
+export
+class CSVToolbar extends Widget {
+  /**
+   * Construct a new csv table widget.
+   */
+  constructor() {
+    super({ node: Private.createNode() });
+    this.addClass(CSV_TOOLBAR_CLASS);
+  }
+
+  /**
+   * A signal emitted when the delimiter selection has changed.
+   */
+  readonly delimiterChanged: ISignal<this, string>;
+
+  /**
+   * The delimiter dropdown menu.
+   */
+  get selectNode(): HTMLSelectElement {
+    return this.node.getElementsByTagName('select')[0];
+  }
+
+  /**
+   * Dispose of the resources held by the toolbar.
+   */
+  dispose(): void {
+    if (this.isDisposed) {
+      return;
+    }
+    clearSignalData(this);
+  }
+
+  /**
+   * Handle the DOM events for the widget.
+   *
+   * @param event - The DOM event sent to the widget.
+   *
+   * #### Notes
+   * This method implements the DOM `EventListener` interface and is
+   * called in response to events on the dock panel's node. It should
+   * not be called directly by user code.
+   */
+  handleEvent(event: Event): void {
+    switch (event.type) {
+    case 'change':
+      this.delimiterChanged.emit(this.selectNode.value);
+      break;
+    default:
+      break;
+    }
+  }
+
+  /**
+   * Handle `after-attach` messages for the widget.
+   */
+  protected onAfterAttach(msg: Message): void {
+    this.selectNode.addEventListener('change', this);
+  }
+
+  /**
+   * Handle `before-detach` messages for the widget.
+   */
+  protected onBeforeDetach(msg: Message): void {
+    this.selectNode.removeEventListener('change', this);
+  }
+}
+
+
+// Define the signals for the `CSVToolbar` class.
+defineSignal(CSVToolbar.prototype, 'delimiterChanged');
+
+
+/**
+ * A namespace for private toolbar methods.
+ */
+namespace Private {
+  /**
+   * Create the node for the delimiter switcher.
+   */
+  export
+  function createNode(): HTMLElement {
+    let div = document.createElement('div');
+    let label = document.createElement('span');
+    let select = document.createElement('select');
+    select.className = CSV_TOOLBAR_DROPDOWN_CLASS;
+    label.textContent = 'Delimiter: ';
+    each(zip([',', ';', '\t'], [',', ';', '\\t']), ([delimiter, label]) => {
+      let option = document.createElement('option');
+      option.value = delimiter;
+      option.textContent = label;
+      select.appendChild(option);
+    });
+    div.appendChild(label);
+    div.appendChild(select);
+    return div;
+  }
+}

+ 52 - 133
src/csvwidget/widget.ts

@@ -1,14 +1,13 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
-import {
-  Kernel
-} from '@jupyterlab/services';
-
 import {
   Message
 } from 'phosphor/lib/core/messaging';
 
+import {
+  disconnectReceiver
+} from 'phosphor/lib/core/signaling';
 
 import {
   PanelLayout
@@ -23,10 +22,12 @@ import {
 } from '../docregistry';
 
 import {
-  HTML_COMMON_CLASS
-} from '../renderers/widget';
+  CSVModel, CSVTable
+} from './table';
 
-import * as d3Dsv from 'd3-dsv';
+import {
+  CSVToolbar
+} from './toolbar';
 
 
 /**
@@ -34,81 +35,56 @@ import * as d3Dsv from 'd3-dsv';
  */
 const CSV_CLASS = 'jp-CSVWidget';
 
-/**
- * The class name added to a csv toolbar widget.
- */
-const CSV_TOOLBAR_CLASS = 'jp-CSVWidget-toolbar';
-
-/**
- * The class name added to a csv toolbar's dropdown element.
- */
-const CSV_TOOLBAR_DROPDOWN_CLASS = 'jp-CSVWidget-toolbarDropdown';
-
-/**
- * The class name added to a csv table widget.
- */
-const CSV_TABLE_CLASS = 'jp-CSVWidget-table';
-
 /**
  * The class name added to a csv warning widget.
  */
 const CSV_WARNING_CLASS = 'jp-CSVWidget-warning';
 
-/**
- * The hard limit on the number of rows to display.
- */
-const DISPLAY_LIMIT = 1000;
-
 
 /**
- * A widget for csv tables.
+ * A widget for CSV tables.
  */
 export
 class CSVWidget extends Widget {
   /**
    * Construct a new csv table widget.
    */
-  constructor(context: DocumentRegistry.IContext<DocumentRegistry.IModel>) {
+  constructor(options: CSVWidget.IOptions) {
     super();
-    this._context = context;
-    this.node.tabIndex = -1;
+
+    let context = options.context;
+    let layout = this.layout = new PanelLayout();
+
     this.addClass(CSV_CLASS);
+    this.title.label = context.path.split('/').pop();
 
-    this.layout = new PanelLayout();
-    this._toolbar = new Widget({ node: createDelimiterSwitcherNode() });
-    this._toolbar.addClass(CSV_TOOLBAR_CLASS);
-    this._table = new Widget();
-    this._table.addClass(CSV_TABLE_CLASS);
-    this._table.addClass(HTML_COMMON_CLASS);
     this._warning = new Widget();
     this._warning.addClass(CSV_WARNING_CLASS);
 
-    let layout = this.layout as PanelLayout;
+    this._table = new CSVTable();
+    this._table.model = new CSVModel({ content: context.model.toString() });
+    this._table.model.maxExceeded.connect((sender, overflow) => {
+      let { available, maximum } = overflow;
+      let message = `Table is too long to render,
+        rendering ${maximum} of ${available} rows`;
+      this._warning.node.textContent = message;
+    }, this);
+
+    this._toolbar = new CSVToolbar();
+    this._toolbar.delimiterChanged.connect((sender, delimiter) => {
+      this._table.model.delimiter = delimiter;
+    }, this);
+
     layout.addWidget(this._toolbar);
     layout.addWidget(this._table);
     layout.addWidget(this._warning);
 
-    let select = this._toolbar.node.getElementsByClassName(
-      CSV_TOOLBAR_DROPDOWN_CLASS)[0] as HTMLSelectElement;
-
-    if (context.model.toString()) {
-      this.update();
-    }
-    context.pathChanged.connect(() => {
-      this.update();
-    });
+    context.pathChanged.connect((c, path) => {
+      this.title.label = path.split('/').pop();
+    }, this);
     context.model.contentChanged.connect(() => {
-      this.update();
-    });
-    context.fileChanged.connect(() => {
-      this.update();
-    });
-
-    // Change delimiter on a change in the dropdown.
-    select.addEventListener('change', event => {
-      this.delimiter = select.value;
-      this.update();
-    });
+      this._table.model.content = context.model.toString();
+    }, this);
   }
 
   /**
@@ -118,57 +94,12 @@ class CSVWidget extends Widget {
     if (this.isDisposed) {
       return;
     }
-    this._context = null;
     super.dispose();
-  }
-
-  /**
-   * Handle `update-request` messages for the widget.
-   */
-  protected onUpdateRequest(msg: Message): void {
-    this.title.label = this._context.path.split('/').pop();
-    let cm = this._context.contentsModel;
-    if (cm === null) {
-      return;
-    }
-    let content = this._context.model.toString();
-    let delimiter = this.delimiter as string;
-    this.renderTable(content, delimiter);
-  }
-
-  /**
-   * Render an html table from a csv string.
-   */
-  renderTable(content: string, delimiter: string) {
-    let parsed = d3Dsv.dsvFormat(delimiter).parse(content);
-    let table = document.createElement('table');
-    let header = document.createElement('thead');
-    let body = document.createElement('tbody');
-    for (let name of parsed.columns) {
-      let th = document.createElement('th');
-      th.textContent = name;
-      header.appendChild(th);
-    }
-    for (let row of parsed.slice(0, DISPLAY_LIMIT)) {
-      let tr = document.createElement('tr');
-      for (let col of parsed.columns) {
-        let td = document.createElement('td');
-        td.textContent = row[col];
-        tr.appendChild(td);
-      }
-      body.appendChild(tr);
-    }
-    let msg =  `Table is too long to render, rendering ${DISPLAY_LIMIT} of ` +
-               `${parsed.length} rows`;
-    if (parsed.length > DISPLAY_LIMIT) {
-      this._warning.node.textContent = msg;
-    } else {
-      this._warning.node.textContent = '';
-    }
-    table.appendChild(header);
-    table.appendChild(body);
-    this._table.node.textContent = '';
-    this._table.node.appendChild(table);
+    this._table.model.dispose();
+    this._table.dispose();
+    this._toolbar.dispose();
+    this._warning.dispose();
+    disconnectReceiver(this);
   }
 
   /**
@@ -178,41 +109,29 @@ class CSVWidget extends Widget {
     this.node.focus();
   }
 
-  private _context: DocumentRegistry.IContext<DocumentRegistry.IModel>;
-  private delimiter: string = ',';
-  private _toolbar: Widget = null;
-  private _table: Widget = null;
+  private _toolbar: CSVToolbar = null;
+  private _table: CSVTable = null;
   private _warning: Widget = null;
 }
 
 
 /**
- * Create the node for the delimiter switcher.
+ * A namespace for `CSVWidget` statics.
  */
-function createDelimiterSwitcherNode(): HTMLElement {
-  let div = document.createElement('div');
-  let label = document.createElement('span');
-  label.textContent = 'Delimiter:';
-  let select = document.createElement('select');
-  for (let delim of [',', ';', '\t']) {
-    let option = document.createElement('option');
-    option.value = delim;
-    if (delim === '\t') {
-      option.textContent = '\\t';
-    } else {
-      option.textContent = delim;
-    }
-    select.appendChild(option);
+export
+namespace CSVWidget {
+  /**
+   * Instantiation options for CSV widgets.
+   */
+  export
+  interface IOptions {
+    context: DocumentRegistry.IContext<DocumentRegistry.IModel>;
   }
-  select.className = CSV_TOOLBAR_DROPDOWN_CLASS;
-  div.appendChild(label);
-  div.appendChild(select);
-  return div;
 }
 
 
 /**
- * A widget factory for csv tables.
+ * A widget factory for CSV widgets.
  */
 export
 class CSVWidgetFactory extends ABCWidgetFactory<CSVWidget, DocumentRegistry.IModel> {
@@ -241,6 +160,6 @@ class CSVWidgetFactory extends ABCWidgetFactory<CSVWidget, DocumentRegistry.IMod
    * Create a new widget given a context.
    */
   protected createNewWidget(context: DocumentRegistry.IContext<DocumentRegistry.IModel>): CSVWidget {
-    return new CSVWidget(context);
+    return new CSVWidget({ context });
   }
 }

+ 1 - 1
src/notebook/notebook/widget.ts

@@ -821,7 +821,7 @@ class Notebook extends StaticNotebook {
   }
 
   /**
-   * Handle `after_attach` messages for the widget.
+   * Handle `after-attach` messages for the widget.
    */
   protected onAfterAttach(msg: Message): void {
     super.onAfterAttach(msg);

+ 1 - 1
src/toolbar/index.ts

@@ -186,7 +186,7 @@ class ToolbarButton extends Widget {
   }
 
   /**
-   * Handle `after_attach` messages for the widget.
+   * Handle `after-attach` messages for the widget.
    */
   protected onAfterAttach(msg: Message): void {
     this.node.addEventListener('click', this);

+ 27 - 0
test/src/csvwidget/toolbar.spec.ts

@@ -0,0 +1,27 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import expect = require('expect.js');
+
+import {
+  CSVToolbar
+} from '../../../lib/csvwidget/toolbar';
+
+
+describe('csvwidget/table', () => {
+
+  describe('CSVToolbar', () => {
+
+    describe('#constructor()', () => {
+
+      it('should instantiate a `CSVToolbar`', () => {
+        let toolbar = new CSVToolbar();
+        expect(toolbar).to.be.a(CSVToolbar);
+        expect(toolbar.node.classList).to.contain('jp-CSVToolbar');
+      });
+
+    });
+
+  });
+
+});

+ 2 - 1
test/src/index.ts

@@ -17,6 +17,8 @@ import './console/foreign.spec';
 import './console/history.spec';
 import './console/panel.spec';
 
+import './csvwidget/toolbar.spec';
+
 import './dialog/dialog.spec';
 
 import './docmanager/savehandler.spec';
@@ -26,7 +28,6 @@ import './docregistry/context.spec';
 import './docregistry/default.spec';
 import './docregistry/registry.spec';
 
-
 import './filebrowser/model.spec';
 
 import './inspector/inspector.spec';