diff --git a/src/model.ts b/src/model.ts index 1a9b3da..6e8c545 100644 --- a/src/model.ts +++ b/src/model.ts @@ -12,6 +12,7 @@ export namespace ArrowModel { rowChunkSize?: number; colChunkSize?: number; loadingRepr?: string; + nullRepr?: string; } } @@ -34,6 +35,7 @@ export class ArrowModel extends DataModel { rowChunkSize: 512, colChunkSize: 24, loadingRepr: "", + nullRepr: "", ...loadingOptions, }; this._fileOptions = fileOptions; @@ -120,7 +122,8 @@ export class ArrowModel extends DataModel { // We have data const row_idx_in_chunk = row % this._loadingParams.rowChunkSize; const col_idx_in_chunk = col % this._loadingParams.colChunkSize; - const out = chunk.getChildAt(col_idx_in_chunk)?.get(row_idx_in_chunk).toString(); + const val = chunk.getChildAt(col_idx_in_chunk)?.get(row_idx_in_chunk); + const out = val?.toString() || this._loadingParams.nullRepr; // Prefetch next chunks only once we have data for the current chunk. // We chain the Promise because this can be considered a low priority operation so we want diff --git a/src/requests.ts b/src/requests.ts index 344a8ea..aefb106 100644 --- a/src/requests.ts +++ b/src/requests.ts @@ -26,6 +26,9 @@ export interface FileInfoResponse { export async function fetchFileInfo(params: Readonly): Promise { const response = await fetch(`/file/info/${params.path}`); + if (!response.ok) { + throw new Error(`Error communicating with the Arbalister server: ${response.status}`); + } const data: FileInfoResponse = await response.json(); return data; } @@ -83,6 +86,9 @@ export async function fetchStats( } const response = await fetch(`/arrow/stats/${params.path}?${query.toString()}`); + if (!response.ok) { + throw new Error(`Error communicating with the Arbalister server: ${response.status}`); + } const data: StatsResponseRaw = await response.json(); // Validate encoding and content type @@ -149,5 +155,9 @@ export async function fetchTable( } const url = `/arrow/stream/${params.path}?${query.toString()}`; - return await tableFromIPC(fetch(url)); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Error communicating with the Arbalister server: ${response.status}`); + } + return await tableFromIPC(response); } diff --git a/src/toolbar.ts b/src/toolbar.ts index 07cb9a3..c3470f3 100644 --- a/src/toolbar.ts +++ b/src/toolbar.ts @@ -1,6 +1,4 @@ -// Copyright (c) Jupyter Development Team. -// Distributed under the terms of the Modified BSD License. - +import { Dialog, showDialog } from "@jupyterlab/apputils"; import { nullTranslator } from "@jupyterlab/translation"; import { Styling } from "@jupyterlab/ui-components"; import { Widget } from "@lumino/widgets"; @@ -12,7 +10,6 @@ import type { CsvFileInfo, CsvReadOptions, FileInfoFor, - FileReadOptions, FileReadOptionsFor, SqliteFileInfo, SqliteReadOptions, @@ -20,36 +17,100 @@ import type { import type { ArrowGridViewer } from "./widget"; /** - * Base toolbar class for file-specific options with a dropdown selector. + * Base toolbar class for a dropdown selector with error recovery. + * Maintain a value synchronized with the UI and falls back to the previous value on error. */ abstract class DropdownToolbar extends Widget { - constructor(gridViewer: ArrowGridViewer, node: HTMLElement) { + constructor(labelName: string, options: Array<[string, string]>, selected: string) { + const node = DropdownToolbar.createDropdownNode(labelName, options, selected); super({ node }); - this._gridViewer = gridViewer; + this._currentValue = selected; + this._labelName = labelName; this.addClass("arrow-viewer-toolbar"); } - abstract get fileOptions(): FileReadOptions; + /** + * Create a generic dropdown node with a label and options. + */ + protected static createDropdownNode( + labelName: string, + options: Array<[string, string]>, + selected: string, + ): HTMLElement { + const div = document.createElement("div"); + const label = document.createElement("span"); + const select = document.createElement("select"); + label.textContent = `${labelName}: `; + label.className = "toolbar-label"; + for (const [value, displayLabel] of options) { + const option = document.createElement("option"); + option.value = value; + option.textContent = displayLabel; + if (value === selected) { + option.selected = true; + } + select.appendChild(option); + } + div.appendChild(label); + const node = Styling.wrapSelect(select); + node.classList.add("toolbar-dropdown"); + div.appendChild(node); + return div; + } + + /** + * Called when the dropdown value changes. Implement this to handle the change. + * If this method throws an error, the dropdown will revert to the previous value. + */ + protected abstract onChange(newValue: string): Promise; + + protected get value(): string { + return this.selectNode.value; + } + + protected set value(val: string) { + this.selectNode.value = val; + } - get selectNode(): HTMLSelectElement { - return this.node.getElementsByTagName("select")![0]; + get labelName(): string { + return this._labelName; } /** * Handle the DOM events for the widget. * - * @param event - The DOM event sent to the widget. + * 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. * - * #### 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. + * This method is made async to handle the chain of event required to catch the exception and + * show the dialog, but it will be fired and forgotten by the browser. + * + * @param event - The DOM event sent to the widget. */ - handleEvent(event: Event): void { + async handleEvent(event: Event): Promise { switch (event.type) { - case "change": - this._gridViewer.updateFileReadOptions(this.fileOptions); + case "change": { + const previousValue = this._currentValue; + const newValue = this.value; + try { + await this.onChange(newValue); + this._currentValue = newValue; + } catch (error) { + // Reset the selector value + this.value = previousValue; + + // Show a message to the user + const trans = Dialog.translator.load("jupyterlab"); + const cancel = Dialog.cancelButton({ label: trans.__("Close") }); + await showDialog({ + title: trans.__(`Error changing the ${this.labelName.toLowerCase()} option`), + body: typeof error === "string" ? error : (error as Error).message, + buttons: [cancel], + }); + } break; + } default: break; } @@ -63,7 +124,12 @@ abstract class DropdownToolbar extends Widget { this.selectNode.removeEventListener("change", this); } - protected _gridViewer: ArrowGridViewer; + private get selectNode(): HTMLSelectElement { + return this.node.getElementsByTagName("select")![0]; + } + + private _currentValue: string; + private _labelName: string; } export namespace CsvToolbar { @@ -75,17 +141,25 @@ export namespace CsvToolbar { export class CsvToolbar extends DropdownToolbar { constructor(options: CsvToolbar.Options, fileOptions: CsvReadOptions, fileInfo: CsvFileInfo) { - super( - options.gridViewer, - Private.createDelimiterNode(fileOptions.delimiter, fileInfo.delimiters, options.translator), - ); + const translator = options.translator || nullTranslator; + const trans = translator.load("jupyterlab"); + const delimiterOptions: [string, string][] = fileInfo.delimiters.map((delim) => [delim, delim]); + super(trans.__("Delimiter"), delimiterOptions, fileOptions.delimiter); + this._gridViewer = options.gridViewer; } get fileOptions(): CsvReadOptions { return { - delimiter: this.selectNode.value, + delimiter: this.value, }; } + + protected async onChange(newValue: string): Promise { + this._gridViewer.updateFileReadOptions({ delimiter: newValue }); + await this._gridViewer.ready; + } + + private _gridViewer: ArrowGridViewer; } export namespace SqliteToolbar { @@ -101,17 +175,25 @@ export class SqliteToolbar extends DropdownToolbar { fileOptions: SqliteReadOptions, fileInfo: SqliteFileInfo, ) { - super( - options.gridViewer, - Private.createTableNameNode(fileOptions.table_name, fileInfo.table_names, options.translator), - ); + const translator = options.translator || nullTranslator; + const trans = translator.load("jupyterlab"); + const tableOptions: [string, string][] = fileInfo.table_names.map((name) => [name, name]); + super(trans.__("Table"), tableOptions, fileOptions.table_name); + this._gridViewer = options.gridViewer; } get fileOptions(): SqliteReadOptions { return { - table_name: this.selectNode.value, + table_name: this.value, }; } + + protected async onChange(newValue: string): Promise { + this._gridViewer.updateFileReadOptions({ table_name: newValue }); + await this._gridViewer.ready; + } + + private _gridViewer: ArrowGridViewer; } /** @@ -145,71 +227,3 @@ export function createToolbar( return null; } } - -namespace Private { - /** - * Create a labeled dropdown node with items. - */ - function createLabeledDropdown( - label: string, - items: string[], - selected: string, - translator?: ITranslator, - ): HTMLElement { - translator = translator || nullTranslator; - const trans = translator?.load("jupyterlab"); - const options: [string, string][] = items.map((item) => [item, item]); - return createDropdownNode(trans.__(label), options, selected); - } - - /** - * Create the node for the delimiter switcher. - */ - export function createDelimiterNode( - selected: string, - delimiters: string[], - translator?: ITranslator, - ): HTMLElement { - return createLabeledDropdown("Delimiter: ", delimiters, selected, translator); - } - - /** - * Create the node for the table name switcher. - */ - export function createTableNameNode( - selected: string, - table_names: string[], - translator?: ITranslator, - ): HTMLElement { - return createLabeledDropdown("Table: ", table_names, selected, translator); - } - - /** - * Create a generic dropdown node with a label and options. - */ - function createDropdownNode( - labelText: string, - options: Array<[string, string]>, - selected: string, - ): HTMLElement { - const div = document.createElement("div"); - const label = document.createElement("span"); - const select = document.createElement("select"); - label.textContent = labelText; - label.className = "toolbar-label"; - for (const [value, displayLabel] of options) { - const option = document.createElement("option"); - option.value = value; - option.textContent = displayLabel; - if (value === selected) { - option.selected = true; - } - select.appendChild(option); - } - div.appendChild(label); - const node = Styling.wrapSelect(select); - node.classList.add("toolbar-dropdown"); - div.appendChild(node); - return div; - } -} diff --git a/src/widget.ts b/src/widget.ts index f3f6c0e..503483e 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -55,7 +55,7 @@ export class ArrowGridViewer extends Panel { } get ready(): Promise { - return this._ready; + return this._ready.then(() => this.dataModel.ready); } get revealed(): Promise {