1
0
mirror of https://github.com/lana-k/sqliteviz.git synced 2025-12-07 02:28:54 +08:00

50 Commits

Author SHA1 Message Date
lana-k
8aac7af481 update package.json 2023-07-03 23:33:52 +02:00
lana-k
6982204e68 Update currentTab when close tabs #112 2023-07-03 23:13:09 +02:00
lana-k
41e0ae7332 fix test for firefox #110 2023-06-29 23:14:08 +02:00
lana-k
ebb5af4f10 send event when sharing 2023-06-29 22:57:39 +02:00
lana-k
ae26358b25 add test #110 2023-06-29 22:28:41 +02:00
lana-k
d9ee702b8e update papaparse #111 2023-06-29 22:14:28 +02:00
lana-k
446045fa55 Catch parsing errors in compete #110 2023-06-29 22:13:56 +02:00
lana-k
1a9d1b308b check data format #109 2023-06-10 20:05:42 +02:00
lana-k
014ecf145e update version 2023-06-10 19:11:15 +02:00
lana-k
0044d82b6f Loading remote database and inquiries #109 2023-06-05 22:31:39 +02:00
lana-k
998e8d66f7 Tab refactor 2023-06-01 14:42:51 +02:00
lana-k
db3dbdf993 Merge branch 'master' of github.com:lana-k/sqliteviz 2023-05-17 21:41:17 +02:00
lana-k
4e13a16e33 No blocking while loading predifined #107 2023-05-17 21:37:41 +02:00
saaj
9c0103fd05 SQLite 3.41.0 and pearson correlation extension function (#106)
* Build SQLite 3.41.0

* Update pivot_vtab

* Add Pearson correlation coefficient function extension, build

* Add an easy way to running test locally without Nodejs

* Use RSS sum to pick top2 processes for the report

* Try previous Ubuntu LTS as a workaround for Firefox worker not starting
2023-03-04 22:51:25 +01:00
saaj
e4b117ffb9 Sqljs upgrade and benchmark improvements (#103)
* Update to sql.js 1.7.0

* Update to emsdk 3.0.1, replace/remove deprecated/irrelevant settings

- Renamed .bc extension to .o
- Remove deprecated INLINING_LIMIT setting
- Remove SINGLE_FILE

* Update SQLite to 3.39.3

* Collect and plot CPU and RSS charts from the benchmark containers

* Move procpath commands to a playbook, plot only top 2 RSS & CPU usage

* Optimise for size, put -flto for both compile and link
2023-03-04 17:00:46 +01:00
lana-k
6320f818cb fix undefined in tests and chart metrics 2022-07-30 16:42:30 +02:00
lana-k
3c456ef135 fix sql hint: read properties of undefined #99 2022-07-29 15:27:01 +02:00
lana-k
c713c713b7 fix paths #97 2022-07-20 23:15:14 +02:00
lana-k
babf0074c0 add artifact with source map #97 2022-07-20 22:49:26 +02:00
lana-k
e71e6700c1 improve events 2022-07-20 22:47:40 +02:00
lana-k
84e66b8167 Update README.md 2022-07-10 23:18:52 +02:00
lana-k
9e84cf269e Merge branch 'master' of github.com:lana-k/sqliteviz 2022-07-10 22:54:09 +02:00
lana-k
e897b4913b cancel deploying to github pages; remove banner 2022-07-10 22:53:23 +02:00
saaj
0646f58ca0 Build SQLite 3.39.0 (#95) 2022-07-10 22:46:39 +02:00
lana-k
c674bf11e3 update version number 2022-07-10 22:29:20 +02:00
lana-k
2d8a91675e Remove console.log 2022-07-10 22:25:11 +02:00
lana-k
45b1021559 links to website 2022-07-10 21:26:26 +02:00
lana-k
7216e996d1 add banner 2022-07-10 21:25:51 +02:00
lana-k
6eae9a0f2d remove empty lines 2022-07-10 16:02:04 +02:00
lana-k
7486b32bd1 add head extention slot 2022-07-03 15:38:41 +02:00
lana-k
2c564767f8 Merge branch 'master' of github.com:lana-k/sqliteviz 2022-07-01 21:01:07 +02:00
lana-k
289a727cbe fix icon width 2022-07-01 21:01:02 +02:00
saaj
5f2b8ba5a9 Upgrade to SQLite 3.38.5 (#91)
* Update to SQLite 3.37 and latest generate_series extension

* Expect column types in upper case

* Rebuild newer SQLite, 3.38.5
2022-06-27 17:35:40 +02:00
lana-k
f0a4212e2b fix tests 2022-06-26 21:06:24 +02:00
lana-k
c8deff32c1 fix lint 2022-06-25 22:38:22 +02:00
lana-k
d56604a7d6 events refactor 2022-06-25 22:37:09 +02:00
lana-k
48e311bff8 minor change 2022-06-24 22:58:43 +02:00
lana-k
518b22b489 events 2022-06-24 21:29:40 +02:00
lana-k
a20dd7f849 forbid index minifying 2022-06-11 19:29:42 +02:00
lana-k
310a939109 #89 add tests 2021-12-24 16:13:42 +01:00
lana-k
bb9ba08902 #89 remove head and body 2021-12-22 20:42:53 +01:00
lana-k
c7c727ff78 fix lint 2021-12-21 22:15:21 +01:00
lana-k
8669a6a9e5 #89 export to html plolty charts and pivots 2021-12-21 22:13:02 +01:00
lana-k
c1cc5bb95e getHtml for chart #89 2021-12-20 22:31:08 +01:00
lana-k
9c55e76a41 update version 2021-12-19 16:00:11 +01:00
lana-k
70a9edf57e fix lint 2021-12-19 15:57:30 +01:00
lana-k
b2c2344951 update version 2021-12-19 15:38:17 +01:00
lana-k
cbec91e78a Merge branch 'master' of github.com:lana-k/sqliteviz 2021-12-19 15:37:01 +01:00
lana-k
816b0e6218 show plotly warnings and errors #55 2021-12-19 15:36:46 +01:00
saaj
4ed93bbea7 Two more extensions and improved extension documentation (#86) 2021-09-10 20:11:37 +02:00
72 changed files with 17529 additions and 12863 deletions

View File

@@ -1,4 +1,4 @@
name: Deploy to GitHub Pages and create release
name: Create release
on:
workflow_dispatch:
push:
@@ -7,7 +7,7 @@ on:
jobs:
deploy:
name: Deploy to GitHub Pages and create release
name: Create release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
@@ -24,10 +24,11 @@ jobs:
npm install
npm run build
- name: Create archive
- name: Create archives
run: |
cd dist
zip -9 -r dist.zip . -x "js/*.map" -x "/*.map"
zip -9 -r ../dist.zip . -x "js/*.map" -x "/*.map"
zip -9 -r ../dist_map.zip .
- name: Create Release Notes
run: |
@@ -39,16 +40,6 @@ jobs:
- name: Create release
uses: ncipollo/release-action@v1
with:
artifacts: "dist/dist.zip"
artifacts: "dist.zip,dist_map.zip"
token: ${{ secrets.GITHUB_TOKEN }}
bodyFile: "CHANGELOG.md"
- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@4.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: build # The branch the action should deploy to.
folder: dist/ # The folder the action should deploy.
clean: true # Automatically remove deleted files from the deploy branch
clean-exclude: .nojekyll

View File

@@ -11,7 +11,7 @@ on:
jobs:
test:
name: Run tests
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Use Node.js

24
Dockerfile.test Normal file
View File

@@ -0,0 +1,24 @@
# An easy way to run tests locally without Nodejs installed:
#
# docker build -t sqliteviz/test -f Dockerfile.test .
#
FROM node:12
RUN set -ex; \
apt update; \
apt install -y chromium firefox-esr; \
npm install -g npm@7
WORKDIR /tmp/build
COPY package.json package-lock.json ./
COPY lib lib
RUN npm install
COPY . .
RUN set -ex; \
sed -i 's/browsers: \[.*\],/browsers: ['"'FirefoxHeadlessTouch'"'],/' karma.conf.js
RUN npm run lint -- --no-fix && npm run test

View File

@@ -18,10 +18,10 @@ With sqliteviz you can:
https://user-images.githubusercontent.com/24638357/128249848-f8fab0f5-9add-46e0-a9c1-dd5085a8623e.mp4
## Quickstart
The latest release of sqliteviz is deployed on GitHub Pages at [lana-k.github.io/sqliteviz][6].
The latest release of sqliteviz is deployed on [sqliteviz.com/app][6].
## Wiki
For user documentation, check out sqliteviz [Wiki][7].
For user documentation, check out sqliteviz [documentation][7].
## Motivation
It's a kind of middleground between [Plotly Falcon][1] and [Redash][2].
@@ -34,8 +34,8 @@ It is built on top of [react-chart-editor][3], [PivotTable.js][12], [sql.js][4]
[3]: https://github.com/plotly/react-chart-editor
[4]: https://github.com/sql-js/sql.js
[5]: https://github.com/vuejs/vue
[6]: https://lana-k.github.io/sqliteviz/
[7]: https://github.com/lana-k/sqliteviz/wiki
[6]: https://sqliteviz.com/app/
[7]: https://sqliteviz.com/docs
[8]: https://github.com/surmon-china/vue-codemirror#readme
[9]: https://www.papaparse.com/
[10]: https://github.com/lana-k/sqliteviz/wiki/Predefined-queries

View File

@@ -1,4 +1,4 @@
FROM emscripten/emsdk:2.0.24
FROM emscripten/emsdk:3.0.1
WORKDIR /tmp/build

View File

@@ -13,6 +13,21 @@ SQLite [amalgamation][2] extensions included:
1. [FTS5][4] -- virtual table module that provides full-text search
functionality
2. [FTS3/FTS4][15] -- older virtual table modules for full-text search
3. [JSON1][16] -- scalar, aggregate and table-valued functions for managing JSON data
SQLite [contribution extensions][17]:
1. [extension-functions][18] -- mathematical and string extension functions for SQL queries.
Math: `acos`, `asin`, `atan`, `atn2`, `atan2`, `acosh`, `asinh`, `atanh`, `difference`,
`degrees`, `radians`, `cos`, `sin`, `tan`, `cot`, `cosh`, `sinh`, `tanh`, `coth`,
`exp`, `log`, `log10`, `power`, `sign`, `sqrt`, `square`, `ceil`, `floor`, `pi`.
String: `replicate`, `charindex`, `leftstr`, `rightstr`, `ltrim`, `rtrim`, `trim`,
`replace`, `reverse`, `proper`, `padl`, `padr`, `padc`, `strfilter`.
Aggregate: `stdev`, `variance`, `mode`, `median`, `lower_quartile`, `upper_quartile`.
SQLite [miscellaneous extensions][3] included:
@@ -21,14 +36,22 @@ SQLite [miscellaneous extensions][3] included:
[Querying Tree Structures in SQLite][11] ([closure.c][8])
3. `uuid`, `uuid_str` and `uuid_blob` RFC-4122 UUID functions ([uuid.c][9])
4. `regexp` (hence `REGEXP` operator) and `regexpi` functions ([regexp.c][10])
5. `percentile` function ([percentile.c][13])
6. `decimal`, `decimal_cmp`, `decimal_add`, `decimal_sub` and `decimal_mul` functions
([decimal.c][14])
SQLite 3rd party extensions included:
1. [pivot_vtab][5] -- a pivot virtual table
2. `pearson` correlation coefficient function extension from [sqlean][21]
(which is part of [squib][20])
To ease the step to have working clone locally, the build is committed into
the repository.
Examples of queries involving these extensions can be found in the test suite in
[sqliteExtensions.spec.js][19].
## Build method
Basically it's extended amalgamation and `SQLITE_EXTRA_INIT` concisely
@@ -71,3 +94,12 @@ described in [this message from SQLite Forum][12]:
[10]: https://sqlite.org/src/file/ext/misc/regexp.c
[11]: https://charlesleifer.com/blog/querying-tree-structures-in-sqlite-using-python-and-the-transitive-closure-extension/
[12]: https://sqlite.org/forum/forumpost/6ad7d4f4bebe5e06?raw
[13]: https://sqlite.org/src/file/ext/misc/percentile.c
[14]: https://sqlite.org/src/file/ext/misc/decimal.c
[15]: https://sqlite.org/fts3.html
[16]: https://sqlite.org/json1.html
[17]: https://sqlite.org/contrib/
[18]: https://sqlite.org/contrib//download/extension-functions.c?get=25
[19]: https://github.com/lana-k/sqliteviz/blob/master/tests/lib/database/sqliteExtensions.spec.js
[20]: https://github.com/mrwilson/squib/blob/master/pearson.c
[21]: https://github.com/nalgeon/sqlean/blob/incubator/src/pearson.c

View File

@@ -1,4 +1,4 @@
FROM node:12-buster
FROM node:14-bullseye
RUN set -ex; \
echo 'deb http://deb.debian.org/debian unstable main' \

View File

@@ -1,14 +1,25 @@
# SQLite WebAssembly build micro-benchmark
This directory contains a micro-benchmark for evaluating SQLite
WebAssembly builds performance on typical SQL queries, run from
`make.sh` script. It can also serve as a smoke test.
This directory contains a micro-benchmark for evaluating SQLite WebAssembly
builds performance on read and write SQL queries, run from `make.sh` script. If
the script has permission to `nice` processes and [Procpath][1] is installed,
e.g. it is run with `sudo -E env PATH=$PATH ./make.sh`, it'll `renice` all
processes running inside the benchmark containers. It can also serve as a smoke
test (e.g. for memory leaks).
The benchmark operates on a set of SQLite WebAssembly builds expected
in `lib/build-$NAME` directories each containing `sql-wasm.js` and
`sql-wasm.wasm`. Then it creates a Docker image for each, and runs
the benchmark in Firefox and Chromium using Karma in the container.
The benchmark operates on a set of SQLite WebAssembly builds expected in
`lib/build-$NAME` directories each containing `sql-wasm.js` and
`sql-wasm.wasm`. Then it creates a Docker image for each, and runs the
benchmark in Firefox and Chromium using Karma in the container.
After successful run, the benchmark result of each build is contained
in `build-$NAME-result.json`. The JSON result files can be analysed
using `result-analysis.ipynb` Jupyter notebook.
After successful run, the benchmark produces the following per each build:
- `build-$NAME-result.json`
- `build-$NAME.sqlite` (if Procpath is installed)
- `build-$NAME.svg` (if Procpath is installed)
These files can be analysed using `result-analysis.ipynb` Jupyter notebook.
The SVG is a chart with CPU and RSS usage of each test container (i.e. Chromium
run, then Firefox run per container).
[1]: https://pypi.org/project/Procpath/

View File

@@ -1,21 +1,47 @@
#!/bin/bash -e
cleanup () {
rm -rf lib/dist "$renice_flag_file"
docker rm -f sqljs-benchmark-run 2> /dev/null || true
}
trap cleanup EXIT
if [ ! -f sample.csv ]; then
wget --header="accept-encoding: gzip" -q -O- \
https://github.com/plotly/datasets/raw/547090bd/wellspublic.csv \
| gunzip -c > sample.csv
fi
PLAYBOOK=procpath/karma_docker.procpath
# for renice to work run like "sudo -E env PATH=$PATH ./make.sh"
test_ni=$(nice -n -5 nice)
if [ $test_ni == -5 ]; then
renice_flag_file=$(mktemp)
fi
{
while [ -f $renice_flag_file ]; do
procpath --logging-level ERROR play -f $PLAYBOOK renice:watch
done
} &
shopt -s nullglob
for d in lib/build-* ; do
rm -r lib/dist || true
rm -rf lib/dist
cp -r $d lib/dist
sample_name=$(basename $d)
name=$(basename $d)
docker build -t sqliteviz/sqljs-benchmark:$name .
docker rm sqljs-benchmark-$name 2> /dev/null || true
docker run -it --name sqljs-benchmark-$name sqliteviz/sqljs-benchmark:$name
docker cp sqljs-benchmark-$name:/tmp/build/suite-result.json ${name}-result.json
docker rm sqljs-benchmark-$name
docker build -t sqliteviz/sqljs-benchmark .
docker rm sqljs-benchmark-run 2> /dev/null || true
docker run -d -it --cpus 2 --name sqljs-benchmark-run sqliteviz/sqljs-benchmark
{
rm -f ${sample_name}.sqlite
procpath play -f $PLAYBOOK -o database_file=${sample_name}.sqlite track:record
procpath play -f $PLAYBOOK -o database_file=${sample_name}.sqlite \
-o plot_file=${sample_name}.svg track:plot
} &
docker attach sqljs-benchmark-run
docker cp sqljs-benchmark-run:/tmp/build/suite-result.json ${sample_name}-result.json
docker rm sqljs-benchmark-run
done
rm -r lib/dist

View File

@@ -0,0 +1,28 @@
# This command may run when "sqljs-benchmark-run" does not yet exist or run
[renice:watch]
interval: 2
repeat: 30
environment:
ROOT_PID=docker inspect -f "{{.State.Pid}}" sqljs-benchmark-run 2> /dev/null || true
query:
PIDS=$..children[?(@.stat.pid in [$ROOT_PID])]..pid
command:
echo $PIDS | tr , '\n' | xargs --no-run-if-empty -I{} -- renice -n -5 -p {}
# Expected input arguments: database_file
[track:record]
interval: 1
stop_without_result: 1
environment:
ROOT_PID=docker inspect -f "{{.State.Pid}}" sqljs-benchmark-run
query:
$..children[?(@.stat.pid == $ROOT_PID)]
pid_list: $ROOT_PID
# Expected input arguments: database_file, plot_file
[track:plot]
moving_average_window: 5
title: Chromium vs Firefox (№1 RSS, №2 CPU)
custom_query_file:
procpath/top2_rss.sql
procpath/top2_cpu.sql

View File

@@ -0,0 +1,29 @@
WITH diff_all AS (
SELECT
record_id,
ts,
stat_pid,
stat_utime + stat_stime - LAG(stat_utime + stat_stime) OVER (
PARTITION BY stat_pid
ORDER BY record_id
) tick_diff,
ts - LAG(ts) OVER (
PARTITION BY stat_pid
ORDER BY record_id
) ts_diff
FROM record
), diff AS (
SELECT * FROM diff_all WHERE tick_diff IS NOT NULL
), one_time_pid_condition AS (
SELECT stat_pid
FROM record
GROUP BY 1
ORDER BY SUM(stat_utime + stat_stime) DESC
LIMIT 2
)
SELECT
ts,
stat_pid pid,
100.0 * tick_diff / (SELECT value FROM meta WHERE key = 'clock_ticks') / ts_diff value
FROM diff
JOIN one_time_pid_condition USING(stat_pid)

View File

@@ -0,0 +1,13 @@
WITH one_time_pid_condition AS (
SELECT stat_pid
FROM record
GROUP BY 1
ORDER BY SUM(stat_rss) DESC
LIMIT 2
)
SELECT
ts,
stat_pid pid,
stat_rss / 1024.0 / 1024 * (SELECT value FROM meta WHERE key = 'page_size') value
FROM record
JOIN one_time_pid_condition USING(stat_pid)

File diff suppressed because one or more lines are too long

View File

@@ -2,9 +2,11 @@ import logging
import subprocess
from pathlib import Path
# See the setting descriptions on these pages:
# - https://emscripten.org/docs/optimizing/Optimizing-Code.html
# - https://github.com/emscripten-core/emscripten/blob/main/src/settings.js
cflags = (
'-O2',
# SQLite configuration
'-DSQLITE_DEFAULT_CACHE_SIZE=-65536', # 64 MiB
'-DSQLITE_DEFAULT_MEMSTATUS=0',
'-DSQLITE_DEFAULT_SYNCHRONOUS=0',
@@ -13,26 +15,26 @@ cflags = (
'-DSQLITE_ENABLE_FTS3',
'-DSQLITE_ENABLE_FTS3_PARENTHESIS',
'-DSQLITE_ENABLE_FTS5',
'-DSQLITE_ENABLE_JSON1',
'-DSQLITE_ENABLE_NORMALIZE',
'-DSQLITE_EXTRA_INIT=extra_init',
'-DSQLITE_OMIT_DEPRECATED',
'-DSQLITE_OMIT_LOAD_EXTENSION',
'-DSQLITE_OMIT_SHARED_CACHE',
'-DSQLITE_THREADSAFE=0',
# Compile-time optimisation
'-Os', # reduces the code size about in half comparing to -O2
'-flto',
)
emflags = (
# Base
'--memory-init-file', '0',
'-s', 'RESERVED_FUNCTION_POINTERS=64',
'-s', 'ALLOW_TABLE_GROWTH=1',
'-s', 'SINGLE_FILE=0',
# WASM
'-s', 'WASM=1',
'-s', 'ALLOW_MEMORY_GROWTH=1',
# Optimisation
'-s', 'INLINING_LIMIT=50',
'-O3',
'-s', 'ENVIRONMENT=web,worker',
# Link-time optimisation
'-Os',
'-flto',
# sql.js
'-s', 'EXPORTED_FUNCTIONS=@src/sqljs/exported_functions.json',
@@ -50,22 +52,22 @@ def build(src: Path, dst: Path):
'emcc',
*cflags,
'-c', src / 'sqlite3.c',
'-o', out / 'sqlite3.bc',
'-o', out / 'sqlite3.o',
])
logging.info('Building LLVM bitcode for extension-functions.c')
subprocess.check_call([
'emcc',
*cflags,
'-c', src / 'extension-functions.c',
'-o', out / 'extension-functions.bc',
'-o', out / 'extension-functions.o',
])
logging.info('Building WASM from bitcode')
subprocess.check_call([
'emcc',
*emflags,
out / 'sqlite3.bc',
out / 'extension-functions.bc',
out / 'sqlite3.o',
out / 'extension-functions.o',
'-o', out / 'sql-wasm.js',
])

View File

@@ -8,7 +8,7 @@ from pathlib import Path
from urllib import request
amalgamation_url = 'https://sqlite.org/2021/sqlite-amalgamation-3360000.zip'
amalgamation_url = 'https://sqlite.org/2023/sqlite-amalgamation-3410000.zip'
# Extension-functions
# ===================
@@ -20,16 +20,19 @@ contrib_functions_url = 'https://sqlite.org/contrib/download/extension-functions
extension_urls = (
# Miscellaneous extensions
# ========================
('https://sqlite.org/src/raw/c6bd5d24?at=series.c', 'sqlite3_series_init'),
('https://sqlite.org/src/raw/8d79354f?at=series.c', 'sqlite3_series_init'),
('https://sqlite.org/src/raw/dbfd8543?at=closure.c', 'sqlite3_closure_init'),
('https://sqlite.org/src/raw/5bb2264c?at=uuid.c', 'sqlite3_uuid_init'),
('https://sqlite.org/src/raw/5853b0e5?at=regexp.c', 'sqlite3_regexp_init'),
('https://sqlite.org/src/raw/b9086e22?at=percentile.c', 'sqlite3_percentile_init'),
('https://sqlite.org/src/raw/09f967dc?at=decimal.c', 'sqlite3_decimal_init'),
# Third-party extension
# =====================
('https://github.com/jakethaw/pivot_vtab/raw/08ab0797/pivot_vtab.c', 'sqlite3_pivotvtab_init'),
('https://github.com/jakethaw/pivot_vtab/raw/9323ef93/pivot_vtab.c', 'sqlite3_pivotvtab_init'),
('https://github.com/nalgeon/sqlean/raw/95e8d21a/src/pearson.c', 'sqlite3_pearson_init'),
)
sqljs_url = 'https://github.com/sql-js/sql.js/archive/refs/tags/v1.5.0.zip'
sqljs_url = 'https://github.com/sql-js/sql.js/archive/refs/tags/v1.7.0.zip'
def _generate_extra_init_c_function(init_function_names):

File diff suppressed because one or more lines are too long

Binary file not shown.

26781
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "sqliteviz",
"version": "0.16.0",
"version": "0.23.2",
"license": "Apache-2.0",
"private": true,
"scripts": {
@@ -16,7 +16,7 @@
"html2canvas": "^1.1.4",
"jquery": "^3.6.0",
"nanoid": "^3.1.12",
"papaparse": "^5.3.1",
"papaparse": "^5.4.1",
"pivottable": "^2.23.0",
"plotly.js": "^1.58.4",
"promise-worker": "^2.0.1",
@@ -51,6 +51,7 @@
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^6.2.2",
"flush-promises": "^1.0.2",
"karma": "^3.1.4",
"karma-firefox-launcher": "^2.1.0",
"karma-webpack": "^4.0.2",

View File

@@ -69,6 +69,8 @@
}
}
</style>
<!-- head extention slot start -->
<!-- head extention slot end -->
</head>
<body>
<noscript>
@@ -93,6 +95,8 @@
</svg>
</div>
</div>
<!-- extention slot start -->
<!-- extention slot end -->
<!-- built files will be auto injected -->
</body>
</html>

View File

@@ -0,0 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M26.8311 34.6554C25.4675 33.8178 24.177 32.8655 22.9735 31.8086V14.3616H30.5728V36.753C29.3146 36.0982 28.0673 35.399 26.8311 34.6554ZM41.4669 25.8486H33.8675V38.1514C36.3477 39.3055 38.884 40.3334 41.4669 41.2313V25.8486ZM22.9735 35.3046L22.4768 34.9051C21.7152 34.2725 21.0033 33.6232 20.3245 32.9739L2.2947 30.8763L5.60596 37.3024L28.7848 39.2002C26.7511 38.0537 24.8082 36.7513 22.9735 35.3046ZM41.0695 44.6441C38.4829 43.7946 35.9458 42.7997 33.4702 41.6641L32.543 41.198L17.2616 40.1825L19.8444 45.593L46.5 46.209C44.6788 45.7761 42.8411 45.2434 41.0695 44.6441ZM9.34768 14.3616C12.2649 19.4905 15.735 24.2807 19.6954 28.6455V11.2651L2.99007 2.99115L1.5 22.3859L18.702 31.2592C14.1919 26.5283 10.9703 20.7087 9.34768 14.3616Z" fill="#119DFF"/>
</svg>

After

Width:  |  Height:  |  Size: 862 B

View File

@@ -112,6 +112,7 @@ import SqlTable from '@/components/SqlTable'
import Logs from '@/components/Logs'
import time from '@/lib/utils/time'
import fIo from '@/lib/utils/fileIo'
import events from '@/lib/utils/events'
export default {
name: 'CsvImport',
@@ -335,6 +336,7 @@ export default {
this.$store.commit('setCurrentTabId', tabId)
this.importCsvCompleted = false
this.$emit('finish')
events.send('inquiry.create', null, { auto: true })
}
}
}

View File

@@ -58,6 +58,7 @@ import fIo from '@/lib/utils/fileIo'
import ChangeDbIcon from '@/components/svg/changeDb'
import database from '@/lib/database'
import CsvImport from '@/components/CsvImport'
import events from '@/lib/utils/events'
export default {
name: 'DbUploader',
@@ -127,6 +128,11 @@ export default {
if (fIo.isDatabase(file)) {
this.loadDb(file)
} else {
events.send('database.import', file.size, {
from: 'csv',
new_db: true
})
this.file = file
await this.$nextTick()
const csvImport = this.$refs.addCsv

View File

@@ -75,14 +75,23 @@ export default {
props: {
horizontal: { type: Boolean, default: false },
before: { type: Object },
after: { type: Object }
after: { type: Object },
default: {
type: Object,
default: () => {
return {
before: 50,
after: 50
}
}
}
},
data () {
return {
container: null,
paneBefore: this.before,
paneAfter: this.after,
beforeMinimising: {
beforeMinimising: !this.after.size || !this.before.size ? this.default : {
before: this.before.size,
after: this.after.size
},

View File

@@ -0,0 +1,49 @@
<template>
<svg
width="19"
height="18"
viewBox="0 0 19 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.1626 10.0745L7.56641 10.8831V12.2322L3.68164 10.6501V9.4812L7.56641
7.89917V9.2439L5.1626 10.0745ZM8.99023 13.3H7.93994L10.124 6.35229H11.1787L8.99023
13.3ZM14.1099 10.0613L11.7192 9.24829V7.90356L15.582 9.4856V10.6545L11.7192
12.2366V10.8918L14.1099 10.0613Z"
fill="#A2B1C6"
/>
<path
d="M2.17041 0.0637207H16.2185V1.56372H2.17041V9.30354H0.67041V1.56372C0.67041 0.73872
1.34541 0.0637207 2.17041 0.0637207Z"
fill="#A2B1C6"
/>
<path
d="M17.1704 0.0637207H15.3052V1.56372H17.1704V9.84163H18.6704V1.56372C18.6704 0.73872
17.9954 0.0637207 17.1704 0.0637207Z"
fill="#A2B1C6"
/>
<path
d="M2.17041 17.1098H15.8754V15.6098H2.17041V8.78486H0.67041V15.6098C0.67041 16.4348
1.34541 17.1098 2.17041 17.1098Z"
fill="#A2B1C6"
/>
<path
d="M17.1704 17.1098H15.3052V15.6098H17.1704V8.55939H18.6704V15.6098C18.6704 16.4348
17.9954 17.1098 17.1704 17.1098Z"
fill="#A2B1C6"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M18.1197 4.13787H1.76172V3.03787H18.1197V4.13787Z"
fill="#A2B1C6"
/>
</svg>
</template>
<script>
export default {
name: 'HtmlIcon'
}
</script>

View File

@@ -1,5 +1,6 @@
import dereference from 'react-chart-editor/lib/lib/dereference'
import plotly from 'plotly.js'
import { nanoid } from 'nanoid'
export function getOptionsFromDataSources (dataSources) {
if (!dataSources) {
@@ -33,8 +34,43 @@ export async function getImageDataUrl (element, type) {
})
}
export function getChartData (element) {
const chartElement = element.querySelector('.js-plotly-plot')
return {
data: chartElement.data,
layout: chartElement.layout
}
}
export function getHtml (options) {
const chartId = nanoid()
return `
<script src="https://cdn.plot.ly/plotly-latest.js" charset="UTF-8"></script>
<div id="${chartId}"></div>
<script>
const el = document.getElementById("${chartId}")
let timeout
function debounceResize() {
clearTimeout(timeout)
timeout = setTimeout(() => {
var r = el.getBoundingClientRect()
Plotly.relayout(el, {width: r.width, height: r.height})
}, 200)
}
const resizeObserver = new ResizeObserver(debounceResize)
resizeObserver.observe(el)
Plotly.newPlot(el, ${JSON.stringify(options.data)}, ${JSON.stringify(options.layout)})
</script>
`
}
export default {
getOptionsFromDataSources,
getOptionsForSave,
getImageDataUrl
getImageDataUrl,
getHtml,
getChartData
}

View File

@@ -73,7 +73,9 @@ export default {
comments: false,
step: undefined,
complete: results => {
const res = {
let res
try {
res = {
data: this.getResult(results),
delimiter: results.meta.delimiter,
hasErrors: false,
@@ -85,9 +87,12 @@ export default {
msg.hint = hintsByCode[msg.code]
return msg
})
} catch (error) {
reject(error)
}
resolve(res)
},
error: (error, file) => {
error: error => {
reject(error)
},
download: false,

View File

@@ -7,6 +7,8 @@ import Worker from './_worker.js'
// https://github.com/nolanlawson/promise-worker
import PromiseWorker from 'promise-worker'
import events from '@/lib/utils/events'
function getNewDatabase () {
const worker = new Worker()
return new Database(worker)
@@ -75,7 +77,12 @@ class Database {
}
this.dbName = file ? fu.getFileName(file) : 'database'
this.refreshSchema()
await this.refreshSchema()
events.send('database.import', file ? file.size : 0, {
from: file ? 'sqlite' : 'none',
new_db: true
})
}
async refreshSchema () {
@@ -114,6 +121,7 @@ class Database {
throw new Error(data.error)
}
fu.exportToFile(data, fileName)
events.send('database.export', data.byteLength, { to: 'sqlite' })
}
async validateTableName (name) {

View File

@@ -1,5 +1,6 @@
import { nanoid } from 'nanoid'
import fu from '@/lib/utils/fileIo'
import events from '@/lib/utils/events'
import migration from './_migrations'
const migrate = migration._migrate
@@ -32,17 +33,16 @@ export default {
},
isTabNeedName (inquiryTab) {
const isFromScratch = !inquiryTab.initName
return inquiryTab.isPredefined || isFromScratch
return inquiryTab.isPredefined || !inquiryTab.name
},
save (inquiryTab, newName) {
const value = {
id: inquiryTab.isPredefined ? nanoid() : inquiryTab.id,
query: inquiryTab.query,
viewType: inquiryTab.$refs.dataView.mode,
viewOptions: inquiryTab.$refs.dataView.getOptionsForSave(),
name: newName || inquiryTab.initName
viewType: inquiryTab.dataView.mode,
viewOptions: inquiryTab.dataView.getOptionsForSave(),
name: newName || inquiryTab.name
}
// Get inquiries from local storage
@@ -103,9 +103,19 @@ export default {
importInquiries () {
return fu.importFile()
.then(str => {
return this.deserialiseInquiries(str)
const inquires = this.deserialiseInquiries(str)
events.send('inquiry.import', inquires.length)
return inquires
})
},
export (inquiryList, fileName) {
const jsonStr = this.serialiseInquiries(inquiryList)
fu.exportToFile(jsonStr, fileName)
events.send('inquiry.export', inquiryList.length)
},
async readPredefinedInquiries () {
const res = await fu.readFile('./inquiries.json')

59
src/lib/tab.js Normal file
View File

@@ -0,0 +1,59 @@
import { nanoid } from 'nanoid'
import time from '@/lib/utils/time'
import events from '@/lib/utils/events'
export default class Tab {
constructor (state, inquiry = {}) {
this.id = inquiry.id || nanoid()
this.name = inquiry.id ? inquiry.name : null
this.tempName = inquiry.name || (state.untitledLastIndex
? `Untitled ${state.untitledLastIndex}`
: 'Untitled')
this.query = inquiry.query
this.viewOptions = inquiry.viewOptions || undefined
this.isPredefined = inquiry.isPredefined
this.viewType = inquiry.viewType || 'chart'
this.result = null
this.isGettingResults = false
this.error = null
this.time = 0
this.layout = inquiry.layout || {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
}
this.maximize = inquiry.maximize
this.isSaved = !!inquiry.id
this.state = state
}
async execute () {
this.isGettingResults = true
this.result = null
this.error = null
const db = this.state.db
try {
const start = new Date()
this.result = await db.execute(this.query + ';')
this.time = time.getPeriod(start, new Date())
if (this.result && this.result.values) {
events.send('resultset.create',
this.result.values[this.result.columns[0]].length
)
}
events.send('query.run', parseFloat(this.time), { status: 'success' })
} catch (err) {
this.error = {
type: 'error',
message: err
}
events.send('query.run', 0, { status: 'error' })
}
db.refreshSchema()
this.isGettingResults = false
}
}

12
src/lib/utils/events.js Normal file
View File

@@ -0,0 +1,12 @@
export default {
send (name, value, labels) {
const event = new CustomEvent('sqliteviz-app-event', {
detail: {
name,
value,
labels: labels || {}
}
})
window.dispatchEvent(event)
}
}

View File

@@ -1,3 +1,4 @@
import events from '@/lib/utils/events'
let refresh = false
function invokeServiceWorkerUpdateFlow (registration) {
@@ -41,4 +42,8 @@ if ('serviceWorker' in navigator) {
}
})
})
window.addEventListener('appinstalled', () => {
events.send('pwa.install')
})
}

View File

@@ -4,6 +4,7 @@ import Workspace from '@/views/Main/Workspace'
import Inquiries from '@/views/Main/Inquiries'
import Welcome from '@/views/Welcome'
import Main from '@/views/Main'
import LoadView from '@/views/LoadView'
import store from '@/store'
import database from '@/lib/database'
@@ -31,6 +32,11 @@ const routes = [
component: Inquiries
}
]
},
{
path: '/load',
name: 'Load',
component: LoadView
}
]
@@ -39,7 +45,7 @@ const router = new VueRouter({
})
router.beforeEach(async (to, from, next) => {
if (!store.state.db) {
if (!store.state.db && to.name !== 'Load') {
const newDb = database.getNewDatabase()
await newDb.loadDb()
store.commit('setDb', newDb)

View File

@@ -1,32 +1,17 @@
import { nanoid } from 'nanoid'
import Tab from '@/lib/tab'
export default {
async addTab ({ state }, data) {
const tab = data ? JSON.parse(JSON.stringify(data)) : {}
// If no data then create a new blank one...
// No data.id means to create new tab, but not blank,
// e.g. with 'select * from csv_import' inquiry after csv import
if (!data || !data.id) {
tab.id = nanoid()
tab.name = null
tab.tempName = state.untitledLastIndex
? `Untitled ${state.untitledLastIndex}`
: 'Untitled'
tab.viewType = 'chart'
tab.viewOptions = undefined
tab.isSaved = false
} else {
tab.isSaved = true
}
// add new tab only if was not already opened
if (!state.tabs.some(openedTab => openedTab.id === tab.id)) {
async addTab ({ state }, inquiry = {}) {
// add new tab only if it was not already opened
if (!state.tabs.some(openedTab => openedTab.id === inquiry.id)) {
const tab = new Tab(state, JSON.parse(JSON.stringify(inquiry)))
state.tabs.push(tab)
if (!tab.name) {
state.untitledLastIndex += 1
}
}
return tab.id
}
return inquiry.id
}
}

View File

@@ -1,5 +1,3 @@
import Vue from 'vue'
export default {
setDb (state, db) {
if (state.db) {
@@ -8,8 +6,8 @@ export default {
state.db = db
},
updateTab (state, { index, name, id, query, viewType, viewOptions, isSaved }) {
const tab = state.tabs[index]
updateTab (state, { tab, newValues }) {
const { name, id, query, viewType, viewOptions, isSaved } = newValues
const oldId = tab.id
if (id && state.currentTabId === oldId) {
@@ -26,32 +24,41 @@ export default {
// Saved inquiry is not predefined
delete tab.isPredefined
}
Vue.set(state.tabs, index, tab)
},
deleteTab (state, index) {
deleteTab (state, tab) {
const index = state.tabs.indexOf(tab)
// If closing tab is the current opened
if (state.tabs[index].id === state.currentTabId) {
if (tab.id === state.currentTabId) {
if (index < state.tabs.length - 1) {
state.currentTabId = state.tabs[index + 1].id
} else if (index > 0) {
state.currentTabId = state.tabs[index - 1].id
} else {
state.currentTabId = null
state.currentTab = null
state.untitledLastIndex = 0
}
state.currentTab = state.currentTabId
? state.tabs.find(tab => tab.id === state.currentTabId)
: null
}
state.tabs.splice(index, 1)
},
setCurrentTabId (state, id) {
try {
state.currentTabId = id
},
setCurrentTab (state, tab) {
state.currentTab = tab
state.currentTab = state.tabs.find(tab => tab.id === id)
} catch (e) {
console.error('Can\'t open a tab id:' + id)
}
},
updatePredefinedInquiries (state, inquiries) {
state.predefinedInquiries = Array.isArray(inquiries) ? inquiries : [inquiries]
},
setLoadingPredefinedInquiries (state, value) {
state.loadingPredefinedInquiries = value
},
setPredefinedInquiriesLoaded (state, value) {
state.predefinedInquiriesLoaded = value
}
}

View File

@@ -4,5 +4,7 @@ export default {
currentTabId: null,
untitledLastIndex: 0,
predefinedInquiries: [],
loadingPredefinedInquiries: false,
predefinedInquiriesLoaded: false,
db: null
}

200
src/views/LoadView.vue Normal file
View File

@@ -0,0 +1,200 @@
<template>
<div>
<logs
id="logs"
:messages="messages"
/>
<button
v-if="hasErrors"
id="open-workspace-btn"
class="secondary"
@click="$router.push('/workspace?hide_schema=1')">
Open workspace
</button>
</div>
</template>
<script>
import fu from '@/lib/utils/fileIo'
import database from '@/lib/database'
import Logs from '@/components/Logs'
import events from '@/lib/utils/events'
export default {
name: 'LoadView',
components: {
Logs
},
data () {
return {
newDb: null,
messages: [],
dataMsg: {},
inquiryMsg: {}
}
},
computed: {
hasErrors () {
return this.dataMsg.type === 'error' || this.inquiryMsg.type === 'error'
}
},
async created () {
const {
data_url: dataUrl,
data_format: dataFormat,
inquiry_url: inquiryUrl,
inquiry_id: inquiryIds,
maximize
} = this.$route.query
events.send('share.load', null, {
has_data_url: !!dataUrl,
data_format: dataFormat,
has_inquiry_url: !!inquiryUrl,
inquiry_id_count: (inquiryIds || []).length,
maximize
})
await this.loadData(dataUrl, dataFormat)
const inquiries = await this.loadInquiries(inquiryUrl, inquiryIds)
if (inquiries && inquiries.length > 0) {
await this.openInquiries(inquiries, maximize)
}
if (!this.hasErrors) {
this.$router.push('/workspace?hide_schema=1')
}
},
methods: {
async loadData (dataUrl, dataFormat) {
this.newDb = database.getNewDatabase()
if (dataUrl) {
this.dataMsg = {
message: 'Preparing data...',
type: 'info'
}
this.messages.push(this.dataMsg)
// Show loading indicator after 1 second
const loadingDataIndicator = setTimeout(() => {
if (this.dataMsg.type === 'info') {
this.dataMsg.type = 'loading'
}
}, 1000)
if (dataFormat === 'sqlite') {
await this.getSqliteDb(dataUrl)
} else {
this.dataMsg.message = 'Unknown data format'
this.dataMsg.type = 'error'
}
// Loading indicator is not needed anymore
clearTimeout(loadingDataIndicator)
} else {
await this.newDb.loadDb()
}
this.$store.commit('setDb', this.newDb)
},
async getSqliteDb (dataUrl) {
try {
const filename = new URL(dataUrl).pathname.split('/').pop()
const res = await fu.readFile(dataUrl)
if (!res.ok) {
throw new Error('Fetching DB failed')
}
const file = await res.blob()
file.name = filename
await this.newDb.loadDb(file)
this.dataMsg.message = 'Data is ready'
this.dataMsg.type = 'success'
} catch (error) {
console.error(error)
this.dataMsg.message = error
this.dataMsg.type = 'error'
}
},
async loadInquiries (inquiryUrl, inquiryIds = []) {
if (!inquiryUrl) {
return []
}
// Show loading indicator after 1 second
const loadingInquiriesIndicator = setTimeout(() => {
if (this.inquiryMsg.type === 'info') {
this.inquiryMsg.type = 'loading'
}
}, 1000)
try {
this.inquiryMsg = {
message: 'Preparing inquiries...',
type: 'info'
}
this.messages.push(this.inquiryMsg)
const res = await fu.readFile(inquiryUrl)
const file = await res.json()
this.inquiryMsg.message = 'Inquiries are ready'
this.inquiryMsg.type = 'success'
return inquiryIds.length > 0
? file.inquiries.filter(inquiry => inquiryIds.includes(inquiry.id))
: file.inquiries
} catch (error) {
console.error(error)
this.inquiryMsg.message = error
this.inquiryMsg.type = 'error'
}
// Loading indicator is not needed anymore
clearTimeout(loadingInquiriesIndicator)
},
async openInquiries (inquiries, maximize) {
let tabToOpen = null
const layout = maximize ? this.getLayout(maximize) : undefined
for (const inquiry of inquiries) {
const tabId = await this.$store.dispatch('addTab', {
...inquiry,
id: undefined,
layout,
maximize
})
if (!tabToOpen) {
tabToOpen = tabId
this.$store.commit('setCurrentTabId', tabToOpen)
}
}
this.$store.state.currentTab.execute()
},
getLayout (panelToMaximize) {
if (panelToMaximize === 'dataView') {
return {
sqlEditor: 'hidden',
table: 'above',
dataView: 'bottom'
}
} else {
return {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
}
}
}
}
}
</script>
<style scoped>
#logs {
margin: 8px auto;
max-width: 800px;
}
#open-workspace-btn {
margin: 16px auto;
display: block;
}
</style>

View File

@@ -62,6 +62,7 @@ export default {
<style scoped>
#app-info-icon {
cursor: pointer;
width: 24px;
}
#app-info-container {
display: flex;

View File

@@ -1,11 +1,18 @@
<template>
<div>
<div id="my-inquiries-container">
<div id="start-guide" v-if="allInquiries.length === 0">
You don't have saved inquiries so far.
<span class="link" @click="$root.$emit('createNewInquiry')">Create</span>
the one from scratch or
<span @click="importInquiries" class="link">import</span> from a file.
</div>
<div
id="loading-predefined-status"
v-if="$store.state.loadingPredefinedInquiries"
>
<loading-indicator/>
Loading predefined inquiries...
</div>
<div id="my-inquiries-content" ref="my-inquiries-content" v-show="allInquiries.length > 0">
<div id="my-inquiries-toolbar">
<div id="toolbar-buttons">
@@ -157,9 +164,9 @@ import DeleteIcon from './svg/delete'
import CloseIcon from '@/components/svg/close'
import TextField from '@/components/TextField'
import CheckBox from '@/components/CheckBox'
import LoadingIndicator from '@/components/LoadingIndicator'
import tooltipMixin from '@/tooltipMixin'
import storedInquiries from '@/lib/storedInquiries'
import fu from '@/lib/utils/fileIo'
export default {
name: 'Inquiries',
@@ -170,7 +177,8 @@ export default {
DeleteIcon,
CloseIcon,
TextField,
CheckBox
CheckBox,
LoadingIndicator
},
mixins: [tooltipMixin],
data () {
@@ -249,15 +257,21 @@ export default {
}
}
},
created () {
storedInquiries.readPredefinedInquiries()
.then(inquiries => {
this.$store.commit('updatePredefinedInquiries', inquiries)
})
.catch(console.error)
.finally(() => {
async created () {
this.inquiries = storedInquiries.getStoredInquiries()
})
const loadingPredefinedInquiries = this.$store.state.loadingPredefinedInquiries
const predefinedInquiriesLoaded = this.$store.state.predefinedInquiriesLoaded
if (!predefinedInquiriesLoaded && !loadingPredefinedInquiries) {
try {
this.$store.commit('setLoadingPredefinedInquiries', true)
const inquiries = await storedInquiries.readPredefinedInquiries()
this.$store.commit('updatePredefinedInquiries', inquiries)
this.$store.commit('setPredefinedInquiriesLoaded', true)
} catch (e) {
console.error(e)
}
this.$store.commit('setLoadingPredefinedInquiries', false)
}
},
mounted () {
this.resizeObserver = new ResizeObserver(this.calcMaxTableHeight)
@@ -324,12 +338,14 @@ export default {
storedInquiries.updateStorage(this.inquiries)
// update tab, if renamed inquiry is opened
const tabIndex = this.findTabIndex(processedInquiry.id)
if (tabIndex >= 0) {
const tab = this.$store.state.tabs
.find(tab => tab.id === processedInquiry.id)
if (tab) {
this.$store.commit('updateTab', {
index: tabIndex,
name: this.newName,
id: processedInquiry.id
tab,
newValues: {
name: this.newName
}
})
}
// hide dialog
@@ -353,9 +369,10 @@ export default {
this.inquiries.splice(this.processedInquiryIndex, 1)
// Close deleted inquiry tab if it was opened
const tabIndex = this.findTabIndex(this.processedInquiryId)
if (tabIndex >= 0) {
this.$store.commit('deleteTab', tabIndex)
const tab = this.$store.state.tabs
.find(tab => tab.id === this.processedInquiryId)
if (tab) {
this.$store.commit('deleteTab', tab)
}
// Clear checkbox
@@ -369,10 +386,12 @@ export default {
// Close deleted inquiries if it was opened
const tabs = this.$store.state.tabs
for (let i = tabs.length - 1; i >= 0; i--) {
let i = tabs.length - 1
while (i > -1) {
if (this.selectedInquiriesIds.has(tabs[i].id)) {
this.$store.commit('deleteTab', i)
this.$store.commit('deleteTab', tabs[i])
}
i--
}
// Clear checkboxes
@@ -381,12 +400,8 @@ export default {
this.selectedInquiriesCount = this.selectedInquiriesIds.size
storedInquiries.updateStorage(this.inquiries)
},
findTabIndex (id) {
return this.$store.state.tabs.findIndex(tab => tab.id === id)
},
exportToFile (inquiryList, fileName) {
const jsonStr = storedInquiries.serialiseInquiries(inquiryList)
fu.exportToFile(jsonStr, fileName)
storedInquiries.export(inquiryList, fileName)
},
exportSelectedInquiries () {
const inquiryList = this.allInquiries.filter(
@@ -443,6 +458,21 @@ export default {
</script>
<style scoped>
#my-inquiries-container {
position: relative;
}
#loading-predefined-status {
position: absolute;
right: 0;
display: flex;
gap: 4px;
font-size: 12px;
color: var(--color-text-light-2);
align-items: center;
padding: 8px;
}
#start-guide {
position: absolute;
top: 50%;

View File

@@ -1,9 +1,12 @@
<template>
<nav>
<div>
<div id="nav-links">
<a href="https://sqliteviz.com">
<img :src="require('@/assets/images/logo_simple.svg')">
</a>
<router-link to="/workspace">Workspace</router-link>
<router-link to="/inquiries">Inquiries</router-link>
<a href="https://github.com/lana-k/sqliteviz/wiki" target="_blank">Help</a>
<a href="https://sqliteviz.com/docs" target="_blank">Help</a>
</div>
<div id="nav-buttons">
<button
@@ -57,6 +60,7 @@ import TextField from '@/components/TextField'
import CloseIcon from '@/components/svg/close'
import storedInquiries from '@/lib/storedInquiries'
import AppDiagnosticInfo from './AppDiagnosticInfo'
import events from '@/lib/utils/events'
export default {
name: 'MainMenu',
@@ -76,19 +80,10 @@ export default {
return this.$store.state.currentTab
},
isSaved () {
if (!this.currentInquiry) {
return false
}
const tabIndex = this.currentInquiry.tabIndex
const tab = this.$store.state.tabs[tabIndex]
return tab && tab.isSaved
return this.currentInquiry && this.currentInquiry.isSaved
},
isPredefined () {
if (this.currentInquiry) {
return this.currentInquiry.isPredefined
} else {
return false
}
return this.currentInquiry && this.currentInquiry.isPredefined
},
runDisabled () {
return this.currentInquiry && (!this.$store.state.db || !this.currentInquiry.query)
@@ -110,6 +105,8 @@ export default {
this.$router.push('/workspace')
}
})
events.send('inquiry.create', null, { auto: false })
},
cancelSave () {
this.$modal.hide('save')
@@ -139,13 +136,15 @@ export default {
// Update tab in store
this.$store.commit('updateTab', {
index: this.currentInquiry.tabIndex,
tab: this.currentInquiry,
newValues: {
name: value.name,
id: value.id,
query: value.query,
viewType: value.viewType,
viewOptions: value.viewOptions,
isSaved: true
}
})
// Restore data:
@@ -163,6 +162,7 @@ export default {
// Signal about saving
this.$root.$emit('inquirySaved')
events.send('inquiry.save')
},
_keyListener (e) {
if (this.$route.path === '/workspace') {
@@ -237,4 +237,12 @@ button {
#nav-buttons {
display: flex;
}
#nav-links {
display: flex;
align-items: center;
}
#nav-links img {
width: 32px;
}
</style>

View File

@@ -33,6 +33,7 @@
<script>
import fIo from '@/lib/utils/fileIo'
import events from '@/lib/utils/events'
import TableDescription from './TableDescription'
import TextField from '@/components/TextField'
import TreeChevron from '@/components/svg/treeChevron'
@@ -86,6 +87,11 @@ export default {
csvImport.reset()
await csvImport.previewCsv()
csvImport.open()
events.send('database.import', this.file.size, {
from: 'csv',
new_db: false
})
}
}
}

View File

@@ -31,10 +31,15 @@ import PlotlyEditor from 'react-chart-editor'
import chartHelper from '@/lib/chartHelper'
import dereference from 'react-chart-editor/lib/lib/dereference'
import fIo from '@/lib/utils/fileIo'
import events from '@/lib/utils/events'
export default {
name: 'Chart',
props: ['dataSources', 'initOptions', 'importToPngEnabled', 'importToSvgEnabled'],
props: [
'dataSources', 'initOptions',
'importToPngEnabled', 'importToSvgEnabled',
'forPivot'
],
components: {
PlotlyEditor
},
@@ -55,6 +60,24 @@ export default {
return chartHelper.getOptionsFromDataSources(this.dataSources)
}
},
created () {
// https://github.com/plotly/plotly.js/issues/4555
plotly.setPlotConfig({
notifyOnLogging: 1
})
this.$watch(
() => this.state && this.state.data && this.state.data
.map(trace => `${trace.type}${trace.mode ? '-' + trace.mode : ''}`)
.join(','),
(value) => {
events.send('viz_plotly.render', null, {
type: value,
pivot: !!this.forPivot
})
},
{ deep: true }
)
},
mounted () {
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.chartContainer)
@@ -99,6 +122,13 @@ export default {
fIo.downloadFromUrl(url, 'chart')
},
saveAsHtml () {
fIo.exportToFile(
chartHelper.getHtml(this.state),
'chart.html',
'text/html'
)
},
async prepareCopy (type = 'png') {
return await chartHelper.getImageDataUrl(this.$refs.plotlyEditor.$el, type)
}

View File

@@ -19,10 +19,11 @@ import $ from 'jquery'
import 'pivottable'
import 'pivottable/dist/pivot.css'
import PivotUi from './PivotUi'
import { getPivotCanvas } from './pivotHelper'
import pivotHelper from './pivotHelper'
import Chart from '@/views/Main/Workspace/Tabs/Tab/DataView/Chart'
import chartHelper from '@/lib/chartHelper'
import Vue from 'vue'
import events from '@/lib/utils/events'
const ChartClass = Vue.extend(Chart)
export default {
@@ -87,8 +88,17 @@ export default {
'pivotOptions.rendererName': {
immediate: true,
handler () {
this.$emit('update:importToPngEnabled', this.pivotOptions.rendererName !== 'TSV Export')
this.$emit('update:importToSvgEnabled', this.viewStandartChart || this.viewCustomChart)
this.$emit(
'update:importToPngEnabled',
this.pivotOptions.rendererName !== 'TSV Export'
)
this.$emit(
'update:importToSvgEnabled',
this.viewStandartChart || this.viewCustomChart
)
events.send('viz_pivot.render', null, {
type: this.pivotOptions.rendererName
})
}
},
pivotOptions () {
@@ -169,7 +179,7 @@ export default {
} else {
const source = this.viewStandartChart
? await chartHelper.getImageDataUrl(this.$refs.pivotOutput, 'png')
: (await getPivotCanvas(this.$refs.pivotOutput)).toDataURL('image/png')
: (await pivotHelper.getPivotCanvas(this.$refs.pivotOutput)).toDataURL('image/png')
this.$emit('loadingImageCompleted')
fIo.downloadFromUrl(source, 'pivot')
@@ -179,11 +189,11 @@ export default {
async prepareCopy () {
if (this.viewCustomChart) {
return await this.pivotOptions.rendererOptions.customChartComponent.prepareCopy()
} else if (this.viewStandartChart) {
return await chartHelper.getImageDataUrl(this.$refs.pivotOutput, 'png')
} else {
return await getPivotCanvas(this.$refs.pivotOutput)
}
if (this.viewStandartChart) {
return await chartHelper.getImageDataUrl(this.$refs.pivotOutput, 'png')
}
return await pivotHelper.getPivotCanvas(this.$refs.pivotOutput)
},
async saveAsSvg () {
@@ -193,6 +203,28 @@ export default {
const url = await chartHelper.getImageDataUrl(this.$refs.pivotOutput, 'svg')
fIo.downloadFromUrl(url, 'pivot')
}
},
saveAsHtml () {
if (this.viewCustomChart) {
this.pivotOptions.rendererOptions.customChartComponent.saveAsHtml()
return
}
if (this.viewStandartChart) {
const chartState = chartHelper.getChartData(this.$refs.pivotOutput)
fIo.exportToFile(
chartHelper.getHtml(chartState),
'chart.html',
'text/html'
)
return
}
fIo.exportToFile(
pivotHelper.getPivotHtml(this.$refs.pivotOutput),
'pivot.html',
'text/html'
)
}
}
}

View File

@@ -51,6 +51,8 @@ export function _getDataSources (pivotData) {
function customChartRenderer (data, options) {
options.customChartComponent.dataSources = _getDataSources(data)
options.customChartComponent.forPivot = true
options.customChartComponent.$mount()
return $(options.customChartComponent.$el)
@@ -81,3 +83,40 @@ export async function getPivotCanvas (pivotOutput) {
const tableElement = pivotOutput.querySelector('.pvtTable')
return await html2canvas(tableElement, { logging: false })
}
export function getPivotHtml (pivotOutput) {
return `
<style>
table.pvtTable {
font-family: Arial, sans-serif;
font-size: 12px;
text-align: left;
border-collapse: collapse;
min-width: 100%;
}
table.pvtTable .pvtColLabel {
text-align: center;
}
table.pvtTable .pvtTotalLabel {
text-align: right;
}
table.pvtTable tbody tr td {
color: #506784;
border: 1px solid #DFE8F3;
text-align: right;
}
table.pvtTable thead tr th,
table.pvtTable tbody tr th {
background-color: #506784;
color: #fff;
border: 1px solid #DFE8F3;
}
</style>
${pivotOutput.outerHTML}
`
}
export default {
getPivotCanvas,
getPivotHtml
}

View File

@@ -51,6 +51,13 @@
<export-to-svg-icon />
</icon-button>
<icon-button
tooltip="Save as HTML"
tooltip-position="top-left"
@click="saveAsHtml"
>
<HtmlIcon />
</icon-button>
<icon-button
:loading="copyingImage"
tooltip="Copy visualisation to clipboard"
@@ -81,12 +88,14 @@ import SideToolBar from '../SideToolBar'
import IconButton from '@/components/IconButton'
import ChartIcon from '@/components/svg/chart'
import PivotIcon from '@/components/svg/pivot'
import HtmlIcon from '@/components/svg/html'
import ExportToSvgIcon from '@/components/svg/exportToSvg'
import PngIcon from '@/components/svg/png'
import ClipboardIcon from '@/components/svg/clipboard'
import cIo from '@/lib/utils/clipboardIo'
import loadingDialog from '@/components/LoadingDialog'
import time from '@/lib/utils/time'
import events from '@/lib/utils/events'
export default {
name: 'DataView',
@@ -100,6 +109,7 @@ export default {
PivotIcon,
ExportToSvgIcon,
PngIcon,
HtmlIcon,
ClipboardIcon,
loadingDialog
},
@@ -114,6 +124,11 @@ export default {
dataToCopy: null
}
},
computed: {
plotlyInPivot () {
return this.mode === 'pivot' && this.$refs.viewComponent.viewCustomChart
}
},
watch: {
mode () {
this.$emit('update')
@@ -139,6 +154,7 @@ export default {
*/
await time.sleep(0)
this.$refs.viewComponent.saveAsPng()
this.exportSignal('png')
},
getOptionsForSave () {
return this.$refs.viewComponent.getOptionsForSave()
@@ -169,6 +185,7 @@ export default {
async copyToClipboard () {
cIo.copyImage(this.dataToCopy)
this.$modal.hide('prepareCopy')
this.exportSignal('clipboard')
},
cancelCopy () {
this.dataToCopy = null
@@ -177,6 +194,26 @@ export default {
saveAsSvg () {
this.$refs.viewComponent.saveAsSvg()
this.exportSignal('svg')
},
saveAsHtml () {
this.$refs.viewComponent.saveAsHtml()
this.exportSignal('html')
},
exportSignal (to) {
const eventLabels = { type: to }
if (this.mode === 'chart' || this.plotlyInPivot) {
eventLabels.pivot = this.plotlyInPivot
}
events.send(
this.mode === 'chart' || this.plotlyInPivot
? 'viz_plotly.export'
: 'viz_pivot.export',
null,
eventLabels
)
}
}
}

View File

@@ -72,6 +72,7 @@ import fIo from '@/lib/utils/fileIo'
import cIo from '@/lib/utils/clipboardIo'
import time from '@/lib/utils/time'
import loadingDialog from '@/components/LoadingDialog'
import events from '@/lib/utils/events'
export default {
name: 'RunResult',
@@ -117,10 +118,24 @@ export default {
},
exportToCsv () {
if (this.result && this.result.values) {
events.send('resultset.export',
this.result.values[this.result.columns[0]].length,
{ to: 'csv' }
)
}
fIo.exportToFile(csv.serialize(this.result), 'result_set.csv', 'text/csv')
},
async prepareCopy () {
if (this.result && this.result.values) {
events.send('resultset.export',
this.result.values[this.result.columns[0]].length,
{ to: 'clipboard' }
)
}
if ('ClipboardItem' in window) {
this.preparingCopy = true
this.$modal.show('prepareCSVCopy')

View File

@@ -3,14 +3,20 @@ import 'codemirror/addon/hint/show-hint.js'
import 'codemirror/addon/hint/sql-hint.js'
import store from '@/store'
function _getHintText (hint) {
return typeof hint === 'string' ? hint : hint.text
}
export function getHints (cm, options) {
const token = cm.getTokenAt(cm.getCursor()).string.toUpperCase()
const result = CM.hint.sql(cm, options)
// Don't show the hint if there is only one option
// and the token is already completed with this option
if (result.list.length === 1 && result.list[0].text.toUpperCase() === token) {
// and the replacingText is already equals to this option
const replacedText = cm.getRange(result.from, result.to).toUpperCase()
if (result.list.length === 1 &&
_getHintText(result.list[0]).toUpperCase() === replacedText) {
result.list = []
}
return result
}

View File

@@ -3,44 +3,45 @@
<splitpanes
class="query-results-splitter"
horizontal
:before="{ size: 50, max: 100 }"
:after="{ size: 50, max: 100 }"
:before="{ size: topPaneSize, max: 100 }"
:after="{ size: 100 - topPaneSize, max: 100 }"
:default="{ before: 50, after: 50 }"
>
<template #left-pane>
<div :id="'above-' + tabIndex" class="above" />
<div :id="'above-' + tab.id" class="above" />
</template>
<template #right-pane>
<div :id="'bottom-'+ tabIndex" ref="bottomPane" class="bottomPane" />
<div :id="'bottom-'+ tab.id" ref="bottomPane" class="bottomPane" />
</template>
</splitpanes>
<div :id="'hidden-'+ tabIndex" class="hidden-part" />
<div :id="'hidden-'+ tab.id" class="hidden-part" />
<teleport :to="`#${layout.sqlEditor}-${tabIndex}`">
<teleport :to="`#${tab.layout.sqlEditor}-${tab.id}`">
<sql-editor
ref="sqlEditor"
v-model="query"
:is-getting-results="isGettingResults"
v-model="tab.query"
:is-getting-results="tab.isGettingResults"
@switchTo="onSwitchView('sqlEditor', $event)"
@run="execute"
@run="tab.execute()"
/>
</teleport>
<teleport :to="`#${layout.table}-${tabIndex}`">
<teleport :to="`#${tab.layout.table}-${tab.id}`">
<run-result
:result="result"
:is-getting-results="isGettingResults"
:error="error"
:time="time"
:result="tab.result"
:is-getting-results="tab.isGettingResults"
:error="tab.error"
:time="tab.time"
@switchTo="onSwitchView('table', $event)"
/>
</teleport>
<teleport :to="`#${layout.dataView}-${tabIndex}`">
<teleport :to="`#${tab.layout.dataView}-${tab.id}`">
<data-view
:data-source="(result && result.values) || null"
:init-options="initViewOptions"
:init-mode="initViewType"
:data-source="(tab.result && tab.result.values) || null"
:init-options="tab.viewOptions"
:init-mode="tab.viewType"
ref="dataView"
@switchTo="onSwitchView('dataView', $event)"
@update="onDataViewUpdate"
@@ -54,14 +55,15 @@ import Splitpanes from '@/components/Splitpanes'
import SqlEditor from './SqlEditor'
import DataView from './DataView'
import RunResult from './RunResult'
import time from '@/lib/utils/time'
import Teleport from 'vue2-teleport'
import events from '@/lib/utils/events'
export default {
name: 'Tab',
props: [
'id', 'initName', 'initQuery', 'initViewOptions', 'tabIndex', 'isPredefined', 'initViewType'
],
props: {
tab: Object
},
components: {
SqlEditor,
DataView,
@@ -71,21 +73,14 @@ export default {
},
data () {
return {
query: this.initQuery,
result: null,
isGettingResults: false,
error: null,
time: 0,
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
}
topPaneSize: this.tab.maximize
? this.tab.layout[this.tab.maximize] === 'above' ? 100 : 0
: 50
}
},
computed: {
isActive () {
return this.id === this.$store.state.currentTabId
return this.tab.id === this.$store.state.currentTabId
}
},
watch: {
@@ -93,42 +88,34 @@ export default {
immediate: true,
async handler () {
if (this.isActive) {
this.$store.commit('setCurrentTab', this)
await this.$nextTick()
this.$refs.sqlEditor.focus()
}
}
},
query () {
this.$store.commit('updateTab', { index: this.tabIndex, isSaved: false })
'tab.query' () {
this.$store.commit('updateTab', {
tab: this.tab,
newValues: { isSaved: false }
})
}
},
mounted () {
this.tab.dataView = this.$refs.dataView
},
methods: {
onSwitchView (from, to) {
const fromPosition = this.layout[from]
this.layout[from] = this.layout[to]
this.layout[to] = fromPosition
const fromPosition = this.tab.layout[from]
this.tab.layout[from] = this.tab.layout[to]
this.tab.layout[to] = fromPosition
events.send('inquiry.panel', null, { panel: to })
},
onDataViewUpdate () {
this.$store.commit('updateTab', { index: this.tabIndex, isSaved: false })
},
async execute () {
this.isGettingResults = true
this.result = null
this.error = null
const state = this.$store.state
try {
const start = new Date()
this.result = await state.db.execute(this.query + ';')
this.time = time.getPeriod(start, new Date())
} catch (err) {
this.error = {
type: 'error',
message: err
}
}
state.db.refreshSchema()
this.isGettingResults = false
this.$store.commit('updateTab', {
tab: this.tab,
newValues: { isSaved: false }
})
}
}
}

View File

@@ -5,7 +5,7 @@
v-for="(tab, index) in tabs"
:key="index"
@click="selectTab(tab.id)"
:class="[{'tab-selected': (tab.id === selectedIndex)}, 'tab']"
:class="[{'tab-selected': (tab.id === selectedTabId)}, 'tab']"
>
<div class="tab-name">
<span v-show="!tab.isSaved" class="star">*</span>
@@ -13,20 +13,14 @@
<span v-else class="tab-untitled">{{ tab.tempName }}</span>
</div>
<div>
<close-icon class="close-icon" :size="10" @click="beforeCloseTab(index)"/>
<close-icon class="close-icon" :size="10" @click="beforeCloseTab(tab)"/>
</div>
</div>
</div>
<tab
v-for="(tab, index) in tabs"
v-for="tab in tabs"
:key="tab.id"
:id="tab.id"
:init-name="tab.name"
:init-query="tab.query"
:init-view-options="tab.viewOptions"
:init-view-type="tab.viewType"
:is-predefined="tab.isPredefined"
:tab-index="index"
:tab="tab"
/>
<div v-show="tabs.length === 0" id="start-guide">
<span class="link" @click="$root.$emit('createNewInquiry')">Create</span>
@@ -38,25 +32,25 @@
<modal name="close-warn" classes="dialog" height="auto">
<div class="dialog-header">
Close tab {{
closingTabIndex !== null
? (tabs[closingTabIndex].name || `[${tabs[closingTabIndex].tempName}]`)
closingTab !== null
? (closingTab.name || `[${closingTab.tempName}]`)
: ''
}}
<close-icon @click="$modal.hide('close-warn')"/>
</div>
<div class="dialog-body">
You have unsaved changes. Save changes in {{
closingTabIndex !== null
? (tabs[closingTabIndex].name || `[${tabs[closingTabIndex].tempName}]`)
closingTab !== null
? (closingTab.name || `[${closingTab.tempName}]`)
: ''
}} before closing?
</div>
<div class="dialog-buttons-container">
<button class="secondary" @click="closeTab(closingTabIndex)">
<button class="secondary" @click="closeTab(closingTab)">
Close without saving
</button>
<button class="secondary" @click="$modal.hide('close-warn')">Cancel</button>
<button class="primary" @click="saveAndClose(closingTabIndex)">Save and close</button>
<button class="primary" @click="saveAndClose(closingTab)">Save and close</button>
</div>
</modal>
</div>
@@ -73,14 +67,14 @@ export default {
},
data () {
return {
closingTabIndex: null
closingTab: null
}
},
computed: {
tabs () {
return this.$store.state.tabs
},
selectedIndex () {
selectedTabId () {
return this.$store.state.currentTabId
}
},
@@ -97,25 +91,24 @@ export default {
selectTab (id) {
this.$store.commit('setCurrentTabId', id)
},
beforeCloseTab (index) {
this.closingTabIndex = index
if (!this.tabs[index].isSaved) {
beforeCloseTab (tab) {
this.closingTab = tab
if (!tab.isSaved) {
this.$modal.show('close-warn')
} else {
this.closeTab(index)
this.closeTab(tab)
}
},
closeTab (index) {
closeTab (tab) {
this.$modal.hide('close-warn')
this.closingTabIndex = null
this.$store.commit('deleteTab', index)
this.$store.commit('deleteTab', tab)
},
saveAndClose (index) {
saveAndClose (tab) {
this.$root.$on('inquirySaved', () => {
this.closeTab(index)
this.closeTab(tab)
this.$root.$off('inquirySaved')
})
this.selectTab(this.tabs[index].id)
this.selectTab(tab.id)
this.$modal.hide('close-warn')
this.$nextTick(() => {
this.$root.$emit('saveInquiry')

View File

@@ -2,8 +2,9 @@
<div>
<splitpanes
class="schema-tabs-splitter"
:before="{ size: 20, max: 30 }"
:after="{ size: 80, max: 100 }"
:before="{ size: schemaWidth, max: 30 }"
:after="{ size: 100 - schemaWidth, max: 100 }"
:default="{ before: 20, after: 80 }"
>
<template #left-pane>
<schema/>
@@ -19,6 +20,7 @@
import Splitpanes from '@/components/Splitpanes'
import Schema from './Schema'
import Tabs from './Tabs'
import events from '@/lib/utils/events'
export default {
name: 'Workspace',
@@ -27,9 +29,14 @@ export default {
Splitpanes,
Tabs
},
data () {
return {
schemaWidth: this.$route.query.hide_schema === '1' ? 0 : 20
}
},
async beforeCreate () {
const schema = this.$store.state.db.schema
if (!schema || schema.length === 0) {
if ((!schema || schema.length === 0) && this.$store.state.tabs.length === 0) {
const stmt = [
'/*',
' * Your database is empty. In order to start building charts',
@@ -49,6 +56,8 @@ export default {
const tabId = await this.$store.dispatch('addTab', { query: stmt })
this.$store.commit('setCurrentTabId', tabId)
events.send('inquiry.create', null, { auto: true })
}
}
}

View File

@@ -55,6 +55,7 @@ describe('Logs.vue', () => {
await wrapper.vm.$nextTick()
messages.push({ type: 'error', message: 'msg 5' })
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
const height = wrapper.find('.logs-container').element.scrollHeight
expect(wrapper.find('.logs-container').element.scrollTop)

View File

@@ -40,7 +40,7 @@ describe('Splitpanes.vue', () => {
expect(wrapper.findAll('.splitpanes-pane').at(1).element.style.height).to.equal('40%')
})
it('toggles correctly', async () => {
it('toggles correctly - no maximized initially', async () => {
// mount the component
const wrapper = shallowMount(Splitpanes, {
slots: {
@@ -70,6 +70,64 @@ describe('Splitpanes.vue', () => {
expect(wrapper.findAll('.splitpanes-pane').at(1).element.style.width).to.equal('40%')
})
it('toggles correctly - with maximized initially', async () => {
// mount the component
let wrapper = shallowMount(Splitpanes, {
slots: {
leftPane: '<div />',
rightPane: '<div />'
},
propsData: {
before: { size: 0, max: 100 },
after: { size: 100, max: 100 },
default: { before: 20, after: 80 }
}
})
await wrapper.find('.toggle-btn').trigger('click')
expect(wrapper.findAll('.splitpanes-pane').at(0).element.style.width).to.equal('20%')
expect(wrapper.findAll('.splitpanes-pane').at(1).element.style.width).to.equal('80%')
await wrapper.findAll('.toggle-btn').at(0).trigger('click')
expect(wrapper.findAll('.splitpanes-pane').at(0).element.style.width).to.equal('0%')
expect(wrapper.findAll('.splitpanes-pane').at(1).element.style.width).to.equal('100%')
await wrapper.find('.toggle-btn').trigger('click')
expect(wrapper.findAll('.splitpanes-pane').at(0).element.style.width).to.equal('20%')
expect(wrapper.findAll('.splitpanes-pane').at(1).element.style.width).to.equal('80%')
await wrapper.findAll('.toggle-btn').at(1).trigger('click')
expect(wrapper.findAll('.splitpanes-pane').at(0).element.style.width).to.equal('100%')
expect(wrapper.findAll('.splitpanes-pane').at(1).element.style.width).to.equal('0%')
wrapper = shallowMount(Splitpanes, {
slots: {
leftPane: '<div />',
rightPane: '<div />'
},
propsData: {
before: { size: 100, max: 100 },
after: { size: 0, max: 100 }
}
})
await wrapper.find('.toggle-btn').trigger('click')
expect(wrapper.findAll('.splitpanes-pane').at(0).element.style.width).to.equal('50%')
expect(wrapper.findAll('.splitpanes-pane').at(1).element.style.width).to.equal('50%')
await wrapper.findAll('.toggle-btn').at(0).trigger('click')
expect(wrapper.findAll('.splitpanes-pane').at(0).element.style.width).to.equal('0%')
expect(wrapper.findAll('.splitpanes-pane').at(1).element.style.width).to.equal('100%')
await wrapper.find('.toggle-btn').trigger('click')
expect(wrapper.findAll('.splitpanes-pane').at(0).element.style.width).to.equal('50%')
expect(wrapper.findAll('.splitpanes-pane').at(1).element.style.width).to.equal('50%')
await wrapper.findAll('.toggle-btn').at(1).trigger('click')
expect(wrapper.findAll('.splitpanes-pane').at(0).element.style.width).to.equal('100%')
expect(wrapper.findAll('.splitpanes-pane').at(1).element.style.width).to.equal('0%')
})
it('drag - vertical', async () => {
const root = document.createElement('div')
const place = document.createElement('div')

View File

@@ -64,7 +64,40 @@ describe('chartHelper.js', () => {
expect(/^data:image\/png/.test(url)).to.equal(true)
url = await chartHelper.getImageDataUrl(element, 'svg')
console.log()
expect(/^data:image\/svg\+xml/.test(url)).to.equal(true)
})
it('getChartData returns plotly data and layout from element', async () => {
const element = document.createElement('div')
const child = document.createElement('div')
element.append(child)
child.classList.add('js-plotly-plot')
child.data = 'plotly data'
child.layout = 'plotly layout'
const chartData = chartHelper.getChartData(element)
expect(chartData).to.eql({
data: 'plotly data',
layout: 'plotly layout'
})
})
it('getHtml returns valid html', async () => {
const options = {
data: 'plotly data',
layout: 'plotly layout'
}
const html = chartHelper.getHtml(options)
const doc = document.createElement('div')
doc.innerHTML = html
expect(doc.innerHTML).to.equal(html)
expect(doc.children).to.have.lengthOf(3)
expect(doc.children[0].src).to.includes('plotly-latest.js')
expect(doc.children[1].id).to.have.lengthOf(21)
expect(doc.children[2].innerHTML).to.includes(doc.children[1].id)
expect(doc.children[2].innerHTML)
.to.includes('Plotly.newPlot(el, "plotly data", "plotly layout"')
})
})

View File

@@ -116,6 +116,33 @@ describe('csv.js', () => {
await expect(csv.parse(file)).to.be.rejectedWith(err)
})
it('parse rejects when getResult failed', async () => {
let err
try {
new Date('invalid date').toISOString()
} catch (e) {
err = e // get error message, it's different depending on browser
}
sinon.stub(Papa, 'parse').callsFake((file, config) => {
config.complete({
data: [
[1, new Date('invalid date')],
[2, new Date('2023-05-05T15:30:00Z')]
],
errors: [],
meta: {
delimiter: ',',
linebreak: '\n',
aborted: false,
truncated: true
}
})
})
const file = {}
await expect(csv.parse(file)).to.be.rejectedWith(err.message)
})
it('prepareForExport', () => {
const resultSet = {
columns: ['id', 'name'],

View File

@@ -44,7 +44,7 @@ describe('database.js', () => {
expect(schema[0].columns[0].type).to.equal('N/A')
expect(schema[0].columns[1].name).to.equal('col2')
expect(schema[0].columns[1].type).to.equal('integer')
expect(schema[0].columns[1].type).to.equal('INTEGER')
})
it('creates schema with view', async () => {
@@ -68,12 +68,12 @@ describe('database.js', () => {
expect(schema[0].columns[1]).to.eql({
name: 'col2',
type: 'integer'
type: 'INTEGER'
})
expect(schema[1].columns).to.eql([{
name: 'amount',
type: 'integer'
type: 'INTEGER'
}])
})

View File

@@ -269,6 +269,48 @@ describe('SQLite extensions', function () {
})
})
it('supports percentile', async function () {
const actual = await db.execute(`
CREATE TABLE s(x INTEGER);
INSERT INTO s VALUES (15), (20), (35), (40), (50);
SELECT
percentile(x, 5) p5,
percentile(x, 30) p30,
percentile(x, 40) p40,
percentile(x, 50) p50,
percentile(x, 100) p100
FROM s;
`)
expect(actual.values).to.eql({
p5: [16],
p30: [23],
p40: [29],
p50: [35],
p100: [50]
})
})
it('supports decimal', async function () {
const actual = await db.execute(`
SELECT
decimal_add(decimal('0.1'), decimal('0.2')) "add",
decimal_sub(0.2, 0.1) sub,
decimal_mul(power(2, 69), 2) mul,
decimal_cmp(decimal('0.1'), 0.1) cmp_e,
decimal_cmp(decimal('0.1'), decimal('0.099999')) cmp_g,
decimal_cmp(decimal('0.199999'), decimal('0.2')) cmp_l
`)
expect(actual.values).to.eql({
add: ['0.3'],
sub: ['0.1'],
mul: ['1180591620717412000000'],
cmp_e: [0],
cmp_g: [1],
cmp_l: [-1]
})
})
it('supports FTS5', async function () {
const actual = await db.execute(`
CREATE VIRTUAL TABLE email USING fts5(sender, title, body, tokenize = 'porter ascii');
@@ -296,4 +338,121 @@ describe('SQLite extensions', function () {
sender: ['bar@localhost']
})
})
it('supports FTS3', async function () {
const actual = await db.execute(`
CREATE VIRTUAL TABLE email USING fts3(sender, title, body, tokenize = 'porter');
INSERT INTO email VALUES
(
'foo@localhost',
'fts3/4',
'FTS3 and FTS4 are SQLite virtual table modules that allows users to perform '
|| 'full-text searches on a set of documents.'
),
(
'bar@localhost',
'fts4',
'FTS5 is an SQLite virtual table module that provides full-text search '
|| 'functionality to database applications.'
);
SELECT sender
FROM email
WHERE body MATCH '("full-text" NOT document AND (functionality OR table))';
`)
expect(actual.values).to.eql({
sender: ['bar@localhost']
})
})
it('supports FTS4', async function () {
const actual = await db.execute(`
CREATE VIRTUAL TABLE email USING fts4(
sender, title, body, notindexed=sender, tokenize='simple'
);
INSERT INTO email VALUES
(
'foo@localhost',
'fts3/4',
'FTS3 and FTS4 are SQLite virtual table modules that allows users to perform '
|| 'full-text searches on a set of documents.'
),
(
'bar@localhost',
'fts4',
'FTS5 is an SQLite virtual table module that provides full-text search '
|| 'functionality to database applications.'
);
SELECT sender
FROM email
WHERE body MATCH '("full-text" NOT document AND (functionality OR table NOT modules))';
`)
expect(actual.values).to.eql({
sender: ['bar@localhost']
})
})
it('supports JSON1', async function () {
const actual = await db.execute(`
WITH input(filename) AS (
VALUES
('/etc/redis/redis.conf'),
('/run/redis/redis-server.pid'),
('/var/log/redis-server.log')
), tmp AS (
SELECT
filename,
'["' || replace(filename, '/', '", "') || '"]' as filename_array
FROM input
)
SELECT (
SELECT group_concat(ip.value, '/')
FROM json_each(filename_array) ip
WHERE ip.id <= p.id
) AS path
FROM tmp, json_each(filename_array) AS p
WHERE p.id > 1 -- because the filenames start with the separator
`)
expect(actual.values).to.eql({
path: [
'/etc',
'/etc/redis',
'/etc/redis/redis.conf',
'/run',
'/run/redis',
'/run/redis/redis-server.pid',
'/var',
'/var/log',
'/var/log/redis-server.log'
]
})
})
it('supports pearson', async function () {
const actual = await db.execute(`
CREATE TABLE dataset(x REAL, y REAL, z REAL);
INSERT INTO dataset VALUES
(5,3,3.2), (5,6,4.3), (5,9,5.4),
(10,3,4), (10,6,3.8), (10,9,3.6),
(15,3,4.8), (15,6,4), (15,9,3.5);
SELECT
pearson(x, x) xx,
pearson(x, y) xy,
abs(-0.12666 - pearson(x, z)) < 0.00001 xz,
pearson(y, x) yx,
pearson(y, y) yy,
abs(0.10555 - pearson(y, z)) < 0.00001 yz,
abs(-0.12666 - pearson(z, x)) < 0.00001 zx,
abs(0.10555 - pearson(z, y)) < 0.00001 zy,
pearson(z, z) zz
FROM dataset;
`)
expect(actual.values).to.eql({
xx: [1], xy: [0], xz: [1], yx: [0], yy: [1], yz: [1], zx: [1], zy: [1], zz: [1]
})
})
})

View File

@@ -87,14 +87,14 @@ describe('storedInquiries.js', () => {
it('isTabNeedName returns false when the inquiry has a name and is not predefined', () => {
const tab = {
initName: 'foo'
name: 'foo'
}
expect(storedInquiries.isTabNeedName(tab)).to.equal(false)
})
it('isTabNeedName returns true when the inquiry has no name and is not predefined', () => {
const tab = {
initName: null,
name: null,
tempName: 'Untitled'
}
expect(storedInquiries.isTabNeedName(tab)).to.equal(true)
@@ -102,7 +102,7 @@ describe('storedInquiries.js', () => {
it('isTabNeedName returns true when the inquiry is predefined', () => {
const tab = {
initName: 'foo',
name: 'foo',
isPredefined: true
}
@@ -351,14 +351,13 @@ describe('storedInquiries.js', () => {
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
initName: null,
$refs: {
name: null,
dataView: {
getOptionsForSave () {
return ['chart']
}
}
}
}
const value = storedInquiries.save(tab, 'foo')
expect(value.id).to.equal(tab.id)
@@ -376,19 +375,18 @@ describe('storedInquiries.js', () => {
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
initName: null,
$refs: {
name: null,
dataView: {
getOptionsForSave () {
return ['chart']
}
}
}
}
const first = storedInquiries.save(tab, 'foo')
tab.initName = 'foo'
tab.name = 'foo'
tab.query = 'select * from foo'
storedInquiries.save(tab)
const inquiries = storedInquiries.getStoredInquiries()
@@ -409,13 +407,11 @@ describe('storedInquiries.js', () => {
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
initName: 'foo predefined',
$refs: {
name: 'foo predefined',
dataView: {
getOptionsForSave () {
return ['chart']
}
}
},
isPredefined: true
}

189
tests/lib/tab.spec.js Normal file
View File

@@ -0,0 +1,189 @@
import { expect } from 'chai'
import sinon from 'sinon'
import Tab from '@/lib/tab.js'
describe('tab.js', () => {
it('Creates a tab for new inquiry', () => {
const state = {
untitledLastIndex: 5
}
const newTab = new Tab(state)
expect(newTab).to.include({
name: null,
tempName: 'Untitled 5',
query: undefined,
viewOptions: undefined,
isPredefined: undefined,
viewType: 'chart',
result: null,
isGettingResults: false,
error: null,
time: 0,
isSaved: false,
state: state
})
expect(newTab.layout).to.include({
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
})
expect(newTab.id).to.have.lengthOf(21)
})
it('Creates a tab for existing inquiry', () => {
const state = {
untitledLastIndex: 5
}
const inquiry = {
id: 'qwerty',
query: 'SELECT * from foo',
viewType: 'pivot',
viewOptions: 'this is view options object',
name: 'Foo inquiry',
createdAt: '2022-12-05T18:30:30'
}
const newTab = new Tab(state, inquiry)
expect(newTab).to.include({
id: 'qwerty',
name: 'Foo inquiry',
tempName: 'Foo inquiry',
query: 'SELECT * from foo',
viewOptions: 'this is view options object',
isPredefined: undefined,
viewType: 'pivot',
result: null,
isGettingResults: false,
error: null,
time: 0,
isSaved: true,
state: state
})
expect(newTab.layout).to.include({
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
})
})
it('Set isGettingResults true when execute', async () => {
let resolveQuering
// mock store state
const state = {
currentTabId: 1,
dbName: 'fooDb',
db: {
execute: sinon.stub().returns(new Promise(resolve => {
resolveQuering = resolve
})),
refreshSchema: sinon.stub().resolves()
}
}
const newTab = new Tab(state, {
id: 'qwerty',
query: 'SELECT * FROM foo; CREATE TABLE bar(a,b);',
viewType: 'cart',
viewOptions: 'this is view options object',
name: 'Foo inquiry',
createdAt: '2022-12-05T18:30:30'
})
expect(newTab.isGettingResults).to.equal(false)
newTab.execute()
expect(newTab.isGettingResults).to.equal(true)
resolveQuering()
})
it('Updates result with query execution result', async () => {
const result = {
columns: ['id', 'name'],
values: {
id: [1, 2],
name: ['Harry', 'Drako']
}
}
// mock store state
const state = {
currentTabId: 1,
dbName: 'fooDb',
db: {
execute: sinon.stub().resolves(result),
refreshSchema: sinon.stub().resolves()
}
}
const newTab = new Tab(state, {
id: 'qwerty',
query: 'SELECT * FROM foo; CREATE TABLE bar(a,b);',
viewType: 'cart',
viewOptions: 'this is view options object',
name: 'Foo inquiry',
createdAt: '2022-12-05T18:30:30'
})
await newTab.execute()
expect(newTab.isGettingResults).to.equal(false)
expect(newTab.result).to.eql(result)
})
it('Updates error with query execution error', async () => {
// mock store state
const state = {
currentTabId: 1,
dbName: 'fooDb',
db: {
execute: sinon.stub().rejects(new Error('No such table')),
refreshSchema: sinon.stub().resolves()
}
}
const newTab = new Tab(state, {
id: 'qwerty',
query: 'SELECT * FROM foo; CREATE TABLE bar(a,b);',
viewType: 'cart',
viewOptions: 'this is view options object',
name: 'Foo inquiry',
createdAt: '2022-12-05T18:30:30'
})
await newTab.execute()
expect(newTab.error.type).to.eql('error')
expect(newTab.error.message.toString()).to.equal('Error: No such table')
})
it('Updates schema after query execution', async () => {
const result = {
columns: ['id', 'name'],
values: {
id: [],
name: []
}
}
// mock store state
const state = {
currentTabId: 1,
dbName: 'fooDb',
db: {
execute: sinon.stub().resolves(result),
refreshSchema: sinon.stub().resolves()
}
}
const newTab = new Tab(state, {
id: 'qwerty',
query: 'SELECT * FROM foo; CREATE TABLE bar(a,b);',
viewType: 'cart',
viewOptions: 'this is view options object',
name: 'Foo inquiry',
createdAt: '2022-12-05T18:30:30'
})
await newTab.execute()
expect(state.db.refreshSchema.calledOnce).to.equal(true)
})
})

View File

@@ -29,12 +29,12 @@ describe('time.js', () => {
})
it('sleep resolves after n ms', async () => {
let before = performance.now()
let before = Date.now()
await time.sleep(10)
expect(performance.now() - before).to.be.least(10)
expect(Date.now() - before).to.be.least(10)
before = performance.now()
before = Date.now()
await time.sleep(30)
expect(performance.now() - before).to.be.least(30)
expect(Date.now() - before).to.be.least(30)
})
})

View File

@@ -11,7 +11,7 @@ describe('actions', () => {
}
let id = await addTab({ state })
expect(state.tabs[0]).to.eql({
expect(state.tabs[0]).to.include({
id: id,
name: null,
tempName: 'Untitled',
@@ -22,7 +22,7 @@ describe('actions', () => {
expect(state.untitledLastIndex).to.equal(1)
id = await addTab({ state })
expect(state.tabs[1]).to.eql({
expect(state.tabs[1]).to.include({
id: id,
name: null,
tempName: 'Untitled 1',
@@ -41,14 +41,13 @@ describe('actions', () => {
const tab = {
id: 1,
name: 'test',
tempName: null,
query: 'SELECT * from foo',
viewType: 'chart',
viewOptions: {},
viewOptions: 'an object with view options',
isSaved: true
}
await addTab({ state }, tab)
expect(state.tabs[0]).to.eql(tab)
expect(state.tabs[0]).to.include(tab)
expect(state.untitledLastIndex).to.equal(0)
})

View File

@@ -5,9 +5,10 @@ const {
updateTab,
deleteTab,
setCurrentTabId,
setCurrentTab,
updatePredefinedInquiries,
setDb
setDb,
setLoadingPredefinedInquiries,
setPredefinedInquiriesLoaded
} = mutations
describe('mutations', () => {
@@ -35,8 +36,7 @@ describe('mutations', () => {
isPredefined: false
}
const newTab = {
index: 0,
const newValues = {
id: 1,
name: 'new test',
query: 'SELECT * from bar',
@@ -49,7 +49,7 @@ describe('mutations', () => {
tabs: [tab]
}
updateTab(state, newTab)
updateTab(state, { tab, newValues })
expect(state.tabs[0]).to.eql({
id: 1,
name: 'new test',
@@ -73,8 +73,7 @@ describe('mutations', () => {
isPredefined: true
}
const newTab = {
index: 0,
const newValues = {
id: 2,
name: 'new test',
query: 'SELECT * from bar',
@@ -88,7 +87,7 @@ describe('mutations', () => {
currentTabId: 1
}
updateTab(state, newTab)
updateTab(state, { tab, newValues })
expect(state.tabs).to.have.lengthOf(1)
expect(state.currentTabId).to.equal(2)
expect(state.tabs[0].id).to.equal(2)
@@ -109,8 +108,7 @@ describe('mutations', () => {
isSaved: false
}
const newTab = {
index: 0,
const newValues = {
id: 1,
name: 'new test'
}
@@ -119,7 +117,7 @@ describe('mutations', () => {
tabs: [tab]
}
updateTab(state, newTab)
updateTab(state, { tab, newValues })
expect(state.tabs).to.have.lengthOf(1)
expect(state.tabs[0].id).to.equal(1)
expect(state.tabs[0].name).to.equal('new test')
@@ -139,8 +137,7 @@ describe('mutations', () => {
isPredefined: true
}
const newTab = {
index: 0,
const newValues = {
isSaved: false
}
@@ -148,7 +145,7 @@ describe('mutations', () => {
tabs: [tab]
}
updateTab(state, newTab)
updateTab(state, { tab, newValues })
expect(state.tabs).to.have.lengthOf(1)
expect(state.tabs[0].id).to.equal(1)
expect(state.tabs[0].name).to.equal('test')
@@ -179,13 +176,15 @@ describe('mutations', () => {
const state = {
tabs: [tab1, tab2],
currentTabId: 1
currentTabId: 1,
currentTab: tab1
}
deleteTab(state, 0)
deleteTab(state, tab1)
expect(state.tabs).to.have.lengthOf(1)
expect(state.tabs[0].id).to.equal(2)
expect(state.currentTabId).to.equal(2)
expect(state.currentTab).to.eql(tab2)
})
it('deleteTab - opened, last', () => {
@@ -211,13 +210,15 @@ describe('mutations', () => {
const state = {
tabs: [tab1, tab2],
currentTabId: 2
currentTabId: 2,
currentTab: tab2
}
deleteTab(state, 1)
deleteTab(state, tab2)
expect(state.tabs).to.have.lengthOf(1)
expect(state.tabs[0].id).to.equal(1)
expect(state.currentTabId).to.equal(1)
expect(state.currentTab).to.eql(tab1)
})
it('deleteTab - opened, in the middle', () => {
@@ -253,14 +254,16 @@ describe('mutations', () => {
const state = {
tabs: [tab1, tab2, tab3],
currentTabId: 2
currentTabId: 2,
currentTab: tab2
}
deleteTab(state, 1)
deleteTab(state, tab2)
expect(state.tabs).to.have.lengthOf(2)
expect(state.tabs[0].id).to.equal(1)
expect(state.tabs[1].id).to.equal(3)
expect(state.currentTabId).to.equal(3)
expect(state.currentTab).to.eql(tab3)
})
it('deleteTab - opened, single', () => {
@@ -276,48 +279,19 @@ describe('mutations', () => {
const state = {
tabs: [tab1],
currentTabId: 1
currentTabId: 1,
currentTab: tab1
}
deleteTab(state, 0)
deleteTab(state, tab1)
expect(state.tabs).to.have.lengthOf(0)
expect(state.currentTabId).to.equal(null)
})
it('deleteTab - not opened', () => {
const tab1 = {
id: 1,
name: 'foo',
tempName: null,
query: 'SELECT * from foo',
viewType: 'chart',
viewOptions: {},
isSaved: true
}
const tab2 = {
id: 2,
name: 'bar',
tempName: null,
query: 'SELECT * from bar',
viewType: 'chart',
viewOptions: {},
isSaved: true
}
const state = {
tabs: [tab1, tab2],
currentTabId: 1
}
deleteTab(state, 1)
expect(state.tabs).to.have.lengthOf(1)
expect(state.tabs[0].id).to.equal(1)
expect(state.currentTabId).to.equal(1)
expect(state.currentTab).to.equal(null)
})
it('setCurrentTabId', () => {
const state = {
tabs: [{ id: 1 }, { id: 2 }],
currentTabId: 1
}
@@ -325,15 +299,6 @@ describe('mutations', () => {
expect(state.currentTabId).to.equal(2)
})
it('setCurrentTab', () => {
const state = {
currentTab: { id: 1 }
}
setCurrentTab(state, { id: 2 })
expect(state.currentTab).to.eql({ id: 2 })
})
it('updatePredefinedInquiries - single', () => {
const inquiry = {
id: 1,
@@ -377,4 +342,22 @@ describe('mutations', () => {
updatePredefinedInquiries(state, inquiries)
expect(state.predefinedInquiries).to.eql(inquiries)
})
it('setLoadingPredefinedInquiries', () => {
const state = {
loadingPredefinedInquiries: false
}
setLoadingPredefinedInquiries(state, true)
expect(state.loadingPredefinedInquiries).to.equal(true)
})
it('setPredefinedInquiriesLoaded', () => {
const state = {
predefinedInquiriesLoaded: false
}
setPredefinedInquiriesLoaded(state, true)
expect(state.predefinedInquiriesLoaded).to.equal(true)
})
})

View File

@@ -0,0 +1,147 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { mount } from '@vue/test-utils'
import Vuex from 'vuex'
import LoadView from '@/views/LoadView'
import fu from '@/lib/utils/fileIo'
import database from '@/lib/database'
import realMutations from '@/store/mutations'
import realActions from '@/store/actions'
import flushPromises from 'flush-promises'
import Tab from '@/lib/tab'
describe('LoadView.vue', () => {
afterEach(() => {
sinon.restore()
})
it('Loads db and inquiries and redirects to workspace if no errors', async () => {
const state = {
tabs: []
}
const mutations = {
setCurrentTabId: sinon.stub().callsFake(realMutations.setCurrentTabId),
setDb: sinon.stub().callsFake(realMutations.setDb)
}
const actions = {
addTab: sinon.stub().callsFake(realActions.addTab)
}
const store = new Vuex.Store({ state, mutations, actions })
const $route = {
path: '/workspace',
query: {
data_url: 'https://my-url/test.db',
data_format: 'sqlite',
inquiry_url: 'https://my-url/test_inquiries.json',
inquiry_id: [1],
maximize: 'dataView'
}
}
const $router = { push: sinon.stub() }
const readFile = sinon.stub(fu, 'readFile')
const dataRes = new Response()
dataRes.blob = sinon.stub().resolves({})
readFile.onCall(0).returns(Promise.resolve(dataRes))
const inquiriesRes = new Response()
inquiriesRes.json = sinon.stub().resolves({
version: 2,
inquiries: [{ id: 1, name: 'foo' }, { id: 2, name: 'bar' }]
})
readFile.onCall(1).returns(Promise.resolve(inquiriesRes))
const db = {
loadDb: sinon.stub().resolves()
}
sinon.stub(database, 'getNewDatabase').returns(db)
Tab.prototype.execute = sinon.stub()
const wrapper = mount(LoadView, {
store,
mocks: { $route, $router },
stubs: ['router-link']
})
await flushPromises()
// DB file is read
expect(fu.readFile.firstCall.args[0]).to.equal('https://my-url/test.db')
// Db is loaded
expect(db.loadDb.firstCall.args[0]).to.equal(await dataRes.blob.returnValues[0])
// Inquiries file is read
expect(fu.readFile.secondCall.args[0])
.to.equal('https://my-url/test_inquiries.json')
// Tab for inquiry is created
expect(actions.addTab.calledOnce).to.equal(true)
expect(actions.addTab.firstCall.args[1]).eql({
id: undefined,
name: 'foo',
layout: {
dataView: 'bottom',
sqlEditor: 'hidden',
table: 'above'
},
maximize: 'dataView'
})
const executedTab = Tab.prototype.execute.firstCall.thisValue
expect(executedTab.tempName).to.equal('foo')
expect(wrapper.find('#open-workspace-btn').exists()).to.equal(false)
expect($router.push.called).to.equal(true)
})
it('Doesn\'t redirect and show the button if there is an error', async () => {
const state = {
tabs: []
}
const mutations = {
setCurrentTabId: sinon.stub().callsFake(realMutations.setCurrentTabId),
setDb: sinon.stub().callsFake(realMutations.setDb)
}
const actions = {
addTab: sinon.stub().callsFake(realActions.addTab)
}
const store = new Vuex.Store({ state, mutations, actions })
const $route = {
path: '/workspace',
query: {
data_url: 'https://my-url/test.db',
data_format: 'sqlite',
inquiry_url: 'https://my-url/test_inquiries.json',
inquiry_id: [1],
maximize: 'dataView'
}
}
const $router = { push: sinon.stub() }
const readFile = sinon.stub(fu, 'readFile')
const dataRes = new Response()
dataRes.blob = sinon.stub().rejects(new Error('Something is wrong'))
readFile.onCall(0).returns(Promise.resolve(dataRes))
const inquiriesRes = new Response()
inquiriesRes.json = sinon.stub().resolves({
version: 2,
inquiries: [{ id: 1 }]
})
readFile.onCall(1).returns(Promise.resolve(inquiriesRes))
sinon.stub(database, 'getNewDatabase').returns({
loadDb: sinon.stub().resolves()
})
const wrapper = mount(LoadView, {
store,
mocks: { $route, $router },
stubs: ['router-link']
})
await flushPromises()
expect(wrapper.find('#open-workspace-btn').exists()).to.equal(true)
expect($router.push.called).to.equal(false)
expect(wrapper.find('#logs').text()).to.include('Something is wrong')
})
})

View File

@@ -19,7 +19,9 @@ describe('Inquiries.vue', () => {
predefinedInquiries: []
}
const mutations = {
updatePredefinedInquiries: sinon.stub()
setPredefinedInquiriesLoaded: sinon.stub(),
updatePredefinedInquiries: sinon.stub(),
setLoadingPredefinedInquiries: sinon.stub()
}
const store = new Vuex.Store({ state, mutations })
const wrapper = shallowMount(Inquiries, { store })
@@ -327,6 +329,7 @@ describe('Inquiries.vue', () => {
sinon.stub(storedInquiries, 'getStoredInquiries').returns([inquiryInStorage])
const state = {
tabs: [],
predefinedInquiries: []
}
const actions = { addTab: sinon.stub().resolves(1) }

View File

@@ -45,7 +45,7 @@ describe('MainMenu.vue', () => {
it('Save is not visible if there is no tabs', () => {
const state = {
currentTab: null,
tabs: [{}],
tabs: [],
db: {}
}
const store = new Vuex.Store({ state })
@@ -62,13 +62,15 @@ describe('MainMenu.vue', () => {
})
it('Save is disabled if current tab.isSaved is true', async () => {
const state = {
currentTab: {
const tab = {
id: 1,
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ isSaved: false }],
isSaved: false
}
const state = {
currentTab: tab,
tabs: [tab],
db: {}
}
const store = new Vuex.Store({ state })
@@ -83,17 +85,19 @@ describe('MainMenu.vue', () => {
expect(wrapper.find('#save-btn').element.disabled).to.equal(false)
await vm.$set(state.tabs[0], 'isSaved', true)
await vm.$nextTick()
expect(wrapper.find('#save-btn').element.disabled).to.equal(true)
})
it('Creates a tab', async () => {
const state = {
currentTab: {
const tab = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ isSaved: false }],
isSaved: false
}
const state = {
currentTab: tab,
tabs: [tab],
db: {}
}
const newInquiryId = 1
@@ -121,13 +125,14 @@ describe('MainMenu.vue', () => {
})
it('Creates a tab and redirects to workspace', async () => {
const state = {
currentTab: {
const tab = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ isSaved: false }],
isSaved: false
}
const state = {
currentTab: tab,
tabs: [tab],
db: {}
}
const newInquiryId = 1
@@ -156,13 +161,14 @@ describe('MainMenu.vue', () => {
it('Ctrl R calls currentTab.execute if running is enabled and route.path is "/workspace"',
async () => {
const state = {
currentTab: {
const tab = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ isSaved: false }],
isSaved: false
}
const state = {
currentTab: tab,
tabs: [tab],
db: {}
}
const store = new Vuex.Store({ state })
@@ -201,13 +207,14 @@ describe('MainMenu.vue', () => {
it('Ctrl Enter calls currentTab.execute if running is enabled and route.path is "/workspace"',
async () => {
const state = {
currentTab: {
const tab = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ isSaved: false }],
isSaved: false
}
const state = {
currentTab: tab,
tabs: [tab],
db: {}
}
const store = new Vuex.Store({ state })
@@ -245,13 +252,14 @@ describe('MainMenu.vue', () => {
})
it('Ctrl B calls createNewInquiry', async () => {
const state = {
currentTab: {
const tab = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ isSaved: false }],
isSaved: false
}
const state = {
currentTab: tab,
tabs: [tab],
db: {}
}
const store = new Vuex.Store({ state })
@@ -280,13 +288,14 @@ describe('MainMenu.vue', () => {
it('Ctrl S calls checkInquiryBeforeSave if the tab is unsaved and route path is /workspace',
async () => {
const state = {
currentTab: {
const tab = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ isSaved: false }],
isSaved: false
}
const state = {
currentTab: tab,
tabs: [tab],
db: {}
}
const store = new Vuex.Store({ state })
@@ -325,13 +334,16 @@ describe('MainMenu.vue', () => {
it('Saves the inquiry when no need the new name',
async () => {
const state = {
currentTab: {
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ id: 1, name: 'foo', isSaved: false }],
isSaved: false
}
const state = {
currentTab: tab,
tabs: [tab],
db: {}
}
const mutations = {
@@ -364,13 +376,15 @@ describe('MainMenu.vue', () => {
// check that the tab was updated
expect(mutations.updateTab.calledOnceWith(state, sinon.match({
index: 0,
tab,
newValues: {
name: 'foo',
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true
}
}))).to.equal(true)
// check that 'inquirySaved' event was triggered on $root
@@ -378,13 +392,17 @@ describe('MainMenu.vue', () => {
})
it('Shows en error when the new name is needed but not specifyied', async () => {
const state = {
currentTab: {
const tab = {
id: 1,
name: null,
tempName: 'Untitled',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ id: 1, name: null, tempName: 'Untitled', isSaved: false }],
isSaved: false
}
const state = {
currentTab: tab,
tabs: [tab],
db: {}
}
const mutations = {
@@ -424,13 +442,17 @@ describe('MainMenu.vue', () => {
})
it('Saves the inquiry with a new name', async () => {
const state = {
currentTab: {
const tab = {
id: 1,
name: null,
tempName: 'Untitled',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ id: 1, name: null, tempName: 'Untitled', isSaved: false }],
isSaved: false
}
const state = {
currentTab: tab,
tabs: [tab],
db: {}
}
const mutations = {
@@ -475,13 +497,15 @@ describe('MainMenu.vue', () => {
// check that the tab was updated
expect(mutations.updateTab.calledOnceWith(state, sinon.match({
index: 0,
tab: tab,
newValues: {
name: 'foo',
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true
}
}))).to.equal(true)
// check that 'inquirySaved' event was triggered on $root
@@ -489,11 +513,11 @@ describe('MainMenu.vue', () => {
})
it('Saves a predefined inquiry with a new name', async () => {
const state = {
currentTab: {
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0,
isPredefined: true,
result: {
columns: ['id', 'name'],
@@ -503,9 +527,12 @@ describe('MainMenu.vue', () => {
]
},
viewType: 'chart',
viewOptions: []
},
tabs: [{ id: 1, name: 'foo', isSaved: false, isPredefined: true }],
viewOptions: [],
isSaved: false
}
const state = {
currentTab: tab,
tabs: [tab],
db: {}
}
const mutations = {
@@ -553,13 +580,15 @@ describe('MainMenu.vue', () => {
// check that the tab was updated
expect(mutations.updateTab.calledOnceWith(state, sinon.match({
index: 0,
tab,
newValues: {
name: 'bar',
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true
}
}))).to.equal(true)
// check that 'inquirySaved' event was triggered on $root
@@ -580,13 +609,17 @@ describe('MainMenu.vue', () => {
})
it('Cancel saving', async () => {
const state = {
currentTab: {
const tab = {
id: 1,
name: null,
tempName: 'Untitled',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ id: 1, name: null, tempName: 'Untitled', isSaved: false }],
isSaved: false
}
const state = {
currentTab: tab,
tabs: [tab],
db: {}
}
const mutations = {

View File

@@ -59,6 +59,31 @@ describe('DataView.vue', () => {
expect(pivot.saveAsSvg.calledOnce).to.equal(true)
})
it('method saveAsHtml calls the same method of the current view component', async () => {
const wrapper = mount(DataView)
// Find chart and spy the method
const chart = wrapper.findComponent({ name: 'Chart' }).vm
sinon.spy(chart, 'saveAsHtml')
// Export to html
const htmlBtn = createWrapper(wrapper.findComponent({ name: 'htmlIcon' }).vm.$parent)
await htmlBtn.trigger('click')
expect(chart.saveAsHtml.calledOnce).to.equal(true)
// Switch to pivot
const pivotBtn = createWrapper(wrapper.findComponent({ name: 'pivotIcon' }).vm.$parent)
await pivotBtn.trigger('click')
// Find pivot and spy the method
const pivot = wrapper.findComponent({ name: 'pivot' }).vm
sinon.spy(pivot, 'saveAsHtml')
// Export to svg
await htmlBtn.trigger('click')
expect(pivot.saveAsHtml.calledOnce).to.equal(true)
})
it('shows alert when ClipboardItem is not supported', async () => {
const ClipboardItem = window.ClipboardItem
delete window.ClipboardItem

View File

@@ -5,6 +5,7 @@ import chartHelper from '@/lib/chartHelper'
import fIo from '@/lib/utils/fileIo'
import $ from 'jquery'
import sinon from 'sinon'
import pivotHelper from '@/views/Main/Workspace/Tabs/Tab/DataView/Pivot/pivotHelper'
describe('Pivot.vue', () => {
let container
@@ -271,6 +272,41 @@ describe('Pivot.vue', () => {
expect(chartComponent.saveAsSvg.called).to.equal(true)
})
it('saveAsHtml calls chart method if renderer is Custom Chart', async () => {
const wrapper = mount(Pivot, {
propsData: {
dataSources: {
item: ['foo', 'bar', 'bar', 'bar'],
year: [2021, 2021, 2020, 2020]
},
initOptions: {
rows: ['item'],
cols: ['year'],
colOrder: 'key_a_to_z',
rowOrder: 'key_a_to_z',
aggregatorName: 'Count',
vals: [],
renderer: $.pivotUtilities.renderers['Custom chart'],
rendererName: 'Custom chart',
rendererOptions: {
customChartOptions: {
data: [],
layout: {},
frames: []
}
}
}
},
attachTo: container
})
const chartComponent = wrapper.vm.pivotOptions.rendererOptions.customChartComponent
sinon.stub(chartComponent, 'saveAsHtml')
await wrapper.vm.saveAsHtml()
expect(chartComponent.saveAsHtml.called).to.equal(true)
})
it('saveAsPng calls chart method if renderer is Custom Chart', async () => {
const wrapper = mount(Pivot, {
propsData: {
@@ -333,6 +369,66 @@ describe('Pivot.vue', () => {
expect(chartHelper.getImageDataUrl.calledOnce).to.equal(true)
})
it('saveAsHtml - standart chart', async () => {
sinon.spy(chartHelper, 'getChartData')
sinon.spy(chartHelper, 'getHtml')
const wrapper = mount(Pivot, {
propsData: {
dataSources: {
item: ['foo', 'bar', 'bar', 'bar'],
year: [2021, 2021, 2020, 2020]
},
initOptions: {
rows: ['item'],
cols: ['year'],
colOrder: 'key_a_to_z',
rowOrder: 'key_a_to_z',
aggregatorName: 'Count',
vals: [],
renderer: $.pivotUtilities.renderers['Bar Chart'],
rendererName: 'Bar Chart'
}
},
attachTo: container
})
await wrapper.vm.saveAsHtml()
expect(chartHelper.getChartData.calledOnce).to.equal(true)
const chartData = await chartHelper.getChartData.returnValues[0]
expect(chartHelper.getHtml.calledOnceWith(chartData)).to.equal(true)
})
it('saveAsHtml - table', async () => {
sinon.stub(pivotHelper, 'getPivotHtml')
sinon.stub(fIo, 'exportToFile')
const wrapper = mount(Pivot, {
propsData: {
dataSources: {
item: ['foo', 'bar', 'bar', 'bar'],
year: [2021, 2021, 2020, 2020]
},
initOptions: {
rows: ['item'],
cols: ['year'],
colOrder: 'key_a_to_z',
rowOrder: 'key_a_to_z',
aggregatorName: 'Count',
vals: [],
renderer: $.pivotUtilities.renderers.Table,
rendererName: 'Table'
}
},
attachTo: container
})
await wrapper.vm.saveAsHtml()
expect(pivotHelper.getPivotHtml.calledOnce).to.equal(true)
const html = pivotHelper.getPivotHtml.returnValues[0]
expect(fIo.exportToFile.calledOnceWith(html, 'pivot.html', 'text/html')).to.equal(true)
})
it('saveAsPng - standart chart', async () => {
sinon.stub(chartHelper, 'getImageDataUrl').returns('standat chart data url')
sinon.stub(fIo, 'downloadFromUrl')
@@ -365,6 +461,7 @@ describe('Pivot.vue', () => {
})
it('saveAsPng - table', async () => {
sinon.stub(pivotHelper, 'getPivotCanvas').returns(document.createElement('canvas'))
sinon.stub(HTMLCanvasElement.prototype, 'toDataURL').returns('canvas data url')
sinon.stub(fIo, 'downloadFromUrl')

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { _getDataSources, getPivotCanvas }
from '@/views/Main/Workspace/Tabs/Tab/DataView/Pivot/pivotHelper'
import { _getDataSources, getPivotCanvas, getPivotHtml }
from '@/views/Main/Workspace/Tabs/Tab/DataView/Pivot/pivotHelper'
describe('pivotHelper.js', () => {
it('_getDataSources returns data sources', () => {
@@ -63,4 +63,19 @@ describe('pivotHelper.js', () => {
expect(await getPivotCanvas(pivotOutput)).to.be.instanceof(HTMLCanvasElement)
})
it('getPivotHtml returns html with styles', async () => {
const pivotOutput = document.createElement('div')
pivotOutput.append('test')
const html = getPivotHtml(pivotOutput)
const doc = document.createElement('div')
doc.innerHTML = html
expect(doc.innerHTML).to.equal(html)
expect(doc.children).to.have.lengthOf(2)
expect(doc.children[0].tagName).to.equal('STYLE')
expect(doc.children[1].tagName).to.equal('DIV')
expect(doc.children[1].innerHTML).to.equal('test')
})
})

View File

@@ -138,15 +138,37 @@ describe('hint.js', () => {
'getHints returns [ ] if there is only one option and token is completed with this option',
() => {
// mock CM.hint.sql and editor
sinon.stub(CM.hint, 'sql').returns({ list: [{ text: 'SELECT' }] })
sinon.stub(CM.hint, 'sql').returns({
list: [{ text: 'SELECT' }],
from: null, // from/to doesn't metter because getRange is mocked
to: null
})
const editor = {
getTokenAt () {
return {
string: 'select',
type: 'keyword'
getRange () {
return 'select'
}
}
const hints = getHints(editor, {})
expect(hints.list).to.eql([])
}
)
it(
'getHints returns [ ] if there is only one string option and token ' +
'is completed with this option',
() => {
// mock CM.hint.sql and editor
sinon.stub(CM.hint, 'sql').returns({
list: ['house.name'],
from: null, // from/to doesn't metter because getRange is mocked
to: null
})
const editor = {
getRange () {
return 'house.name'
}
},
getCursor: sinon.stub()
}
const hints = getHints(editor, {})
@@ -160,15 +182,11 @@ describe('hint.js', () => {
{ text: 'SELECT' },
{ text: 'ST' }
]
sinon.stub(CM.hint, 'sql').returns({ list })
sinon.stub(CM.hint, 'sql').returns({ list, from: null, to: null })
const editor = {
getTokenAt () {
return {
string: 'se',
type: 'keyword'
getRange () {
return 'se'
}
},
getCursor: sinon.stub()
}
const hints = getHints(editor, {})
@@ -182,15 +200,11 @@ describe('hint.js', () => {
() => {
// mock CM.hint.sql and editor
const list = [{ text: 'SELECT' }]
sinon.stub(CM.hint, 'sql').returns({ list })
sinon.stub(CM.hint, 'sql').returns({ list, from: null, to: null })
const editor = {
getTokenAt () {
return {
string: 'sele',
type: 'keyword'
getRange () {
return 'sele'
}
},
getCursor: sinon.stub()
}
const hints = getHints(editor, {})

View File

@@ -31,13 +31,23 @@ describe('Tab.vue', () => {
store,
stubs: ['chart'],
propsData: {
tab: {
id: 1,
initName: 'foo',
initQuery: 'SELECT * FROM foo',
initViewType: 'chart',
initViewOptions: [],
tabIndex: 0,
isPredefined: false
name: 'foo',
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isPredefined: false,
result: null,
isGettingResults: false,
error: null,
time: 0
}
}
})
@@ -60,7 +70,23 @@ describe('Tab.vue', () => {
store,
stubs: ['chart'],
propsData: {
id: 1
tab: {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isPredefined: false,
result: null,
isGettingResults: false,
error: null,
time: 0
}
}
})
expect(wrapper.find('.tab-content-container').isVisible()).to.equal(false)
@@ -79,40 +105,51 @@ describe('Tab.vue', () => {
store,
stubs: ['chart'],
propsData: {
id: 1
tab: {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isPredefined: false,
result: null,
isGettingResults: false,
error: null,
time: 0
}
}
})
expect(wrapper.find('.tab-content-container').isVisible()).to.equal(false)
})
it('Calls setCurrentTab when becomes active', async () => {
// mock store state
const state = {
currentTabId: 0
}
sinon.spy(mutations, 'setCurrentTab')
const store = new Vuex.Store({ state, mutations })
// mount the component
const wrapper = mount(Tab, {
store,
stubs: ['chart'],
propsData: {
id: 1
}
})
state.currentTabId = 1
await wrapper.vm.$nextTick()
expect(mutations.setCurrentTab.calledOnceWith(state, wrapper.vm)).to.equal(true)
})
it('Update tab state when a query is changed', async () => {
// mock store state
const state = {
tabs: [
{ id: 1, name: 'foo', query: 'SELECT * FROM foo', chart: [], isSaved: true }
{
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isPredefined: false,
result: null,
isGettingResults: false,
error: null,
time: 0,
isSaved: true
}
],
currentTabId: 1
}
@@ -124,13 +161,7 @@ describe('Tab.vue', () => {
store,
stubs: ['chart'],
propsData: {
id: 1,
initName: 'foo',
initQuery: 'SELECT * FROM foo',
initViewOptions: [],
initViewType: 'chart',
tabIndex: 0,
isPredefined: false
tab: state.tabs[0]
}
})
await wrapper.findComponent({ name: 'SqlEditor' }).vm.$emit('input', ' limit 100')
@@ -141,7 +172,24 @@ describe('Tab.vue', () => {
// mock store state
const state = {
tabs: [
{ id: 1, name: 'foo', query: 'SELECT * FROM foo', chart: [], isSaved: true }
{
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isPredefined: false,
result: null,
isGettingResults: false,
error: null,
time: 0,
isSaved: true
}
],
currentTabId: 1
}
@@ -153,13 +201,7 @@ describe('Tab.vue', () => {
store,
stubs: ['chart'],
propsData: {
id: 1,
initName: 'foo',
initQuery: 'SELECT * FROM foo',
initViewOptions: [],
initViewType: 'chart',
tabIndex: 0,
isPredefined: false
tab: state.tabs[0]
}
})
await wrapper.findComponent({ name: 'DataView' }).vm.$emit('update')
@@ -169,29 +211,38 @@ describe('Tab.vue', () => {
it('Shows .result-in-progress message when executing query', async () => {
// mock store state
const state = {
currentTabId: 1,
db: {
execute () { return new Promise(() => {}) }
}
currentTabId: 1
}
const store = new Vuex.Store({ state, mutations })
// mount the component
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isPredefined: false,
result: null,
isGettingResults: false,
error: null,
time: 0,
isSaved: true
}
const wrapper = mount(Tab, {
store,
stubs: ['chart'],
propsData: {
id: 1,
initName: 'foo',
initQuery: 'SELECT * FROM foo',
initViewOptions: [],
initViewType: 'chart',
tabIndex: 0,
isPredefined: false
tab
}
})
wrapper.vm.execute()
tab.isGettingResults = true
await wrapper.vm.$nextTick()
expect(wrapper.find('.run-result-panel .result-in-progress').isVisible()).to.equal(true)
})
@@ -199,30 +250,42 @@ describe('Tab.vue', () => {
it('Shows error when executing query ends with error', async () => {
// mock store state
const state = {
currentTabId: 1,
db: {
execute: sinon.stub().rejects(new Error('There is no table foo')),
refreshSchema: sinon.stub().resolves()
}
currentTabId: 1
}
const store = new Vuex.Store({ state, mutations })
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isPredefined: false,
result: null,
isGettingResults: false,
error: null,
time: 0,
isSaved: true
}
// mount the component
const wrapper = mount(Tab, {
store,
stubs: ['chart'],
propsData: {
id: 1,
initName: 'foo',
initQuery: 'SELECT * FROM foo',
initViewOptions: [],
initViewType: 'chart',
tabIndex: 0,
isPredefined: false
tab
}
})
await wrapper.vm.execute()
tab.error = {
type: 'error',
message: 'There is no table foo'
}
await wrapper.vm.$nextTick()
expect(wrapper.find('.run-result-panel .result-before').isVisible()).to.equal(false)
expect(wrapper.find('.run-result-panel .result-in-progress').exists()).to.equal(false)
expect(wrapper.findComponent({ name: 'logs' }).isVisible()).to.equal(true)
@@ -239,11 +302,26 @@ describe('Tab.vue', () => {
}
// mock store state
const state = {
currentTabId: 1,
db: {
execute: sinon.stub().resolves(result),
refreshSchema: sinon.stub().resolves()
currentTabId: 1
}
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isPredefined: false,
result: null,
isGettingResults: false,
error: null,
time: 0,
isSaved: true
}
const store = new Vuex.Store({ state, mutations })
@@ -253,83 +331,50 @@ describe('Tab.vue', () => {
store,
stubs: ['chart'],
propsData: {
id: 1,
initName: 'foo',
initQuery: 'SELECT * FROM foo',
initViewOptions: [],
initViewType: 'chart',
tabIndex: 0,
isPredefined: false
tab
}
})
await wrapper.vm.execute()
tab.result = result
await wrapper.vm.$nextTick()
expect(wrapper.find('.run-result-panel .result-before').isVisible()).to.equal(false)
expect(wrapper.find('.run-result-panel .result-in-progress').exists()).to.equal(false)
expect(wrapper.findComponent({ name: 'logs' }).exists()).to.equal(false)
expect(wrapper.findComponent({ name: 'SqlTable' }).vm.dataSet).to.eql(result)
})
it('Updates schema after query execution', async () => {
const result = {
columns: ['id', 'name'],
values: {
id: [],
name: []
}
}
// mock store state
const state = {
currentTabId: 1,
dbName: 'fooDb',
db: {
execute: sinon.stub().resolves(result),
refreshSchema: sinon.stub().resolves()
}
}
const store = new Vuex.Store({ state, mutations })
// mount the component
const wrapper = mount(Tab, {
store,
stubs: ['chart'],
propsData: {
id: 1,
initName: 'foo',
initQuery: 'SELECT * FROM foo; CREATE TABLE bar(a,b);',
initViewOptions: [],
initViewType: 'chart',
tabIndex: 0,
isPredefined: false
}
})
await wrapper.vm.execute()
expect(state.db.refreshSchema.calledOnce).to.equal(true)
})
it('Switches views', async () => {
const state = {
currentTabId: 1,
db: {}
currentTabId: 1
}
const store = new Vuex.Store({ state, mutations })
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo; CREATE TABLE bar(a,b);',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isPredefined: false,
result: null,
isGettingResults: false,
error: null,
time: 0,
isSaved: true
}
const wrapper = mount(Tab, {
attachTo: place,
store,
stubs: ['chart'],
propsData: {
id: 1,
initName: 'foo',
initQuery: 'SELECT * FROM foo; CREATE TABLE bar(a,b);',
initViewOptions: [],
initViewType: 'chart',
tabIndex: 0,
isPredefined: false
tab
}
})
@@ -361,4 +406,119 @@ describe('Tab.vue', () => {
expect(wrapper.find('.above .sql-editor-panel').exists()).to.equal(true)
expect(wrapper.find('.bottomPane .run-result-panel').exists()).to.equal(true)
})
it('Maximize top panel if maximized panel is above', () => {
const state = {
currentTabId: 1
}
const store = new Vuex.Store({ state, mutations })
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo; CREATE TABLE bar(a,b);',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
maximize: 'sqlEditor',
isPredefined: false,
result: null,
isGettingResults: false,
error: null,
time: 0,
isSaved: true
}
const wrapper = mount(Tab, {
attachTo: place,
store,
stubs: ['chart'],
propsData: {
tab
}
})
expect(wrapper.find('.above').element.parentElement.style.height)
.to.equal('100%')
})
it('Maximize bottom panel if maximized panel is below', () => {
const state = {
currentTabId: 1
}
const store = new Vuex.Store({ state, mutations })
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo; CREATE TABLE bar(a,b);',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
maximize: 'table',
isPredefined: false,
result: null,
isGettingResults: false,
error: null,
time: 0,
isSaved: true
}
const wrapper = mount(Tab, {
attachTo: place,
store,
stubs: ['chart'],
propsData: {
tab
}
})
expect(wrapper.find('.bottomPane').element.parentElement.style.height)
.to.equal('100%')
})
it('Panel size is 50 is nothing to maximize', () => {
const state = {
currentTabId: 1
}
const store = new Vuex.Store({ state, mutations })
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo; CREATE TABLE bar(a,b);',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isPredefined: false,
result: null,
isGettingResults: false,
error: null,
time: 0,
isSaved: true
}
const wrapper = mount(Tab, {
attachTo: place,
store,
stubs: ['chart'],
propsData: {
tab
}
})
expect(wrapper.find('.above').element.parentElement.style.height)
.to.equal('50%')
expect(wrapper.find('.bottomPane').element.parentElement.style.height)
.to.equal('50%')
})
})

View File

@@ -94,8 +94,33 @@ describe('Tabs.vue', () => {
// mock store state
const state = {
tabs: [
{ id: 1, name: 'foo', query: 'select * from foo', chart: [], isSaved: true },
{ id: 2, name: null, tempName: 'Untitled', query: '', chart: [], isSaved: false }
{
id: 1,
name: 'foo',
query: 'select * from foo',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isSaved: true
},
{
id: 2,
name: null,
tempName: 'Untitled',
query: '',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isSaved: false
}
],
currentTabId: 2
}
@@ -125,8 +150,33 @@ describe('Tabs.vue', () => {
// mock store state
const state = {
tabs: [
{ id: 1, name: 'foo', query: 'select * from foo', chart: [], isSaved: true },
{ id: 2, name: null, tempName: 'Untitled', query: '', chart: [], isSaved: false }
{
id: 1,
name: 'foo',
query: 'select * from foo',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isSaved: true
},
{
id: 2,
name: null,
tempName: 'Untitled',
query: '',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isSaved: false
}
],
currentTabId: 2
}
@@ -166,8 +216,33 @@ describe('Tabs.vue', () => {
// mock store state
const state = {
tabs: [
{ id: 1, name: 'foo', query: 'select * from foo', chart: [], isSaved: true },
{ id: 2, name: null, tempName: 'Untitled', query: '', chart: [], isSaved: false }
{
id: 1,
name: 'foo',
query: 'select * from foo',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isSaved: true
},
{
id: 2,
name: null,
tempName: 'Untitled',
query: '',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isSaved: false
}
],
currentTabId: 2
}
@@ -211,8 +286,33 @@ describe('Tabs.vue', () => {
// mock store state
const state = {
tabs: [
{ id: 1, name: 'foo', query: 'select * from foo', chart: [], isSaved: true },
{ id: 2, name: null, tempName: 'Untitled', query: '', chart: [], isSaved: false }
{
id: 1,
name: 'foo',
query: 'select * from foo',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isSaved: true
},
{
id: 2,
name: null,
tempName: 'Untitled',
query: '',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isSaved: false
}
],
currentTabId: 2
}

View File

@@ -12,9 +12,11 @@ describe('Workspace.vue', () => {
tabs: []
}
const store = new Vuex.Store({ state, actions, mutations })
const $route = { path: '/workspace', query: {} }
mount(Workspace, {
store,
stubs: ['router-link']
stubs: ['router-link'],
mocks: { $route }
})
expect(state.tabs[0].query).to.include('Your database is empty.')
@@ -24,4 +26,20 @@ describe('Workspace.vue', () => {
expect(state.tabs[0].viewOptions).to.equal(undefined)
expect(state.tabs[0].isSaved).to.equal(false)
})
it('Collapse schema if hide_schema is 1', () => {
const state = {
db: {},
tabs: []
}
const store = new Vuex.Store({ state, actions, mutations })
const $route = { path: '/workspace', query: { hide_schema: '1' } }
const vm = mount(Workspace, {
store,
stubs: ['router-link'],
mocks: { $route }
})
expect(vm.find('#schema-container').element.offsetWidth).to.equal(0)
})
})

View File

@@ -41,5 +41,12 @@ module.exports = {
.end()
config.module.rule('js').exclude.add(/worker\.js$/)
config
.plugin('html')
.tap(args => {
args[0].minify = false
return args
})
}
}