diff --git a/README.md b/README.md index 5bf2247..74102a1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # 🦆 Huey -Huey is a browser-based application that lets you inspect and analyze tabular datasets. +Huey is a browser-based application that lets you explore tabular datasets. Huey supports reading from multiple file formats, like .csv, .parquet, .json data files as well as .duckdb database files. __Try Huey now online__ [https://rpbouman.github.io/huey/src/index.html](https://rpbouman.github.io/huey/src/index.html) @@ -8,10 +8,13 @@ __Try Huey now online__ [https://rpbouman.github.io/huey/src/index.html](https:/ ## Key features -- Zero install. Download or checkout the source tree, and open src/index.html in your browser! No server required. +- An intuitive and responsive pivot table that supports filtering and (sub)totals +- Supports many different aggregate functions for reporting and data exploration +- Automatic breakdown of date/time columns into separate parts (year, month, quarter etc) for reporting - Supports reading .parquet, .csv, .json and .duckdb database files. (Support for reading MS Excel .xlsx files and .sqlite is planned) +- Export of results and/or SQL queries to file or clipboard - Blazing fast, even for large files - courtesy of [DuckDB](https://duckdb.org) -- An intuitive and responsive pivot table, with support for many types of metrics +- Zero install. Download or checkout the source tree, and open src/index.html in your browser! No server required. Note: although Huey can run locally, there is nothing that keeps you from deploying it in a webserver if you want to. @@ -27,33 +30,44 @@ Note: although Huey can run locally, there is nothing that keeps you from deploy ## Registering and Analyzing Files with Huey ### Registering Files -Huey uses [DuckDb WASM](https://duckdb.org/docs/archive/0.9.2/api/wasm/overview) to access and analyze files. -Due to general security policy, the web browser can not simply read arbitrary files from your local computer: you need to explicitly select files and register them in DuckDB WASM's virtual file system. +Huey uses [DuckDb WASM](https://duckdb.org/docs/archive/0.9.2/api/wasm/overview) to read and analyze data files. +Due to general security policy, the web browser can not simply read files from your local computer: you need to explicitly select and register files in DuckDB WASM's virtual file system. To register one or more files, you can either -1) Click the 'Upload...' button, which is the leftmost button on the toolbar at the top of the page. -2) Drag 'n Drop one or more files unto the "Datasources" tab in the sidebar. (The sidebar is on the left of the screen) +1) Click the 'Upload...' button ![upload button icon](https://github.com/rpbouman/huey/assets/647315/8dbae6ad-c4f2-4d5e-bc9a-f15fa9444c89). +The upload button is always available as the leftmost button on the toolbar at the top of the page. The upload action will pop up a file browser dialog that lets you browse and choose one or more files from your local filesystem. +In the file browser dialog, navigate to the file or files that you want to explore, select them and then confirm the dialog by clicking the 'Ok' button. +2) Drag 'n Drop one or multiple files unto the "Datasources" tab in the sidebar. + +Either action will open the Upload dialog. The upload dialog will show a progress bar for each file that is being registered. Additional progress items may appear in case a duckdb extension needs to be installed and/or loaded. + +![image](https://github.com/rpbouman/huey/assets/647315/b0c37783-4b3a-4166-9f3b-7f5a5ff91cd9) -Either of these actions will pop up a dialog that lets you browse and choose one or more files from your local filesystem. -In the file browser dialog, navigate to the file or files that you want to analyze, select them and then confirm the dialog by clicking the 'Ok' button. +After completion of the upload process, the upload dialog is updated to indicate the status of the uploads (or the extension installation, if applicable). -After confirming the dialog, Huey will attempt to register the files in DuckDb. -The successfully registered files are added to the "Datasources" tab in the sidebar. +Items that encountered an error are indicated by red progressbars. In case of errors, the item is expanded to reveal any information that might help to remedy the issue. -When registering new files, Huey will attempt to group files having similar column signature. The group appears as a separate node in the Datasources tab, and the individual files appear indented below it. +Successful actions are indicated by green progressbars. Succesfully loaded files are available in the Datasources tab, from where you can start exploring their contents by clicking the explore button ![explore button](https://github.com/rpbouman/huey/assets/647315/7b67ff2d-5cec-44e0-91d4-e670d38487c1). As a convenience, the explore button is also present in the upload dialog. + +Huey will attempt to group files having similar column signature. The group appears as a separate top-level node in the Datasources tab, with its individual files indented below it. A file group has its own explore button, so that you can not only explore the individual files, but also the UNION of all Files in the group: + +![image](https://github.com/rpbouman/huey/assets/647315/0ad057e0-e4ab-4bd8-b996-d3f50542853d) Files that cannot be grouped appear in a separate Miscellanous Files group. #### Opening DuckDb files -Apart from reading data files directly, Huey can also utilize existing duckdb files and access its tables and views. -The process for accessing duckdb files is exactly the same as for accessing data files. Just make sure you give your duckdb file a '.duckdb' extension - that's how Huey knows it's a duckdb file -(DuckDB data files are not required to have any particular name or extension, but Huey currently cannot detect that, so it relies on a file extension convention instead.) +Apart from reading data files directly, Huey can also open existing duckdb files and access its tables and views. The process for accessing duckdb files is exactly the same as for accessing data files. Just make sure you give your duckdb file a '.duckdb' extension - that's how Huey knows it's a duckdb file. (DuckDB data files are not required to have any particular name or extension, but Huey currently cannot detect that, so it relies on a file extension convention instead.) Successfully loaded .duckdb files will appear in the DuckDb Folder, which appears at the top of the DataSources tab. + +![image](https://github.com/rpbouman/huey/assets/647315/c7ca5ed7-7454-4783-8dbc-493244f8bb28) + +The schemas in the duckdb database file are presented as folders below the duckdb file entry, and any tables or views in the schema are presented below the schema folder. Each table or view has an explore button which you can click to explore the data. Note: We ran into a limitation - when the duckdb file itself refers to external files, then it's likely that Huey (or rather, DuckDB WASM) won't be able to find them. But native duckdb tables, as well as views based on duckdb base tables work marvelously and are quite a bit faster than querying bare data files. -### Analyzing Datasources -The Datasources have an analyze button. After clicking it, the sidebar switches to the Attributes tab, which is then is populated with a list of the Attributes of the selected Datasource. +### Exploring Datasources +The Datasources have an explore button ![explore button](https://github.com/rpbouman/huey/assets/647315/7b67ff2d-5cec-44e0-91d4-e670d38487c1) + . After clicking it, the sidebar switches to the Attributes tab, which is then is populated with a list of the Attributes of the selected Datasource. You can think of Attributes as a list of values (a column) that can be extracted from the Datasource and presented along the axes of the pivot table. The pivot table has two axes for placing attribute values: diff --git a/src/AttributeUi/AttributeUi.css b/src/AttributeUi/AttributeUi.css index 311481e..c47ed56 100644 --- a/src/AttributeUi/AttributeUi.css +++ b/src/AttributeUi/AttributeUi.css @@ -13,47 +13,61 @@ .attributeUi details > summary > .label { max-width: calc(100% - 166px); } + +/** +* folder icons +*/ +.attributeUi details[data-nodetype=folder] > summary > .icon::before { + /* folder */ + content: "\eaad"; +} + +.attributeUi details[data-nodetype=folder][open] > summary > .icon::before { + /* folder-open */ + content: "\faf7"; +} + /** * * Data type icons * */ -.attributeUi details[data-nodetype=column][data-column_type=VARCHAR] > summary > .icon:before { +.attributeUi details[data-nodetype=column][data-column_type=VARCHAR] > summary > .icon::before { /* letter T */ content: "\ec63"; } -.attributeUi details[data-nodetype=column][data-column_type$=INT] > summary > .icon:before, -.attributeUi details[data-nodetype=column][data-column_type=INTEGER] > summary > .icon:before +.attributeUi details[data-nodetype=column][data-column_type$=INT] > summary > .icon::before, +.attributeUi details[data-nodetype=column][data-column_type=INTEGER] > summary > .icon::before { /* 123 */ content: "\f554"; } -.attributeUi details[data-nodetype=column][data-column_type^=STRUCT] > summary > .icon:before +.attributeUi details[data-nodetype=column][data-column_type^=STRUCT] > summary > .icon::before { /* code-dots */ content: "\f61a"; } -.attributeUi details[data-nodetype=column][data-column_type^=DECIMAL] > summary > .icon:before, -.attributeUi details[data-nodetype=column][data-column_type=DOUBLE] > summary > .icon:before, -.attributeUi details[data-nodetype=column][data-column_type=REAL] > summary > .icon:before { +.attributeUi details[data-nodetype=column][data-column_type^=DECIMAL] > summary > .icon::before, +.attributeUi details[data-nodetype=column][data-column_type=DOUBLE] > summary > .icon::before, +.attributeUi details[data-nodetype=column][data-column_type=REAL] > summary > .icon::before { /* decimal */ content: "\fa26"; } -.attributeUi details[data-nodetype=column][data-column_type*=TIMESTAMP] > summary > .icon:before { +.attributeUi details[data-nodetype=column][data-column_type*=TIMESTAMP] > summary > .icon::before { /* calendar-clock */ content: "\fd2e"; } -.attributeUi details[data-nodetype=column][data-column_type=DATE] > summary > .icon:before { +.attributeUi details[data-nodetype=column][data-column_type=DATE] > summary > .icon::before { /* calendar */ content: "\ea53"; } -.attributeUi details[data-nodetype=column][data-column_type=TIME] > summary > .icon:before { +.attributeUi details[data-nodetype=column][data-column_type=TIME] > summary > .icon::before { /* clock */ content: "\ea70"; } @@ -63,61 +77,61 @@ * Derivation icons * */ -.attributeUi details[data-nodetype=derived][data-derivation=iso-date] > summary > .icon:before { +.attributeUi details[data-nodetype=derived][data-derivation=iso-date] > summary > .icon::before { /* calendar */ content: "\ea53"; } -.attributeUi details[data-nodetype=derived][data-derivation=year] > summary > .icon:before { +.attributeUi details[data-nodetype=derived][data-derivation=year] > summary > .icon::before { /* letter-y */ content: "\ec68"; } -.attributeUi details[data-nodetype=derived][data-derivation=quarter] > summary > .icon:before { +.attributeUi details[data-nodetype=derived][data-derivation=quarter] > summary > .icon::before { /* letter q */ content: "\ec60"; } -.attributeUi details[data-nodetype=derived][data-derivation="month num"] > summary > .icon:before { +.attributeUi details[data-nodetype=derived][data-derivation="month num"] > summary > .icon::before { /* letter m */ content: "\ec5c"; } -.attributeUi details[data-nodetype=derived][data-derivation="week num"] > summary > .icon:before { - /* letter w */ - content: "\ec66"; +.attributeUi details[data-nodetype=derived][data-derivation="week num"] > summary > .icon::before { + /* calendar-week */ + content: "\fd30"; } -.attributeUi details[data-nodetype=derived][data-derivation="day of year"] > summary > .icon:before { +.attributeUi details[data-nodetype=derived][data-derivation="day of year"] > summary > .icon::before { /* letter-d */ content: "\ec53"; } -.attributeUi details[data-nodetype=derived][data-derivation="day of month"] > summary > .icon:before { - /* letter-d */ - content: "\ec53"; +.attributeUi details[data-nodetype=derived][data-derivation="day of month"] > summary > .icon::before { + /* calendar-month */ + content: "\fd2f"; } -.attributeUi details[data-nodetype=derived][data-derivation="day of week"] > summary > .icon:before { - /* letter-d */ - content: "\ec53"; +.attributeUi details[data-nodetype=derived][data-derivation="day of week"] > summary > .icon::before { + /* letter-d-small */ + content: "\fcca"; } -.attributeUi details[data-nodetype=derived][data-derivation=iso-time] > summary > .icon:before { +.attributeUi details[data-nodetype=derived][data-derivation=iso-time] > summary > .icon::before { /* clock */ content: "\ea70"; } -.attributeUi details[data-nodetype=derived][data-derivation=hour] > summary > .icon:before { +.attributeUi details[data-nodetype=derived][data-derivation=hour] > summary > .icon::before { /* letter-h */ content: "\ec57"; } -.attributeUi details[data-nodetype=derived][data-derivation=minute] > summary > .icon:before { +.attributeUi details[data-nodetype=derived][data-derivation=minute] > summary > .icon::before { /* letter-m-small */ content: "\fcd3"; } -.attributeUi details[data-nodetype=derived][data-derivation=second] > summary > .icon:before { +.attributeUi details[data-nodetype=derived][data-derivation=second] > summary > .icon::before { /* letter-s */ content: "\ec62"; } @@ -128,82 +142,82 @@ * */ -.attributeUi details[data-nodetype=aggregate][data-aggregator=count] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator=count] > summary > .icon::before { /* tallymarks */ content: "\ec4a"; } -.attributeUi details[data-nodetype=aggregate][data-aggregator="distinct count"] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator="distinct count"] > summary > .icon::before { /* tallymark-4 */ content: "\ec49"; } -.attributeUi details[data-nodetype=aggregate][data-aggregator=max] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator=max] > summary > .icon::before { /* math-max */ content: "\f0f5"; } -.attributeUi details[data-nodetype=aggregate][data-aggregator=min] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator=min] > summary > .icon::before { /* math-min */ content: "\f0f6"; } -.attributeUi details[data-nodetype=aggregate][data-aggregator=list] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator=list] > summary > .icon::before { /* list */ content: "\eb6b"; } -.attributeUi details[data-nodetype=aggregate][data-aggregator="distinct list"] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator="distinct list"] > summary > .icon::before { /* list details */ content: "\ef40"; } -.attributeUi details[data-nodetype=aggregate][data-aggregator=histogram] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator=histogram] > summary > .icon::before { /* list numbers */ content: "\ef11"; } -.attributeUi details[data-nodetype=aggregate][data-aggregator=sum] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator=sum] > summary > .icon::before { /* sum */ content: "\eb73"; } -.attributeUi details[data-nodetype=aggregate][data-aggregator=avg] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator=avg] > summary > .icon::before { /* math-avg */ content: "\f0f4"; } -.attributeUi details[data-nodetype=aggregate][data-aggregator=median] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator=median] > summary > .icon::before { /* calculator */ content: "\eb80"; } -.attributeUi details[data-nodetype=aggregate][data-aggregator=mode] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator=mode] > summary > .icon::before { /* calculator */ content: "\eb80"; } -.attributeUi details[data-nodetype=aggregate][data-aggregator=stdev] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator=stdev] > summary > .icon::before { /* calculator */ content: "\eb80"; } -.attributeUi details[data-nodetype=aggregate][data-aggregator=variance] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator=variance] > summary > .icon::before { /* calculator */ content: "\eb80"; } -.attributeUi details[data-nodetype=aggregate][data-aggregator=entropy] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator=entropy] > summary > .icon::before { /* calculator */ content: "\eb80"; } -.attributeUi details[data-nodetype=aggregate][data-aggregator=kurtosis] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator=kurtosis] > summary > .icon::before { /* calculator */ content: "\eb80"; } -.attributeUi details[data-nodetype=aggregate][data-aggregator=skewness] > summary > .icon:before { +.attributeUi details[data-nodetype=aggregate][data-aggregator=skewness] > summary > .icon::before { /* calculator */ content: "\eb80"; } @@ -212,7 +226,7 @@ /** * Prevent the Attribute UI events when the pivot table is busy: */ -main.layout:has( .workarea > .pivotTableUiContainer[aria-busy=true] ) > nav#sidebar .attributeUi details > summary > label.attributeUiAxisButton { +main.layout:has( .workarea > .pivotTableUiContainer[aria-busy=true] ) > nav#sidebar .attributeUi details > summary { pointer-events: none; } @@ -241,7 +255,7 @@ main.layout:has( .workarea > .pivotTableUiContainer[aria-busy=true] ) > nav#side display: none; } -.attributeUi details > summary > .attributeUiAxisButton[data-axis=rows]:before { +.attributeUi details > summary > .attributeUiAxisButton[data-axis=rows]::before { /* table-column */ /* It may seem backward that we're using the table-column icon for the rows axis, @@ -250,7 +264,7 @@ main.layout:has( .workarea > .pivotTableUiContainer[aria-busy=true] ) > nav#side content: "\faff"; } -.attributeUi details > summary > .attributeUiAxisButton[data-axis=columns]:before { +.attributeUi details > summary > .attributeUiAxisButton[data-axis=columns]::before { /* table-row */ /* It may seem backward that we're using the table-row icon for the columns axis, @@ -259,7 +273,7 @@ main.layout:has( .workarea > .pivotTableUiContainer[aria-busy=true] ) > nav#side content: "\fb00"; } -.attributeUi details > summary > .attributeUiAxisButton[data-axis=cells]:before { +.attributeUi details > summary > .attributeUiAxisButton[data-axis=cells]::before { /* layout grid */ content: "\edba"; } @@ -279,14 +293,14 @@ main.layout:has( .workarea > .pivotTableUiContainer[aria-busy=true] ) > nav#side color: var( --huey-icon-color-highlight ); } -.attributeUi details > summary > .attributeUiAxisButton[data-axis=filters]:before { +.attributeUi details > summary > .attributeUiAxisButton[data-axis=filters]::before { /* filter-plus */ /* content: "\fa02"; */ /* filter */ content: "\eaa5"; } -.attributeUi details > summary > .attributeUiAxisButton[data-axis=filters]:has( > input[type=checkbox]:checked ):before { +.attributeUi details > summary > .attributeUiAxisButton[data-axis=filters]:has( > input[type=checkbox]:checked )::before { /* filter-x */ /* content: "\fa04"; */ /* filter-off */ diff --git a/src/AttributeUi/AttributeUi.js b/src/AttributeUi/AttributeUi.js index 942cdc7..f831145 100644 --- a/src/AttributeUi/AttributeUi.js +++ b/src/AttributeUi/AttributeUi.js @@ -1,4 +1,3 @@ - class AttributeUi { #id = undefined; @@ -18,22 +17,27 @@ class AttributeUi { columnType: 'HUGEINT' }, 'min': { + folder: "statistics", preservesColumnType: true, expressionTemplate: 'MIN( ${columnName} )' }, 'max': { + folder: "statistics", preservesColumnType: true, expressionTemplate: 'MAX( ${columnName} )' }, 'list': { + folder: "list aggregators", expressionTemplate: 'LIST( ${columnName} )', isArray: true }, 'distinct list': { + folder: "list aggregators", expressionTemplate: 'LIST( DISTINCT ${columnName} )', isArray: true }, 'histogram': { + folder: "list aggregators", expressionTemplate: 'HISTOGRAM( ${columnName} )', isStruct: true, }, @@ -43,14 +47,17 @@ class AttributeUi { expressionTemplate: 'SUM( ${columnName} )', createFormatter: function(axisItem){ var columnType = axisItem.columnType; - var dataTypeInfo = dataTypes[columnType]; - var formatter = createNumberFormatter(dataTypeInfo.isInteger !== true); + var dataTypeInfo = getDataTypeInfo(columnType); + var isInteger = dataTypeInfo.isInteger; + var formatter = createNumberFormatter(isInteger !== true); + return function(value, field){ - return formatter.format(value); + return formatter.format(value, field); }; } }, 'avg': { + folder: "statistics", isNumeric: true, isInteger: false, forNumeric: true, @@ -58,20 +65,21 @@ class AttributeUi { createFormatter: function(axisItem){ var formatter = createNumberFormatter(true); return function(value, field){ - return formatter.format(value); + return formatter.format(value, field); }; } }, 'median': { + folder: "statistics", expressionTemplate: 'MEDIAN( ${columnName} )', createFormatter: function(axisItem){ var columnType = axisItem.columnType; - var dataTypeInfo = dataTypes[columnType]; + var dataTypeInfo = getDataTypeInfo(columnType); var formatter; if (dataTypeInfo.isNumeric) { formatter = createNumberFormatter(dataTypeInfo.isInteger !== true); return function(value, field){ - return formatter.format(value); + return formatter.format(value, field); }; } else { @@ -82,10 +90,12 @@ class AttributeUi { } }, 'mode': { + folder: "statistics", preservesColumnType: true, expressionTemplate: 'MODE( ${columnName} )' }, 'stdev': { + folder: "statistics", isNumeric: true, isInteger: false, forNumeric: true, @@ -93,6 +103,7 @@ class AttributeUi { columnType: 'DOUBLE' }, 'variance': { + folder: "statistics", isNumeric: true, isInteger: false, forNumeric: true, @@ -100,12 +111,14 @@ class AttributeUi { columnType: 'DOUBLE' }, 'entropy': { + folder: "statistics", isNumeric: true, isInteger: false, expressionTemplate: 'ENTROPY( ${columnName} )', columnType: 'DOUBLE' }, 'kurtosis': { + folder: "statistics", isNumeric: true, isInteger: false, forNumeric: true, @@ -113,6 +126,7 @@ class AttributeUi { columnType: 'DOUBLE' }, 'skewness': { + folder: "statistics", isNumeric: true, isInteger: false, forNumeric: true, @@ -123,26 +137,24 @@ class AttributeUi { static dateFields = { 'iso-date': { + folder: 'date fields', // %x is isodate, // see: https://duckdb.org/docs/sql/functions/dateformat.html expressionTemplate: "strftime( ${columnName}, '%x' )", columnType: 'VARCHAR' }, 'year': { + folder: 'date fields', expressionTemplate: "CAST( YEAR( ${columnName} ) AS INT)", - columnType: 'INT', - formats: { - 'yyyy': { - }, - 'yy': { - } - } + columnType: 'INTEGER' }, 'quarter': { + folder: 'date fields', expressionTemplate: "'Q' || QUARTER( ${columnName} )", - columnType: 'VARCHAR' + columnType: 'VARCHAR' }, 'month num': { + folder: 'date fields', expressionTemplate: "CAST( MONTH( ${columnName} ) AS UTINYINT)", columnType: 'UTINYINT', formats: { @@ -155,18 +167,22 @@ class AttributeUi { } }, 'week num': { + folder: 'date fields', expressionTemplate: "CAST( WEEK( ${columnName} ) AS UTINYINT)", columnType: 'UTINYINT' }, 'day of year': { + folder: 'date fields', expressionTemplate: "CAST( DAYOFYEAR( ${columnName} ) as USMALLINT)", columnType: 'USMALLINT' }, 'day of month': { + folder: 'date fields', expressionTemplate: "CAST( DAYOFMONTH( ${columnName} ) AS UTINYINT)", columnType: 'UTINYINT' }, 'day of week': { + folder: 'date fields', expressionTemplate: "CAST( DAYOFWEEK( ${columnName} ) as UTINYINT)", columnType: 'UTINYINT', formats: { @@ -182,10 +198,12 @@ class AttributeUi { static timeFields = { 'iso-time': { + folder: 'time fields', expressionTemplate: "strftime( ${columnName}, '%H:%M:%S' )", columnType: 'VARCHAR' }, 'hour': { + folder: 'time fields', expressionTemplate: "CAST( HOUR( ${columnName} ) as UTINYINT)", columnType: 'UTINYINT', formats: { @@ -196,17 +214,19 @@ class AttributeUi { } }, 'minute': { + folder: 'time fields', expressionTemplate: "CAST( MINUTE( ${columnName} ) as UTINYINT)", columnType: 'UTINYINT' }, 'second': { + folder: 'time fields', expressionTemplate: "CAST( SECOND( ${columnName} ) as UTINYINT)", columnType: 'UTINYINT' } }; - + static getApplicableDerivations(typeName){ - var typeInfo = dataTypes[typeName]; + var typeInfo = getDataTypeInfo(typeName); var hasTimeFields = Boolean(typeInfo.hasTimeFields); var hasDateFields = Boolean(typeInfo.hasDateFields); @@ -233,7 +253,7 @@ class AttributeUi { } static getApplicableAggregators(typeName) { - var typeInfo = dataTypes[typeName]; + var typeInfo = getDataTypeInfo(typeName); var isNumeric = Boolean(typeInfo.isNumeric); var isInteger = Boolean(typeInfo.isInteger); @@ -382,14 +402,12 @@ class AttributeUi { #renderAttributeUiNode(config){ var node = createEl('details', { role: 'treeitem', - "class": ['attributeUiNode', config.type], - 'data-nodetype': config.type + 'data-nodetype': config.type, + 'data-column_name': config.profile.column_name, + 'data-column_type': config.profile.column_type }); - node.setAttribute('data-column_name', config.profile.column_name); - node.setAttribute('data-column_type', config.profile.column_type); switch (config.type){ case 'column': - node.setAttribute('data-state', 'collapsed'); node.addEventListener('toggle', this.#toggleNodeState.bind(this) ); break; case 'aggregate': @@ -447,57 +465,122 @@ class AttributeUi { attributesUi.appendChild(node); } } - - #loadChildNodesForColumnNode(node){ - var columnName = node.getAttribute('data-column_name'); - var columnType = node.getAttribute('data-column_type'); - var profile = { - column_name: columnName, - column_type: columnType - }; - var typeName = /^[^\(]+/.exec(columnType)[0]; - - var derived = []; - var aggregates = []; - var typeInfo = dataTypes[columnType]; + #renderFolderNode(config){ + var node = createEl('details', { + role: 'treeitem', + 'data-nodetype': 'folder', + }); - var isNumeric = Boolean(typeInfo.isNumeric); - var isInteger = Boolean(typeInfo.isInteger); - var hasTimeFields = Boolean(typeInfo.hasTimeFields); - var hasDateFields = Boolean(typeInfo.hasDateFields); - - var childNode, config; + var head = createEl('summary', { + }); + var icon = createEl('span', { + 'class': 'icon', + 'role': 'img' + }); + head.appendChild(icon); + + var label = createEl('span', { + "class": 'label' + }, config.caption); + head.appendChild(label); + node.appendChild(head); + return node; + } + + #createFolders(itemsObject, node){ + var folders = Object.keys(itemsObject).reduce(function(acc, curr){ + var object = itemsObject[curr]; + var folder = object.folder; + if (!folder) { + return acc; + } + if (acc[folder]) { + return acc; + } + var folderNode = this.#renderFolderNode({caption: folder}); + acc[folder] = folderNode; + + var childNodes = node.childNodes; + if (childNodes.length) { + // folders got before any other child, + for (var i = 0; i < childNodes.length ; i++){ + var childNode = childNodes.item(i); + if (childNode.nodeType !== 1) { + continue; + } + if (childNode.getAttribute('data-nodetype') === 'folder'){ + continue; + } + node.insertBefore(folderNode, childNode); + return acc; + } + } + + node.appendChild(folderNode); + return acc; + }.bind(this), {}); + return folders; + } + + #loadDerivationChildNodes(node, typeName, profile){ var applicableDerivations = AttributeUi.getApplicableDerivations(typeName); + var folders = this.#createFolders(applicableDerivations, node); for (var derivationName in applicableDerivations) { var derivation = applicableDerivations[derivationName]; - config = { + var config = { type: 'derived', derivation: derivationName, title: derivation.title, expressionTemplate: derivation.expressionTemplate, profile: profile }; - childNode = this.#renderAttributeUiNode(config); - node.appendChild(childNode); + var childNode = this.#renderAttributeUiNode(config); + if (derivation.folder) { + folders[derivation.folder].appendChild(childNode); + } + else { + node.appendChild(childNode); + } } + } + #loadAggregatorChildNodes(node, typeName, profile) { var applicableAggregators = AttributeUi.getApplicableAggregators(typeName); + var folders = this.#createFolders(applicableAggregators, node); for (var aggregationName in applicableAggregators) { var aggregator = applicableAggregators[aggregationName]; - config = { + var config = { type: 'aggregate', aggregator: aggregationName, title: aggregator.title, expressionTemplate: aggregator.expressionTemplate, profile: profile }; - childNode = this.#renderAttributeUiNode(config); - node.appendChild(childNode); + var childNode = this.#renderAttributeUiNode(config); + if (aggregator.folder) { + folders[aggregator.folder].appendChild(childNode); + } + else { + node.appendChild(childNode); + } } } + #loadChildNodesForColumnNode(node){ + var columnName = node.getAttribute('data-column_name'); + var columnType = node.getAttribute('data-column_type'); + var profile = { + column_name: columnName, + column_type: columnType + }; + var typeName = /^[^\(]+/.exec(columnType)[0]; + + this.#loadDerivationChildNodes(node, typeName, profile); + this.#loadAggregatorChildNodes(node, typeName, profile); + } + #loadChildNodes(node){ var nodeType = node.getAttribute('data-nodetype'); switch (nodeType){ @@ -577,13 +660,12 @@ class AttributeUi { } } - #clickHandler(event) { var target = event.target; var classNames = getClassNames(target); event.stopPropagation(); - var node = getAncestorWithClassName(target, 'attributeUiNode'); + var node = getAncestorWithTagName(target, 'details'); if (!node) { return; } diff --git a/src/DataSet/TupleSet.js b/src/DataSet/TupleSet.js index 480c9e4..7081e76 100644 --- a/src/DataSet/TupleSet.js +++ b/src/DataSet/TupleSet.js @@ -254,7 +254,21 @@ class TupleSet extends DataSetComponent { return resultset.numRows; } + getCachedTupleCount(offset){ + var data = this.#tuples; + var cachedTupleCount = 0; + for (var i = offset; i < this.#tupleCount; i++){ + var tuple = data[i]; + if (!tuple){ + break; + } + cachedTupleCount += 1; + } + return cachedTupleCount; + } + async getTuples(count, offset){ + var data = this.#tuples; var tuples = []; diff --git a/src/DataSource/DataSourcesUi.css b/src/DataSource/DataSourcesUi.css index 871992a..3810028 100644 --- a/src/DataSource/DataSourcesUi.css +++ b/src/DataSource/DataSourcesUi.css @@ -124,8 +124,6 @@ } #datasourcesUi details > summary > label.analyzeActionButton::before { - /* table */ - /* content: '\eba1';*/ /* analyze */ content: '\f3a3'; } diff --git a/src/DataSource/DataSourcesUi.js b/src/DataSource/DataSourcesUi.js index 682b749..1ad0935 100644 --- a/src/DataSource/DataSourcesUi.js +++ b/src/DataSource/DataSourcesUi.js @@ -20,6 +20,11 @@ class DataSourcesUi { var dataTransfer = event.dataTransfer; dataTransfer.dropEffect = 'copy'; return; + + // unfortunately, we see that the files list is always emtpy. + // instead, when dragging files, we see a list of items of type file, but for some reason we do not see the names of the files. + // so this is pretty much useless, we cannot figure out in advance if the dragged items could be successfully loaded. + var files = dataTransfer.files; valid = Boolean(files.length); var fileTypes = DuckDbDataSource.fileTypes; @@ -50,7 +55,6 @@ class DataSourcesUi { event.preventDefault(); var dataTransfer = event.dataTransfer; - console.log('dragover'); } #dropHandler(event) { @@ -58,10 +62,28 @@ class DataSourcesUi { event.stopPropagation(); var dataTransfer = event.dataTransfer; var files = dataTransfer.files; + var items = dataTransfer.items; if (files.length) { uploadUi.uploadFiles(files); } - console.log('drop'); + else + if (items.length){ + for (var i = 0 ; i < items.length; i++) { + var item = items[i]; + if (item.kind !== 'string') { + continue; + } + if (item.type !== 'text/uri-list'){ + continue; + } + item.getAsString(function(uri){ + uploadUi.uploadFiles([uri]); + }); + + // support only 1 url at a time. + return; + } + } } getDom(){ @@ -210,7 +232,7 @@ class DataSourcesUi { showLoadDatasourcesHint(){ if (!Object.keys(this.#datasources).length){ - this.getDom().innerHTML = ``; + this.getDom().innerHTML = ``; } } diff --git a/src/DataSource/duckdb/DuckDbDataSource.js b/src/DataSource/duckdb/DuckDbDataSource.js index 5b9b9c8..af6ab17 100644 --- a/src/DataSource/duckdb/DuckDbDataSource.js +++ b/src/DataSource/duckdb/DuckDbDataSource.js @@ -91,6 +91,26 @@ class DuckDbDataSource extends EventEmitter { }; } + // this is a light weight method that should produce the id of a datasource that would be created for the given file. + // this should not actually instantiate a datasource, merely its identifier. + // It is a service to easily create UI elements that may refer to a datasource without having to actually create one. + static getDatasourceIdForFileName(fileName){ + return `${this.types.FILE}:${getQuotedIdentifier(fileName)}`; + } + + static createFromUrl(duckdb, instance, url) { + if (!(typeof url === 'string')){ + throw new Error(`The url should be of type string`); + } + + var config = { + type: DuckDbDataSource.types.FILE, + fileName: url + }; + var instance = new DuckDbDataSource(duckdb, instance, config); + return instance; + } + static createFromFile(duckdb, instance, file) { if (!(file instanceof File)){ throw new Error(`The file argument must be an instance of File`); @@ -190,6 +210,10 @@ class DuckDbDataSource extends EventEmitter { var type = this.getType(); var postFix; switch (type) { + case DuckDbDataSource.types.FILE: + var fileName = this.getFileName(); + var id = DuckDbDataSource.getDatasourceIdForFileName(fileName); + return id; case DuckDbDataSource.types.FILES: postFix = JSON.stringify(this.#fileNames); break; @@ -349,6 +373,9 @@ class DuckDbDataSource extends EventEmitter { case 'xlsx': var fileType = DuckDbDataSource.fileTypes[fileExtension]; sql = `${fileType.duckdb_reader}( ${quotedFileName} )`; + break; + default: // for urls we will be lenient for now + sql = quotedFileName; } break; case DuckDbDataSource.types.SQLQUERY: diff --git a/src/FilterUi/FilterUi.css b/src/FilterUi/FilterUi.css index 8c83003..a5c7bd0 100644 --- a/src/FilterUi/FilterUi.css +++ b/src/FilterUi/FilterUi.css @@ -67,11 +67,38 @@ dialog#filterDialog > footer > button { width: 5.5em; } +/* clear selected/highlighted button */ dialog#filterDialog > section > footer > button#filterDialogClearSelectedButton { - width: 8.5em; + width: 9em; } + +dialog#filterDialog > section:not( section > div > select > option:checked ) > footer[role=toolbar] > button#filterDialogClearSelectedButton { + pointer-events: none; + color: var( --huey-placeholder-color ); + background-color: var( --huey-medium-background-color ); +} + +dialog#filterDialog > section:has( > section > div > select > option:checked ) > footer[role=toolbar] > button#filterDialogClearSelectedButton { + pointer-events: auto; + color: var( --huey-foreground-color ); + background-color: var( --huey-dark-background-color ); +} + +/* clear all button */ dialog#filterDialog > section > footer > button#filterDialogClearButton{ - width: 8.5em; + width: 9em; +} + +dialog#filterDialog > section:not( section > div > select > option ) > footer[role=toolbar] > button#filterDialogClearButton { + pointer-events: none; + color: var( --huey-placeholder-color ); + background-color: var( --huey-medium-background-color ); +} + +dialog#filterDialog > section:has( > section > div > select > option ) > footer[role=toolbar] > button#filterDialogClearButton { + pointer-events: auto; + color: var( --huey-foreground-color ); + background-color: var( --huey-dark-background-color ); } dialog#filterDialog section > header { @@ -87,7 +114,8 @@ dialog#filterDialog section > section { } dialog#filterDialog > section > section { - flex-grow: 1.25 + flex-grow: 1.25; + margin-top: 4px; } dialog#filterDialog > section > header > section > section > select#filterPicklist > optgroup > option[data-next-page-loader=true] { @@ -109,10 +137,6 @@ dialog#filterDialog > section { padding: 2px; } -dialog#filterDialog > section > section { - margin-top: 4px; -} - dialog#filterDialog section { display:flex; flex-direction: column; @@ -124,6 +148,10 @@ dialog#filterDialog section > footer { padding-bottom: 2px; } +dialog#filterDialog section > footer[role=toolbar]:has( > button ){ + text-align: center; +} + dialog#filterDialog > section > header > section > header > input[type=search]#filterSearch { border-bottom-left-radius: 0px; border-bottom-right-radius: 0px; @@ -141,7 +169,7 @@ dialog#filterDialog > section > header > section > section > select#filterPickli resize: vertical; } -dialog#filterDialog > section > header > section > footer[role=status] { +dialog#filterDialog section > footer[role=status] { font-size: smaller; } diff --git a/src/FilterUi/FilterUi.js b/src/FilterUi/FilterUi.js index ab8ff6a..d5ee0ff 100644 --- a/src/FilterUi/FilterUi.js +++ b/src/FilterUi/FilterUi.js @@ -1,5 +1,5 @@ class FilterDialog { - + static #numRowsColumnName = '__huey_numrows'; static filterTypes = { INCLUDE: 'in', @@ -7,14 +7,14 @@ class FilterDialog { BETWEEN: 'between', NOTBETWEEN: 'notbetween' }; - + #id = undefined; #queryAxisItem = undefined; #queryModel = undefined; - + #valuePicklistPageSize = 100; #searchAutoQueryTimeout = 1000; - + constructor(config){ this.#id = config.id; this.#queryModel = config.queryModel; @@ -23,14 +23,14 @@ class FilterDialog { #initEvents(){ var filterDialog = this.getDom(); - + // Ok button confirms the filter settings and stores them in the model this.#getOkButton().addEventListener('click', function(event){ var dialogState = this.#getDialogState(); this.#queryModel.setQueryAxisItemFilter(this.#queryAxisItem, dialogState); filterDialog.close(); }.bind(this)); - + this.#getRemoveFilterButton().addEventListener('click', function(event){ this.#queryModel.removeItem(this.#queryAxisItem); filterDialog.close(); @@ -39,11 +39,11 @@ class FilterDialog { this.#getCancelButton().addEventListener('click', function(event){ filterDialog.close(); }.bind(this)); - + // Clear button clears the values lists this.#getClearButton().addEventListener('click', function(event){ this.clearFilterValueLists(); - + this.#updateValueSelectionStatusText(); }.bind(this)); @@ -52,13 +52,13 @@ class FilterDialog { this.#removeSelectedValues(); this.#updateValueSelectionStatusText(); }.bind(this)); - + // Selecting values in the picklist adds them to the value lists (behavior depends on the filter type) this.#getValuePicklist().addEventListener('change', this.#handleValuePicklistChange.bind(this)); - + this.#getFilterValuesList().addEventListener('change', this.#handleFilterValuesListChange.bind(this)); this.#getToFilterValuesList().addEventListener('change', this.#handleToFilterValuesListChange.bind(this)); - + // When the filterType is set to a range type (BETWEEN/NOTBETWEEN), the two value lists share a scrollbar. // this handler ensures the scrolbar moves both lists. this.#getToFilterValuesList().addEventListener('scroll', function(event){ @@ -84,7 +84,7 @@ class FilterDialog { width = ''; } element.style.width = width; - + // reset the width again so the resizer can manage the width. element.style.width = ''; this.#getValuePicklist().selectedIndex = -1; @@ -94,22 +94,22 @@ class FilterDialog { var includeAllFiltersCheckbox = this.#getIncludeAllFilters(); includeAllFiltersCheckbox.checked = settings.getSettings(['filterDialogSettings', 'filterSearchApplyAll']); includeAllFiltersCheckbox.addEventListener('change', function(event){ - // TODO: check to see if there are any other filter items, + // TODO: check to see if there are any other filter items, // and also if they have any filter condition set. // If we can avoid a query, then return, if not, repopulate the list. var target = event.target; settings.assignSettings(['filterDialogSettings', 'filterSearchApplyAll'], target.checked); - + this.#updatePicklist(0, this.#valuePicklistPageSize); }.bind(this)); - + bufferEvents(this.#getSearch(), 'input', function(event, count){ if (count === undefined) { this.#updatePicklist(0, this.#valuePicklistPageSize); } }, this, this.#searchAutoQueryTimeout); } - + #handleFilterValuesListChange(event){ if (event.target.selectedOptions.length){ this.#getValuePicklist().selectedIndex = -1; @@ -122,11 +122,11 @@ class FilterDialog { this.#getFilterValuesList().selectedIndex = -1; } } - + #sortValues(values){ var dataType = QueryAxisItem.getQueryAxisItemDataType(this.#queryAxisItem); if (dataType){ - var dataTypeInfo = dataTypes[dataType]; + var dataTypeInfo = getDataTypeInfo(dataType); if (dataTypeInfo.isNumeric){ return values.sort(function(a, b){ a = parseFloat(a); @@ -143,7 +143,7 @@ class FilterDialog { } return values.sort(); } - + #sortValueLists(valuesList, toValuesList){ var sortedValuesList = {}, sortedToValuesList, toKeys; var keys = Object.keys(valuesList); @@ -152,7 +152,7 @@ class FilterDialog { sortedToValuesList = {}; toKeys = Object.keys(toValuesList); } - + sortedKeys.forEach(function(key){ sortedValuesList[key] = valuesList[key]; if (toValuesList) { @@ -160,52 +160,62 @@ class FilterDialog { sortedToValuesList[toKey] = toValuesList[toKey]; } }); - + return { - valuesList: sortedValuesList, + valuesList: sortedValuesList, toValuesList: sortedToValuesList }; } - + + #extractValuesFromOption(option){ + var valueObject = { + value: option.value, + label: option.label, + literal: option.getAttribute('data-sql-literal') + } + if (option.getAttribute('data-sql-null') === String(true)) { + valueObject.isSqlNull = true; + } + return valueObject; + } + + #createOptionElementFromValues(valueObject){ + var optionElement = createEl('option', { + value: valueObject.value, + label: valueObject.label, + "data-sql-literal": valueObject.literal + }); + if (valueObject.isSqlNull){ + optionElement.setAttribute('data-sql-null', true); + } + return optionElement; + } + #extractOptionsFromSelectList(selectList){ var optionObjects = {}; var options = selectList.options; for (var i = 0; i < options.length; i++){ var option = options[i]; - var valueObject = { - value: option.value, - label: option.label, - literal: option.getAttribute('data-sql-literal') - }; - if (option.getAttribute('data-sql-null') === String(true)) { - valueObject.isSqlNull = true; - } + var valueObject = this.#extractValuesFromOption(option); optionObjects[option.value] = valueObject; } return optionObjects; } - + #renderOptionsToSelectList(options, selectList){ if (options === undefined) { return; } var values = Object.keys(options); - + for (var i = 0; i < values.length; i++){ var value = values[i]; - var optionObject = options[value]; - var optionElement = createEl('option', { - value: optionObject.value, - label: optionObject.label, - "data-sql-literal": optionObject.literal - }); - if (optionObject.isSqlNull){ - optionElement.setAttribute('data-sql-null', true); - } + var valueObject = options[value]; + var optionElement = this.#createOptionElementFromValues(valueObject); selectList.appendChild(optionElement); - } + } } - + #handleValuePicklistChange(event){ var isSqlNull; var valueSelectionStatusText = undefined; @@ -225,7 +235,7 @@ class FilterDialog { return; } } - + var filterType = this.#getFilterType().value; var isRangeFilterType; switch (filterType){ @@ -236,16 +246,16 @@ class FilterDialog { default: isRangeFilterType = false; } - + var filterValuesList = this.#getFilterValuesList(); var filterValuesListOptions = filterValuesList.options; var currentValues = this.#extractOptionsFromSelectList(filterValuesList); - - var toFilterValuesList, currentToValues; + + var toFilterValuesList, currentToValues; // these are used to set a selection in either the values list or the values to list. var restoreSelectionInValueList, restoreSelectionValue; - + // get the current selection and create new options out of it. if (isRangeFilterType) { toFilterValuesList = this.#getToFilterValuesList(); @@ -253,15 +263,15 @@ class FilterDialog { currentToValues = this.#extractOptionsFromSelectList(toFilterValuesList); var rangeStart, rangeEnd; - - // The following condition captures the case where the user selected 1 option in the picklist, - // and either the values list or the to values list also has 1 option selected. + + // The following condition captures the case where the user selected 1 option in the picklist, + // and either the values list or the to values list also has 1 option selected. // In action is then to use the picklist value to update that end of a range. if ( selectedOptions.length === 1 && selectedOption.getAttribute('data-sql-null') !== String(true) && ( filterValuesList.selectedOptions.length === 1 && toFilterValuesList.selectedOptions.length === 0 && filterValuesList.selectedOptions[0].getAttribute('data-sql-null') !== String(true) || filterValuesList.selectedOptions.length === 0 && toFilterValuesList.selectedOptions.length === 1 && toFilterValuesList.selectedOptions[0].getAttribute('data-sql-null') !== String(true) - ) + ) ) { var selectedList = filterValuesList.selectedOptions.length ? filterValuesList : toFilterValuesList; @@ -273,11 +283,11 @@ class FilterDialog { var selectedIndex = selectedList.selectedIndex; var option = selectedList.options[selectedIndex]; - var optionValue = option.value; + var optionValue = option.value; var correspondingOption = correspondingList.options[selectedIndex]; - var correspondingValue = correspondingOption.value; - + var correspondingValue = correspondingOption.value; + if (values[selectedOption.value]) { // invalid choice, range already exists valueSelectionStatusText = `Existing range collision.`; @@ -296,19 +306,15 @@ class FilterDialog { delete values[option.value]; var correspondingOptionValueObject = correspondingValues[correspondingValue]; delete correspondingValues[correspondingValue]; - - values[selectedOption.value] = { - value: selectedOption.getAttribute('value'), - label: selectedOption.getAttribute('label'), - literal: selectedOption.getAttribute('data-sql-literal') - }; + + values[selectedOption.value] = this.#extractValuesFromOption(selectedOption); correspondingValues[correspondingValue] = correspondingOptionValueObject; valueSelectionStatusText = `Range modified.`; } - + if (filterValuesList.selectedOptions.length === 1) { // if the values list had a selected item, we will restore that selection. - // (if the to values list had a selected item, it could be the result of adding a new range, + // (if the to values list had a selected item, it could be the result of adding a new range, // and in that case we don't want to restore the selection because the likely new action is adding a new range, not editing the existing range.) restoreSelectionInValueList = filterValuesList; restoreSelectionValue = optionValue; @@ -318,36 +324,24 @@ class FilterDialog { // go through the options, and add one pair of from/to values for a set of adjacent selected options for (var i = 0; i < options.length; i++){ var option = options[i]; - - if (option.selected) { - isSqlNull = selectedOption.getAttribute('data-sql-null') === String(true); + if (option.selected) { // no range start, this is the start of a new range. if (rangeStart === undefined) { - rangeStart = { - value: option.getAttribute('value'), - label: option.getAttribute('label'), - literal: option.getAttribute('data-sql-literal'), - isSqlNull: isSqlNull - }; + rangeStart = this.#extractValuesFromOption(option); } - + // update the end of the current range (we keep updating it as long as the options are selected) if (rangeStart !== undefined) { - rangeEnd = { - value: option.getAttribute('value'), - label: option.getAttribute('label'), - literal: option.getAttribute('data-sql-literal'), - isSqlNull: isSqlNull - }; + rangeEnd = this.#extractValuesFromOption(option); } } - + // if the option is not selected, or if we are the last option, then add the current range. if((option.selected !== true || i === options.length -1) && rangeStart){ // check if we should add the new range if ( - currentValues[rangeStart.value] === undefined || + currentValues[rangeStart.value] === undefined || JSON.stringify(currentValues[rangeStart.value]) === JSON.stringify(currentToValues[rangeStart.value]) ){ currentValues[rangeStart.value] = rangeStart; @@ -363,32 +357,26 @@ class FilterDialog { rangeStart = rangeEnd = undefined; } } - valueSelectionStatusText = `Range created.`; + valueSelectionStatusText = `Range created.`; } } else { // go through the new options, and add them if they aren't already in the list. for (var i = 0; i < selectedOptions.length; i++) { selectedOption = selectedOptions[i]; - isSqlNull = selectedOption.getAttribute('data-sql-null') === String(true); if (currentValues[selectedOption.value] !== undefined) { continue; } - currentValues[selectedOption.value] = { - value: selectedOption.getAttribute('value'), - label: selectedOption.getAttribute('label'), - literal: selectedOption.getAttribute('data-sql-literal'), - isSqlNull: isSqlNull - } + currentValues[selectedOption.value] = this.#extractValuesFromOption(selectedOption); } } - + // clear the value lists and then update them with the changed set of values this.clearFilterValueLists(); var sortedValueLists = this.#sortValueLists(currentValues, currentToValues) this.#renderOptionsToSelectList(sortedValueLists.valuesList, filterValuesList); this.#renderOptionsToSelectList(sortedValueLists.toValuesList, toFilterValuesList); - + if (valueSelectionStatusText){ this.#setValueSelectionStatusText(valueSelectionStatusText); } @@ -400,7 +388,7 @@ class FilterDialog { if (restoreSelectionInValueList === undefined) { return ; } - + // finally, restore the selection in the value lists. filterValuesListOptions = restoreSelectionInValueList.options; for (var i = 0; i < filterValuesListOptions.length; i++){ @@ -410,10 +398,10 @@ class FilterDialog { filterValuesListOptions.selectedIndex = i; break; } - + // the end. } - + #removeSelectedValues(){ var selectControl = this.#getFilterValuesList(); var options = selectControl.options; @@ -426,21 +414,15 @@ class FilterDialog { if (i < toValuesOptions.length){ toOption = toValuesOptions[i]; } - + if (option.selected || toOption && toOption.selected) { // either side has a selected option, which should be removed. } else { // neither side has a selected option, so we add it to preserve - currentValues[option.value] = { - value: option.value, - label: option.label, - }; + currentValues[option.value] = this.#extractValuesFromOption(option); if (toOption){ - currentToValues[toOption.value] = { - value: toOption.value, - label: toOption.label, - }; + currentToValues[toOption.value] = this.#extractValuesFromOption(toOption); } } } @@ -448,7 +430,7 @@ class FilterDialog { this.clearFilterValueLists(); this.#renderOptionsToSelectList(currentValues, selectControl); this.#renderOptionsToSelectList(currentToValues, toValuesList); - + this.#getValuePicklist().selectedIndex = -1; } @@ -462,11 +444,11 @@ class FilterDialog { #getOkButton(){ return byId('filterDialogOkButton'); } - + #getRemoveFilterButton(){ return byId('filterDialogRemoveButton'); } - + #getCancelButton(){ return byId('filterDialogCancelButton'); } @@ -480,9 +462,9 @@ class FilterDialog { } #getSpinner(){ - return byId('filterDialogSpinner'); + return byId('filterDialogSpinner'); } - + setBusy(trueOrFalse){ var dom = this.getDom(); dom.setAttribute('aria-busy', String(trueOrFalse)); @@ -491,7 +473,7 @@ class FilterDialog { #getSearch(){ return byId('filterSearch'); } - + #getSearchStatus(){ return byId('filterSearchStatus'); } @@ -499,11 +481,11 @@ class FilterDialog { #getValueSelectionStatus(){ return byId('filterValueSelectionStatus'); } - + #setValueSelectionStatusText(text){ this.#getValueSelectionStatus().innerText = text; } - + #updateValueSelectionStatusText(){ var text; var count = this.#getFilterValuesList().options.length; @@ -511,16 +493,27 @@ class FilterDialog { text = 'Select values from the picklist'; } else { + var object = 'value', verb; switch (this.#getFilterType().value) { case FilterDialog.filterTypes.INCLUDE: + verb = 'included'; + break; case FilterDialog.filterTypes.EXCLUDE: - text = `${count} values selected.`; + verb = 'excluded'; break; case FilterDialog.filterTypes.BETWEEN: + object += ' range'; + verb = 'included'; + break; case FilterDialog.filterTypes.NOTBETWEEN: - text = `${count} value ranges selected.`; + object += ' range'; + verb = 'excluded'; break; } + if (count > 1){ + object += 's'; + } + text = `${count} ${object} ${verb}.`; } this.#setValueSelectionStatusText(text); } @@ -536,7 +529,7 @@ class FilterDialog { #getValuePicklist(){ return byId('filterPicklist'); } - + clearValuePicklist(){ this.#getValuePicklist().innerHTML = ''; } @@ -544,7 +537,7 @@ class FilterDialog { #getFilterValuesList(){ return byId('filterValueList'); } - + #getToFilterValuesList(){ return byId('toFilterValueList'); } @@ -556,45 +549,45 @@ class FilterDialog { clearToFilterValueList(){ this.#getToFilterValuesList().innerHTML = ''; } - + clearFilterValueLists() { this.clearFilterValueList(); this.clearToFilterValueList(); } - + #positionFilterDialog(queryAxisItemUi){ var boundingRect = queryAxisItemUi.getBoundingClientRect(); var filterDialog = this.getDom(); - filterDialog.style.left = boundingRect.x + 'px' + filterDialog.style.left = boundingRect.x + 'px' filterDialog.style.top = (boundingRect.y + boundingRect.height) + 'px'; } - + #clearDialog(){ this.#getValuePicklist().innerHTML = ''; this.clearFilterValueLists(); this.#getSearch().value = ''; } - + async openFilterDialog(queryModel, queryModelItem, queryAxisItemUi){ this.#clearDialog(); - + this.#queryAxisItem = queryModelItem; this.#queryModel = queryModel; - + this.#updateDialogState(); - + this.#positionFilterDialog(queryAxisItemUi); var filterDialog = this.getDom(); filterDialog.showModal(); this.#updatePicklist(0, this.#valuePicklistPageSize); } - + #updateDialogState(){ var queryAxisItem = this.#queryAxisItem; var filter = queryAxisItem.filter; if (filter){ this.#getFilterType().value = filter.filterType; - + var sortedValues, sortedValueArgs = [filter.values]; switch (filter.filterType) { case FilterDialog.filterTypes.BETWEEN: @@ -603,14 +596,14 @@ class FilterDialog { } sortedValues = this.#sortValueLists.apply(this, sortedValueArgs); this.#renderOptionsToSelectList(sortedValues.valuesList, this.#getFilterValuesList()); - this.#renderOptionsToSelectList(sortedValues.toValuesList, this.#getToFilterValuesList()); + this.#renderOptionsToSelectList(sortedValues.toValuesList, this.#getToFilterValuesList()); } else { - + } this.#updateValueSelectionStatusText(); } - + #getDialogState(){ var dialogState = { filterType: this.#getFilterType().value, @@ -619,12 +612,12 @@ class FilterDialog { }; return dialogState; } - + async #updatePicklist(offset, limit){ var result = await this.#getPicklistValues(offset, limit); this.#populatePickList(result, offset, limit); } - + #getOtherFilterAxisItems(withFilterValues){ var filtersAxis = this.#queryModel.getFiltersAxis(); var filtersAxisItems = filtersAxis.getItems(); @@ -635,7 +628,7 @@ class FilterDialog { if ( filterAxisItem.columnName === this.#queryAxisItem.columnName && filterAxisItem.derivation === this.#queryAxisItem.derivation && - filterAxisItem.aggregator === this.#queryAxisItem.aggregator + filterAxisItem.aggregator === this.#queryAxisItem.aggregator ){ return false; } @@ -643,11 +636,11 @@ class FilterDialog { if (!filterAxisItem.filter) { return false; } - + if (!filterAxisItem.filter.values) { return false; } - + if (!Object.keys(filterAxisItem.filter.values).length){ return false; } @@ -656,7 +649,7 @@ class FilterDialog { }.bind(this)); return otherFilterAxisItems; } - + async #getPicklistValues(offset, limit){ this.setBusy(true); @@ -664,7 +657,7 @@ class FilterDialog { var searchStatus = this.#getSearchStatus(); searchStatus.innerHTML = 'Finding values...'; } - + var datasource = this.#queryModel.getDatasource(); var sqlExpression = QueryAxisItem.getSqlForQueryAxisItem(this.#queryAxisItem); @@ -687,7 +680,7 @@ class FilterDialog { fromClause ]; } - + var condition = ''; var filterSearchApplyAll = settings.getSettings(['filterDialogSettings', 'filterSearchApplyAll']); if (filterSearchApplyAll) { @@ -701,7 +694,7 @@ class FilterDialog { } } } - + var search = this.#getSearch(); var searchString = search.value.trim(); var parameters = []; @@ -720,15 +713,15 @@ class FilterDialog { break; } } - + if (condition) { sql.push(`WHERE ${condition}`); - } - + } + if (bindValue){ parameters.push(bindValue); } - + if (offset === 0){ sql.push(`GROUP BY ${sqlExpression}`); } @@ -739,13 +732,13 @@ class FilterDialog { } parameters.push(limit); sql.push('LIMIT ?'); - + if (offset === undefined){ offset = 0; } parameters.push(offset); sql.push('OFFSET ?'); - + sql = sql.join('\n'); console.log(`Preparing sql for filter dialog value picklist:`); console.log(sql); @@ -756,9 +749,9 @@ class FilterDialog { console.time(timeMessage); var result = await preparedStatement.query.apply(preparedStatement, parameters); console.timeEnd(timeMessage); - return result; + return result; } - + #populatePickList(resultset, offset, limit){ if (offset === 0) { var searchStatus = this.#getSearchStatus(); @@ -768,20 +761,20 @@ class FilterDialog { } searchStatus.innerHTML = `${count} values found.`; } - + var listOfValues = this.#getValuePicklist(); - + var exhausted = resultset.numRows < limit; var optionGroup, optionsContainer; var optionGroupLabelText = `Values ${offset + 1} - ${offset + resultset.numRows}`; - + if (offset === 0) { this.clearValuePicklist(); if (exhausted) { optionsContainer = listOfValues; } - else { + else { optionGroup = createEl('optgroup', { label: optionGroupLabelText, "data-offset": offset + limit, @@ -800,7 +793,7 @@ class FilterDialog { optionGroup.innerHTML = ''; optionsContainer = optionGroup; } - + var formatter = this.#queryAxisItem.formatter; var valueField, labelField; if (formatter) { @@ -858,7 +851,7 @@ class FilterDialog { optionGroup.appendChild(option); listOfValues.appendChild(optionGroup); } - + getDom(){ return byId(this.#id); } diff --git a/src/PivotTableUi/PivotTableUi.css b/src/PivotTableUi/PivotTableUi.css index aee3eaf..9868334 100644 --- a/src/PivotTableUi/PivotTableUi.css +++ b/src/PivotTableUi/PivotTableUi.css @@ -127,7 +127,7 @@ top: 50%; z-index: 100; width: 200px; - height: 160px; + height: 180px; background-color: var( --huey-light-background-color ); border-color: var( --huey-light-border-color ); border-style: solid; @@ -135,6 +135,10 @@ border-radius: 20px; } +.pivotTableUiContainer > *[role=progressbar] > * { + background-color: var( --huey-light-background-color ); +} + .pivotTableUiContainer[aria-busy=false] > *[role=progressbar] { display: none; } @@ -152,6 +156,19 @@ border-bottom-color: var( --huey-dark-border-color ); } +.pivotTableUiContainer > .pivotTableUiInnerContainer > .pivotTableUiTable > .pivotTableUiTableHeader > .pivotTableUiRow:has( + .pivotTableUiRow > .pivotTableUiRowsAxisHeaderCell) > .pivotTableUiHeaderCell:not([data-totals]) { + border-bottom-color: var( --huey-dark-border-color ); +} + + +.pivotTableUiContainer > .pivotTableUiInnerContainer > .pivotTableUiTable > .pivotTableUiTableHeader > .pivotTableUiRow:last-child > .pivotTableUiRowsAxisHeaderCell:has( + .pivotTableUiHeaderCell[data-totals] ) { + border-right-color: var( --huey-dark-border-color ); +} + +.pivotTableUiContainer > .pivotTableUiInnerContainer > .pivotTableUiTable > .pivotTableUiTableHeader > .pivotTableUiRow:last-child > .pivotTableUiRowsAxisHeaderCell { + border-bottom-color: var( --huey-dark-border-color ); +} + .pivotTableUiContainer > .pivotTableUiInnerContainer > .pivotTableUiTable .pivotTableUiRow > .pivotTableUiCell > .pivotTableUiCellLabel { overflow: hidden; text-overflow: ellipsis; @@ -173,7 +190,7 @@ border-top-color: var( --huey-dark-border-color ); } -.pivotTableUiContainer > .pivotTableUiInnerContainer > .pivotTableUiTable .pivotTableUiTableHeader > .pivotTableUiRow > .pivotTableUiCell:has(+ .pivotTableUiColumnsAxisHeaderCell) { +.pivotTableUiContainer > .pivotTableUiInnerContainer > .pivotTableUiTable .pivotTableUiTableHeader > .pivotTableUiRow > .pivotTableUiCell:has( + .pivotTableUiColumnsAxisHeaderCell ) { border-right-color: var( --huey-dark-border-color ); } diff --git a/src/PivotTableUi/PivotTableUi.js b/src/PivotTableUi/PivotTableUi.js index 9cf0479..ba54719 100644 --- a/src/PivotTableUi/PivotTableUi.js +++ b/src/PivotTableUi/PivotTableUi.js @@ -518,8 +518,7 @@ class PivotTableUi { #renderCellValue(cell, cellsAxisItem, cellElement){ var label = getChildWithClassName(cellElement, 'pivotTableUiCellLabel'); if (!cell || !cellsAxisItem){ - label.innerText = ''; - return; + return label.innerText = ''; } var values = cell.values; @@ -545,7 +544,7 @@ class PivotTableUi { else { labelText = String(value); } - label.innerText = labelText; + label.innerText = labelText; return labelText } @@ -603,7 +602,7 @@ class PivotTableUi { var cellIndex; - for (var i = 0; i < rowCount; i++){ + for (var i = 0; i < tableBodyRows.length - 1; i++){ var tableRow = tableBodyRows.item(i); var cellElements = tableRow.childNodes; @@ -612,7 +611,7 @@ class PivotTableUi { rowsAxisTupleIndex = rowTupleIndexInfo.tupleIndex; } - for (var j = headerColumnCount; j < headerColumnCount + columnCount; j++){ + for (var j = headerColumnCount; j < headerColumnCount + (firstTableHeaderRowCells.length - headerColumnCount - 1); j++){ if (j === headerColumnCount) { columnsAxisTupleIndex = columnTupleIndexInfo.tupleIndex; @@ -630,7 +629,7 @@ class PivotTableUi { if (cells) { cell = cells[cellIndex]; } - var cellsAxisItem = cellsAxisItems[cellsAxisItemIndex]; + var cellsAxisItem = cellsAxisItems[cellsAxisItemIndex]; var labelText = this.#renderCellValue(cell, cellsAxisItem, cellElement); // adjust the column width if necessary. @@ -1047,7 +1046,7 @@ class PivotTableUi { bodyRow.appendChild(cell); - var labelText; + var labelText = undefined; if (j < rowAxisItems.length) { if (k === 0 && tuple) { var value = tuple.values[j]; diff --git a/src/QueryModel/QueryModel.js b/src/QueryModel/QueryModel.js index b69ac97..9064266 100644 --- a/src/QueryModel/QueryModel.js +++ b/src/QueryModel/QueryModel.js @@ -32,7 +32,7 @@ class QueryAxisItem { } if (dataType) { - var dataTypeInfo = dataTypes[dataType]; + var dataTypeInfo = getDataTypeInfo(dataType); if (dataTypeInfo) { if (dataTypeInfo.createFormatter){ return dataTypeInfo.createFormatter(); @@ -52,7 +52,7 @@ class QueryAxisItem { static createLiteralWriter(axisItem){ var dataType = QueryAxisItem.getQueryAxisItemDataType(axisItem); - var dataTypeInfo = dataTypes[dataType]; + var dataTypeInfo = getDataTypeInfo(dataType); return dataTypeInfo.createLiteralWriter(); } @@ -727,6 +727,10 @@ class QueryModel extends EventEmitter { // (note that the normal getters return copies) var axis = this.getQueryAxis(queryModelItem.axis); var items = axis.getItems(); + + if (!Object.keys(filter.values).length){ + filter = undefined; + } items[queryModelItem.index].filter = filter; var axesChangeInfo = {}; diff --git a/src/QueryUi/QueryUi.css b/src/QueryUi/QueryUi.css index 0348ab8..b63eb30 100644 --- a/src/QueryUi/QueryUi.css +++ b/src/QueryUi/QueryUi.css @@ -189,8 +189,8 @@ } .queryUi > section > ol > li[data-derivation="week num"] > span:before { - /* letter w */ - content: "\ec66"; + /* calendar-week */ + content: "\fd30"; } .queryUi > section > ol > li[data-derivation="day of year"] > span:before { @@ -199,13 +199,13 @@ } .queryUi > section > ol > li[data-derivation="day of month"] > span:before { - /* letter-d */ - content: "\ec53"; + /* calendar-month */ + content: "\fd2f"; } .queryUi > section > ol > li[data-derivation="day of week"] > span:before { - /* letter-d */ - content: "\ec53"; + /* letter-d-small */ + content: "\fcca"; } .queryUi > section > ol > li[data-derivation="iso-time"] > span:before { @@ -371,13 +371,18 @@ content: "\faff"; } -.queryUi > section[data-axis=filters] > ol > li > menu > label:nth-child(2):has( > button )::after { +.queryUi > section[data-axis=filters] > ol > li > menu > label:nth-child(1):has( > button )::after { /* filter-edit */ /* content: "\fa00"; */ /* filter cog */ content: "\f9fe"; } +.queryUi > section[data-axis=filters] > ol > li > menu > label:nth-child(2):has( > button )::after { + /* trash */ + content: "\eb41"; +} + /* axis item action to remove item from axis */ .queryUi > section > ol > li > menu > label:nth-child(3):has( > button )::after { /* trash */ diff --git a/src/Search/Search.css b/src/Search/Search.css index 6d2d1af..7df4a20 100644 --- a/src/Search/Search.css +++ b/src/Search/Search.css @@ -11,6 +11,6 @@ width: 100%; } -.attributeUi .attributeUiNode[data-matches-searchstring=false] { +.attributeUi details[data-matches-searchstring=false] { display: none; } diff --git a/src/Search/Search.js b/src/Search/Search.js index 03cb06d..ae295c8 100644 --- a/src/Search/Search.js +++ b/src/Search/Search.js @@ -1,7 +1,7 @@ function clearSearch(){ byId('searchAttribute').value = ''; - var attributeUi = byId('attributeUi'); - var attributeNodes = attributeUi.getElementsByClassName('attributeUiNode'); + var attributeUi = byId('attributeUi'); + var attributeNodes = attributeUi.getElementsByTagName('details'); for (var i = 0; i < attributeNodes.length; i++){ var attributeNode = attributeNodes.item(i); attributeNode.setAttribute('data-matches-searchstring', ''); @@ -16,7 +16,7 @@ function handleAttributeSearch(event, count){ var searchString = searchElement.value.trim().toUpperCase(); console.log(searchString); var attributeUi = byId('attributeUi'); - var attributeNodes = attributeUi.getElementsByClassName('attributeUiNode'); + var attributeNodes = attributeUi.getElementsByTagName('details'); for (var i = 0; i < attributeNodes.length; i++){ var attributeNode = attributeNodes.item(i); var match; diff --git a/src/Theme/General.css b/src/Theme/General.css index 4b2a509..fb81eb4 100644 --- a/src/Theme/General.css +++ b/src/Theme/General.css @@ -149,7 +149,7 @@ dialog { border-width: 1px; border-color: var( --huey-dark-border-color ); border-radius: 18px; - padding: 15px; + padding: 0em 1em 0em 1em; color: var( --huey-foreground-color ); } @@ -163,6 +163,10 @@ dialog[open] { dialog > header { flex-grow: 0; color: var( --huey-foreground-color ); + position: sticky; + top: 0px; + background-color: var( --huey-medium-background-color ); + padding: .5em 0em .5em 0em; } /* this is used as dialog contents section */ @@ -178,10 +182,12 @@ dialog > form[role=tablist] { /* this is used as dialog buttons section */ dialog > footer { - margin-top: 12px; - margin-bottom: -6px; flex-grow: 0; text-align: center; + position: sticky; + bottom: 0px; + background-color: var( --huey-medium-background-color ); + padding: .5em 0em .5em 0em; } dialog > footer button { @@ -192,6 +198,10 @@ dialog > footer button { color: var( --huey-foreground-color ); } +*[role=toolbar] > button { + user-select: none; +} + dialog h1:first-child, dialog h2:first-child, dialog h3:first-child, diff --git a/src/UploadUi/UploadUi.css b/src/UploadUi/UploadUi.css index 2ff9036..b4130d5 100644 --- a/src/UploadUi/UploadUi.css +++ b/src/UploadUi/UploadUi.css @@ -2,7 +2,6 @@ dialog#uploadUi { resize: both; width: 600px; height: 400px; - padding: 15px; } dialog#uploadUi > section > details > summary > label { @@ -21,6 +20,20 @@ dialog#uploadUi > section > details[aria-invalid=false] > summary > progress { accent-color: green; } +dialog#uploadUi > section > details[aria-invalid=false] > summary > label.analyzeActionButton[for] { + float: right; +} + +dialog#uploadUi > section > details > summary > label.analyzeActionButton > button { + display: none; +} + +dialog#uploadUi > section > details[aria-invalid=false] > summary > label.analyzeActionButton[for]::after { + font-family: var( --huey-icon-font-family ); + font-size: var( --huey-icon-normal ); + content: '\f3a3'; +} + dialog#uploadUi > section > details > summary { width: 100%; } @@ -37,3 +50,20 @@ dialog#uploadUi[aria-busy=true] #uploadDialogOkButton { pointer-events: none; color: var( --huey-placeholder-color ); } + +dialog#uploadUi[aria-busy=false] > header > .timer { + visibility: hidden; +} + + +dialog#uploadUi > header > p#uploadUiDescription > label[for=datasourcesTab] { + color: blue; + text-decoration: underline; + cursor: pointer; +} + +dialog#uploadUi > header > p#uploadUiDescription > span.analyzeActionButton::after { + font-family: var( --huey-icon-font-family ); + font-size: var( --huey-icon-normal ); + content: '\f3a3'; +} diff --git a/src/UploadUi/UploadUi.js b/src/UploadUi/UploadUi.js index 7250291..f2e9bf0 100644 --- a/src/UploadUi/UploadUi.js +++ b/src/UploadUi/UploadUi.js @@ -44,11 +44,18 @@ class UploadUi { var duckDbDataSource; var destroyDatasource = false; try { - duckDbDataSource = DuckDbDataSource.createFromFile(duckdb, instance, file); - progressBar.value = parseInt(progressBar.value, 10) + 20; - - await duckDbDataSource.registerFile(); - progressBar.value = parseInt(progressBar.value, 10) + 40; + if (typeof file === 'string'){ + duckDbDataSource = DuckDbDataSource.createFromUrl(duckdb, instance, file); + progressBar.value = parseInt(progressBar.value, 10) + 20; + } + else + if (file instanceof File){ + duckDbDataSource = DuckDbDataSource.createFromFile(duckdb, instance, file); + progressBar.value = parseInt(progressBar.value, 10) + 20; + + await duckDbDataSource.registerFile(); + progressBar.value = parseInt(progressBar.value, 10) + 40; + } var canAccess = await duckDbDataSource.validateAccess(); progressBar.value = parseInt(progressBar.value, 10) + 30; @@ -77,7 +84,19 @@ class UploadUi { } #createUploadItem(file){ - var fileName = file.name; + var fileName + + if (typeof file === 'string'){ + fileName = file; + } + else + if (file instanceof File) { + fileName = file.name; + } + else { + throw new Error(`Don't know how to handle item of type ${typeof file}`); + } + var uploadItem = createEl('details', { id: fileName }); @@ -89,6 +108,18 @@ class UploadUi { var label = createEl('label', { }, fileName); summary.appendChild(label); + + var datasourceId = DuckDbDataSource.getDatasourceIdForFileName(fileName); + if (datasourceId) { + var analyzeButton = createEl('label', { + "class": 'analyzeActionButton', + "for": `${datasourceId}_analyze`, + "title": `Start exploring data from ${fileName}`, + "onclick": `byId("${this.#id}").close()` + }); + summary.appendChild(analyzeButton); + } + var progressBar = createEl('progress', { id: fileName, max: 100, @@ -127,7 +158,19 @@ class UploadUi { var requiredExtensions = [] for (var i = 0; i < files.length; i++){ var file = files[i]; - var fileName = file.name; + + var fileName; + if (typeof file === 'string') { + fileName = file; + } + else + if (file instanceof File) { + fileName = file.name; + } + else { + throw new Error(`Don't know how to handle item of type ${typeof file}.`); + } + var fileNameParts = DuckDbDataSource.getFileNameParts(fileName); var fileExtension = fileNameParts.lowerCaseExtension; @@ -148,11 +191,13 @@ class UploadUi { return requiredExtensions; } - loadRequiredDuckDbExtensions(requiredDuckDbExtensions){ - var extensionInstallationItems = requiredDuckDbExtensions.map(async function(extensionName){ - var body = this.#getBody(); - var installExtensionItem = this.#createInstallExtensionItem(extensionName); - body.appendChild(installExtensionItem); + async loadDuckDbExtension(extensionName){ + var invalid = true; + var body = this.#getBody(); + var installExtensionItem = this.#createInstallExtensionItem(extensionName); + body.appendChild(installExtensionItem); + + try { var progressbar = installExtensionItem.getElementsByTagName('progress').item(0); var message = installExtensionItem.getElementsByTagName('p').item(0); @@ -192,6 +237,7 @@ class UploadUi { if (row['loaded']){ message.innerHTML += `Extension ${extensionName} is loaded
`; + invalid = false; } else { message.innerHTML += `Extension ${extensionName} not loaded
`; @@ -200,10 +246,28 @@ class UploadUi { await connection.query(`LOAD ${extensionName}`); message.innerHTML += `Extension ${extensionName} is loaded
`; progressbar.value = parseInt(progressbar.value, 10) + 20; + invalid = false; } - progressbar.value = 100; - return true; - }.bind(this)); + if (invalid === false) { + progressbar.value = 100; + } + return !invalid; + } + catch (e){ + message.innerHTML += e.message + '
'; + message.innerHTML += e.stack.split('\n').map(function(stackItem){ + return `
${stackItem}
` + }).join('\n'); + installExtensionItem.setAttribute('open', true); + return e; + } + finally{ + installExtensionItem.setAttribute('aria-invalid', invalid); + } + } + + loadRequiredDuckDbExtensions(requiredDuckDbExtensions){ + var extensionInstallationItems = requiredDuckDbExtensions.map(this.loadDuckDbExtension.bind(this)); return extensionInstallationItems; } @@ -214,7 +278,9 @@ class UploadUi { var numFiles = files.length; var header = this.#getHeader(); - header.innerHTML = `Uploading ${numFiles} file${numFiles === 1 ? '' : 's'}.`; + header.innerText = `Uploading ${numFiles} file${numFiles === 1 ? '' : 's'}.`; + var description = this.#getDescription(); + description.innerText = 'Upload in progress. This will take a few moments...'; dom.showModal(); @@ -270,12 +336,28 @@ class UploadUi { } dom.setAttribute('aria-busy', false); - if (!countFail) { - dom.close(); - } if (datasources.length) { datasourcesUi.addDatasources(datasources); } + var message, description; + var instruction + if (countFail) { + var countSuccess = uploadResults.length - countFail; + if (countSuccess){ + message = `${countSuccess} file${countSuccess > 1 ? 's' : ''} succesfully uploaded, ${countFail} failed.`; + description = 'Some uploads failed. Successfull uploads are available in the . Click the button to start exploring.'; + } + else { + message = `${countFail} file${countFail > 1 ? 's' : ''} failed.`; + description = 'All uploads failed. You can review the errors below:'; + } + } + else { + message = `${uploadResults.length} file${uploadResults.length > 1 ? 's' : ''} succesfully uploaded.` + description = 'Your uploads are available in the . Click the button to start exploring.'; + } + this.#getHeader().innerText = message; + this.#getDescription().innerHTML = description; } getDom(){ @@ -284,9 +366,12 @@ class UploadUi { #getHeader(){ var dom = this.getDom(); - var header = dom.getElementsByTagName('header').item(0); - var h3 = header.getElementsByTagName('h3').item(0); - return h3; + return byId(dom.getAttribute('aria-labelledby')); + } + + #getDescription(){ + var dom = this.getDom(); + return byId(dom.getAttribute('aria-describedby')); } #getBody(){ diff --git a/src/index.html b/src/index.html index 1b593f5..c54dfff 100644 --- a/src/index.html +++ b/src/index.html @@ -86,11 +86,12 @@

+

;

@@ -104,13 +105,9 @@

@@ -545,7 +542,7 @@

Export...

@@ -761,7 +758,7 @@

@@ -772,15 +769,15 @@

About Huey...

A DuckDB User Interface

Created and maintained by Roland.Bouman@gmail.com

-

Donations help keeping Huey in good shape!

+

Donations help to keep Huey in good shape!