1
0
mirror of https://github.com/lana-k/sqliteviz.git synced 2025-12-06 18:18:53 +08:00

48 Commits

Author SHA1 Message Date
lana-k
3893a66f4e Merge branch 'master' of github.com:lana-k/sqliteviz 2024-09-05 22:15:38 +02:00
lana-k
1b6b7c71e9 #116 JSON file import 2024-09-05 22:15:12 +02:00
saaj
3f6427ff0e Build sqlitelua for scalar, aggregate & table-valued UDFs in Lua (#118)
* Update base Docker images

* Use performance.now() instead of Date.now() for time promise tests

* Build sqlitelua: user scalar, aggregate & table-valued functions in Lua
2024-08-25 21:03:34 +02:00
lana-k
a2464d839f #115 fix version number 2024-01-07 13:55:38 +01:00
lana-k
316e603c3c #115 style fixes 2024-01-07 13:37:21 +01:00
lana-k
88466eca5e #115 fix lint 2024-01-07 12:31:53 +01:00
lana-k
5123e39a60 #115 version 2024-01-07 12:14:08 +01:00
lana-k
4c8401f32f #115 scroll record to beginning 2024-01-06 20:36:43 +01:00
lana-k
d949629ee4 #115 fix new lines - use pre 2024-01-06 18:55:45 +01:00
lana-k
7a18e415c8 #115 add styles for blob and null 2024-01-06 16:51:35 +01:00
lana-k
878689b3f7 fix svg button state 2024-01-06 12:03:06 +01:00
lana-k
42f040975d #115 tests 2024-01-06 11:23:23 +01:00
lana-k
78e9ca2120 #115 fix tests 2024-01-03 18:26:07 +01:00
lana-k
96af391f20 #115 clear message 2024-01-02 13:57:42 +01:00
lana-k
f58b62eb0c #115 add messages 2023-12-27 23:00:05 +01:00
lana-k
b17040d3ef #115 copy cell value 2023-12-27 22:22:49 +01:00
lana-k
bc6154b9ad #115 add icons 2023-12-27 21:30:43 +01:00
lana-k
3aea8c951b #115 update value when switch row 2023-12-26 20:45:11 +01:00
lana-k
1e982a1196 #115 unselect on paging 2023-10-31 22:27:47 +01:00
lana-k
6ecbde7fd3 #115 style fixes 2023-10-31 20:48:30 +01:00
lana-k
5ee881432a #115 select cell between modes; pass record number 2023-10-29 20:01:51 +01:00
lana-k
735e4ec7f6 #115 record and row navigator 2023-10-28 22:51:28 +02:00
lana-k
07d31dbfe9 #115 unselect 2023-10-28 19:48:36 +02:00
lana-k
ac1f7de62c #115 formats and call selections 2023-10-27 22:50:54 +02:00
lana-k
96877de532 #115 move focus 2023-10-27 18:47:45 +02:00
lana-k
b60fc28e47 #115 json view 2023-10-27 17:14:14 +02:00
lana-k
bec3d9c737 #115 add split in result set 2023-10-25 20:43:22 +02:00
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
85 changed files with 4078 additions and 1344 deletions

View File

@@ -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,6 +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"

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.22-buster
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

@@ -21,7 +21,7 @@ https://user-images.githubusercontent.com/24638357/128249848-f8fab0f5-9add-46e0-
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].
@@ -35,7 +35,7 @@ It is built on top of [react-chart-editor][3], [PivotTable.js][12], [sql.js][4]
[4]: https://github.com/sql-js/sql.js
[5]: https://github.com/vuejs/vue
[6]: https://sqliteviz.com/app/
[7]: https://github.com/lana-k/sqliteviz/wiki
[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

@@ -43,6 +43,10 @@ SQLite [miscellaneous extensions][3] included:
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])
3. [sqlitelua][22] -- a virtual table `luafunctions` which allows to define custom scalar,
aggregate and table-valued functions in Lua
To ease the step to have working clone locally, the build is committed into
the repository.
@@ -99,3 +103,6 @@ described in [this message from SQLite Forum][12]:
[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
[22]: https://github.com/kev82/sqlitelua

View File

@@ -1,10 +1,8 @@
FROM node:14-bullseye
FROM node:20.14-bookworm
RUN set -ex; \
echo 'deb http://deb.debian.org/debian unstable main' \
> /etc/apt/sources.list.d/unstable.list; \
apt-get update; \
apt-get install -y -t unstable firefox; \
apt-get install -y firefox-esr; \
apt-get install -y chromium
WORKDIR /tmp/build

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,7 +1,8 @@
#!/bin/bash -e
cleanup () {
rm -rf lib/dist $flag_file
rm -rf lib/dist "$renice_flag_file"
docker rm -f sqljs-benchmark-run 2> /dev/null || true
}
trap cleanup EXIT
@@ -11,34 +12,36 @@ if [ ! -f sample.csv ]; then
| 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 -1 nice)
if [ $test_ni == -1 ]; then
flag_file=$(mktemp)
test_ni=$(nice -n -5 nice)
if [ $test_ni == -5 ]; then
renice_flag_file=$(mktemp)
fi
(
while [ -f $flag_file ]; do
root_pid=$(
docker ps -f status=running -f name='^sqljs-benchmark-' -q \
| xargs -r -I{} -- docker inspect -f '{{.State.Pid}}' {}
)
if [ ! -z $root_pid ]; then
procpath query -d $'\n' "$..children[?(@.stat.pid == $root_pid)]..pid" \
| xargs -I{} -- renice -n -1 -p {} > /dev/null
fi
sleep 1
done &
)
{
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 -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 --cpus 2 --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

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,27 @@ 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',
'-Isrc', '-Isrc/lua',
)
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 +53,32 @@ 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 LLVM bitcode for SQLite Lua extension')
subprocess.check_call([
'emcc',
*cflags,
'-shared',
*(src / 'lua').glob('*.c'),
*(src / 'sqlitelua').glob('*.c'),
'-o', out / 'sqlitelua.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',
out / 'sqlitelua.o',
'-o', out / 'sql-wasm.js',
])

View File

@@ -1,14 +1,16 @@
import logging
import re
import shutil
import subprocess
import sys
import tarfile
import zipfile
from io import BytesIO
from pathlib import Path
from urllib import request
amalgamation_url = 'https://sqlite.org/2022/sqlite-amalgamation-3390000.zip'
amalgamation_url = 'https://sqlite.org/2023/sqlite-amalgamation-3410000.zip'
# Extension-functions
# ===================
@@ -28,10 +30,17 @@ extension_urls = (
('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'),
# Third-party extension with own dependencies
# ===========================================
('https://github.com/kev82/sqlitelua/raw/db479510/src/main.c', 'sqlite3_luafunctions_init'),
)
sqljs_url = 'https://github.com/sql-js/sql.js/archive/refs/tags/v1.5.0.zip'
lua_url = 'http://www.lua.org/ftp/lua-5.3.5.tar.gz'
sqlitelua_url = 'https://github.com/kev82/sqlitelua/archive/db479510.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):
@@ -58,6 +67,38 @@ def _get_amalgamation(tgt: Path):
shutil.copyfileobj(fr, fw)
def _get_lua(tgt: Path):
# Library definitions from lua/Makefile
lib_str = '''
CORE_O= lapi.o lcode.o lctype.o ldebug.o ldo.o ldump.o lfunc.o lgc.o llex.o \
lmem.o lobject.o lopcodes.o lparser.o lstate.o lstring.o ltable.o \
ltm.o lundump.o lvm.o lzio.o
LIB_O= lauxlib.o lbaselib.o lbitlib.o lcorolib.o ldblib.o liolib.o \
lmathlib.o loslib.o lstrlib.o ltablib.o lutf8lib.o loadlib.o linit.o
LUA_O= lua.o
'''
header_only_files = {'lprefix', 'luaconf', 'llimits', 'lualib'}
lib_names = set(re.findall(r'(\w+)\.o', lib_str)) | header_only_files
logging.info('Downloading and extracting Lua %s', lua_url)
archive = tarfile.open(fileobj=BytesIO(request.urlopen(lua_url).read()))
(tgt / 'lua').mkdir()
for tarinfo in archive:
tarpath = Path(tarinfo.name)
if tarpath.match('src/*') and tarpath.stem in lib_names:
with (tgt / 'lua' / tarpath.name).open('wb') as fw:
shutil.copyfileobj(archive.extractfile(tarinfo), fw)
logging.info('Downloading and extracting SQLite Lua extension %s', sqlitelua_url)
archive = zipfile.ZipFile(BytesIO(request.urlopen(sqlitelua_url).read()))
archive_root_dir = zipfile.Path(archive, archive.namelist()[0])
(tgt / 'sqlitelua').mkdir()
for zpath in (archive_root_dir / 'src').iterdir():
if zpath.name != 'main.c':
with zpath.open() as fr, (tgt / 'sqlitelua' / zpath.name).open('wb') as fw:
shutil.copyfileobj(fr, fw)
def _get_contrib_functions(tgt: Path):
request.urlretrieve(contrib_functions_url, tgt / 'extension-functions.c')
@@ -69,6 +110,7 @@ def _get_extensions(tgt: Path):
for url, init_fn in extension_urls:
logging.info('Downloading and appending to amalgamation %s', url)
with request.urlopen(url) as resp:
f.write(b'\n')
shutil.copyfileobj(resp, f)
init_functions.append(init_fn)
@@ -89,6 +131,7 @@ def _get_sqljs(tgt: Path):
def configure(tgt: Path):
_get_amalgamation(tgt)
_get_contrib_functions(tgt)
_get_lua(tgt)
_get_extensions(tgt)
_get_sqljs(tgt)

File diff suppressed because one or more lines are too long

Binary file not shown.

49
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sqliteviz",
"version": "0.18.1",
"version": "0.23.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "sqliteviz",
"version": "0.18.1",
"version": "0.23.1",
"license": "Apache-2.0",
"dependencies": {
"codemirror": "^5.57.0",
@@ -15,7 +15,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",
@@ -50,6 +50,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",
@@ -5248,9 +5249,9 @@
"dev": true
},
"node_modules/caniuse-lite": {
"version": "1.0.30001352",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001352.tgz",
"integrity": "sha512-GUgH8w6YergqPQDGWhJGt8GDRnY0L/iJVQcU3eJ46GYf52R8tk0Wxp0PymuFVZboJYXGiCqwozAYZNRjVj6IcA==",
"version": "1.0.30001488",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz",
"integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==",
"dev": true,
"funding": [
{
@@ -5260,6 +5261,10 @@
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
]
},
@@ -10086,6 +10091,12 @@
"resolved": "https://registry.npmjs.org/flip-pixels/-/flip-pixels-1.0.2.tgz",
"integrity": "sha512-oXbJGbjDnfJRWPC7Va38EFhd+A8JWE5/hCiKcK8qjCdbLj9DTpsq6MEudwpRTH+V4qq+Jw7d3pUgQdSr3x3mTA=="
},
"node_modules/flush-promises": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/flush-promises/-/flush-promises-1.0.2.tgz",
"integrity": "sha512-G0sYfLQERwKz4+4iOZYQEZVpOt9zQrlItIxQAAYAWpfby3gbHrx0osCHz5RLl/XoXevXk0xoN4hDFky/VV9TrA==",
"dev": true
},
"node_modules/flush-write-stream": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz",
@@ -16370,9 +16381,9 @@
"dev": true
},
"node_modules/papaparse": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.3.2.tgz",
"integrity": "sha512-6dNZu0Ki+gyV0eBsFKJhYr+MdQYAzFUGlBMNj3GNrmHxmz1lfRa24CjFObPXtjcetlOv5Ad299MhIK0znp3afw=="
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz",
"integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw=="
},
"node_modules/parallel-transform": {
"version": "1.2.0",
@@ -27489,7 +27500,6 @@
"integrity": "sha512-iFv9J3F5VKUPcbx+TqW5qhGmAVyXQxPRpKpPOuTLFIVTzg+iwJnrqVbL4kJU5ECGDxPESW2oCVgxv9bTlDPu7w==",
"dev": true,
"requires": {
"@babel/core": "^7.11.0",
"@babel/helper-compilation-targets": "^7.9.6",
"@babel/helper-module-imports": "^7.8.3",
"@babel/plugin-proposal-class-properties": "^7.8.3",
@@ -27502,7 +27512,6 @@
"@vue/babel-plugin-jsx": "^1.0.3",
"@vue/babel-preset-jsx": "^1.2.4",
"babel-plugin-dynamic-import-node": "^2.3.3",
"core-js": "^3.6.5",
"core-js-compat": "^3.6.5",
"semver": "^6.1.0"
}
@@ -29627,9 +29636,9 @@
"dev": true
},
"caniuse-lite": {
"version": "1.0.30001352",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001352.tgz",
"integrity": "sha512-GUgH8w6YergqPQDGWhJGt8GDRnY0L/iJVQcU3eJ46GYf52R8tk0Wxp0PymuFVZboJYXGiCqwozAYZNRjVj6IcA==",
"version": "1.0.30001488",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz",
"integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==",
"dev": true
},
"canvas-fit": {
@@ -33656,6 +33665,12 @@
"resolved": "https://registry.npmjs.org/flip-pixels/-/flip-pixels-1.0.2.tgz",
"integrity": "sha512-oXbJGbjDnfJRWPC7Va38EFhd+A8JWE5/hCiKcK8qjCdbLj9DTpsq6MEudwpRTH+V4qq+Jw7d3pUgQdSr3x3mTA=="
},
"flush-promises": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/flush-promises/-/flush-promises-1.0.2.tgz",
"integrity": "sha512-G0sYfLQERwKz4+4iOZYQEZVpOt9zQrlItIxQAAYAWpfby3gbHrx0osCHz5RLl/XoXevXk0xoN4hDFky/VV9TrA==",
"dev": true
},
"flush-write-stream": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz",
@@ -38840,9 +38855,9 @@
"dev": true
},
"papaparse": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.3.2.tgz",
"integrity": "sha512-6dNZu0Ki+gyV0eBsFKJhYr+MdQYAzFUGlBMNj3GNrmHxmz1lfRa24CjFObPXtjcetlOv5Ad299MhIK0znp3afw=="
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz",
"integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw=="
},
"parallel-transform": {
"version": "1.2.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sqliteviz",
"version": "0.20.0",
"version": "0.25.0",
"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

@@ -107,3 +107,9 @@ table.sqliteviz-table {
font-size: 11px;
color: var(--color-text-base);
}
.sqliteviz-table tbody td[data-isNull="true"],
.sqliteviz-table tbody td[data-isBlob="true"] {
color: var(--color-text-light-2);
font-style: italic;
}

View File

@@ -8,8 +8,8 @@
:clickToClose="false"
>
<div class="dialog-header">
CSV import
<close-icon @click="cancelCsvImport" :disabled="disableDialog"/>
{{ typeName }} import
<close-icon @click="cancelImport" :disabled="disableDialog"/>
</div>
<div class="dialog-body">
<text-field
@@ -18,15 +18,15 @@
width="484px"
:disabled="disableDialog"
:error-msg="tableNameError"
id="csv-table-name"
id="csv-json-table-name"
/>
<div class="chars">
<div v-if="!isJson && !isNdJson" class="chars">
<delimiter-selector
v-model="delimiter"
width="210px"
class="char-input"
@input="previewCsv"
:disabled="disableDialog"
@input="preview"
/>
<text-field
label="Quote char"
@@ -36,6 +36,7 @@
:disabled="disableDialog"
class="char-input"
id="quote-char"
@input="preview"
/>
<text-field
label="Escape char"
@@ -49,52 +50,52 @@
:disabled="disableDialog"
class="char-input"
id="escape-char"
@input="preview"
/>
</div>
<check-box
@click="header = $event"
:init="true"
v-if="!isJson && !isNdJson"
:init="header"
label="Use first row as column headers"
:disabled="disableDialog"
@click="changeHeaderDisplaying"
/>
<sql-table
v-if="previewData
&& (previewData.rowCount > 0 || Object.keys(previewData).length > 0)
"
v-if="previewData && previewData.rowCount > 0"
:data-set="previewData"
class="preview-table"
:preview="true"
class="preview-table"
/>
<div v-else class="no-data">No data</div>
<logs
class="import-csv-errors"
:messages="importCsvMessages"
class="import-errors"
:messages="importMessages"
/>
</div>
<div class="dialog-buttons-container">
<button
class="secondary"
:disabled="disableDialog"
@click="cancelCsvImport"
id="csv-cancel"
@click="cancelImport"
id="import-cancel"
>
Cancel
</button>
<button
v-show="!importCsvCompleted"
v-show="!importCompleted"
class="primary"
:disabled="disableDialog"
@click="loadFromCsv(file)"
id="csv-import"
:disabled="disableDialog || disableImport"
@click="loadToDb(file)"
id="import-start"
>
Import
</button>
<button
v-show="importCsvCompleted"
v-show="importCompleted"
class="primary"
:disabled="disableDialog"
@click="finish"
id="csv-finish"
id="import-finish"
>
Finish
</button>
@@ -112,10 +113,10 @@ import SqlTable from '@/components/SqlTable'
import Logs from '@/components/Logs'
import time from '@/lib/utils/time'
import fIo from '@/lib/utils/fileIo'
import { send } from '@/lib/utils/events'
import events from '@/lib/utils/events'
export default {
name: 'CsvImport',
name: 'CsvJsonImport',
components: {
CloseIcon,
TextField,
@@ -124,33 +125,50 @@ export default {
SqlTable,
Logs
},
props: ['file', 'db', 'dialogName'],
props: {
file: File,
db: Object,
dialogName: String
},
data () {
return {
disableDialog: false,
disableImport: false,
tableName: '',
delimiter: '',
quoteChar: '"',
escapeChar: '"',
header: true,
importCsvCompleted: false,
importCsvMessages: [],
importCompleted: false,
importMessages: [],
previewData: null,
addedTable: null,
tableNameError: ''
}
},
computed: {
isJson () {
return fIo.isJSON(this.file)
},
isNdJson () {
return fIo.isNDJSON(this.file)
},
typeName () {
return this.isJson || this.isNdJson ? 'JSON' : 'CSV'
}
},
watch: {
quoteChar () {
this.previewCsv()
isJson () {
if (this.isJson) {
this.delimiter = '\u001E'
this.header = false
}
},
escapeChar () {
this.previewCsv()
},
header () {
this.previewCsv()
isNdJson () {
if (this.isNdJson) {
this.delimiter = '\u001E'
this.header = false
}
},
tableName: time.debounce(function () {
this.tableNameError = ''
@@ -164,7 +182,11 @@ export default {
}, 400)
},
methods: {
cancelCsvImport () {
changeHeaderDisplaying (e) {
this.header = e
this.preview()
},
cancelImport () {
if (!this.disableDialog) {
if (this.addedTable) {
this.db.execute(`DROP TABLE "${this.addedTable}"`)
@@ -175,14 +197,15 @@ export default {
}
},
reset () {
this.header = true
this.header = !this.isJson && !this.isNdJson
this.quoteChar = '"'
this.escapeChar = '"'
this.delimiter = ''
this.delimiter = !this.isJson && !this.isNdJson ? '' : '\u001E'
this.tableName = ''
this.disableDialog = false
this.importCsvCompleted = false
this.importCsvMessages = []
this.disableImport = false
this.importCompleted = false
this.importMessages = []
this.previewData = null
this.addedTable = null
this.tableNameError = ''
@@ -191,39 +214,69 @@ export default {
this.tableName = this.db.sanitizeTableName(fIo.getFileName(this.file))
this.$modal.show(this.dialogName)
},
async previewCsv () {
this.importCsvCompleted = false
async preview () {
this.disableImport = false
if (!this.file) {
return
}
this.importCompleted = false
const config = {
preview: 3,
quoteChar: this.quoteChar || '"',
escapeChar: this.escapeChar,
header: this.header,
delimiter: this.delimiter
delimiter: this.delimiter,
columns: !this.isJson && !this.isNdJson ? null : ['doc']
}
try {
const start = new Date()
const parseResult = await csv.parse(this.file, config)
const parseResult = this.isJson
? await this.getJsonParseResult(this.file)
: await csv.parse(this.file, config)
const end = new Date()
this.previewData = parseResult.data
this.previewData.rowCount = parseResult.rowCount
this.delimiter = parseResult.delimiter
// In parseResult.messages we can get parse errors
this.importCsvMessages = parseResult.messages || []
this.importMessages = parseResult.messages || []
if (this.previewData.rowCount === 0) {
this.disableImport = true
this.importMessages.push({
type: 'info',
message: 'No rows to import.'
})
}
if (!parseResult.hasErrors) {
this.importCsvMessages.push({
this.importMessages.push({
message: `Preview parsing is completed in ${time.getPeriod(start, end)}.`,
type: 'success'
})
}
} catch (err) {
this.importCsvMessages = [{
console.error(err)
this.importMessages = [{
message: err,
type: 'error'
}]
}
},
async loadFromCsv (file) {
async getJsonParseResult (file) {
const jsonContent = await fIo.getFileContent(file)
const isEmpty = !jsonContent.trim()
return {
data: {
columns: ['doc'],
values: { doc: !isEmpty ? [jsonContent] : [] }
},
hasErrors: false,
messages: [],
rowCount: +(!isEmpty)
}
},
async loadToDb (file) {
if (!this.tableName) {
this.tableNameError = "Table name can't be empty"
return
@@ -234,17 +287,18 @@ export default {
quoteChar: this.quoteChar || '"',
escapeChar: this.escapeChar,
header: this.header,
delimiter: this.delimiter
delimiter: this.delimiter,
columns: !this.isJson && !this.isNdJson ? null : ['doc']
}
const parseCsvMsg = {
message: 'Parsing CSV...',
const parsingMsg = {
message: `Parsing ${this.typeName}...`,
type: 'info'
}
this.importCsvMessages.push(parseCsvMsg)
const parseCsvLoadingIndicator = setTimeout(() => { parseCsvMsg.type = 'loading' }, 1000)
this.importMessages.push(parsingMsg)
const parsingLoadingIndicator = setTimeout(() => { parsingMsg.type = 'loading' }, 1000)
const importMsg = {
message: 'Importing CSV into a SQLite database...',
message: `Importing ${this.typeName} into a SQLite database...`,
type: 'info'
}
let importLoadingIndicator = null
@@ -256,27 +310,30 @@ export default {
try {
let start = new Date()
const parseResult = await csv.parse(this.file, config)
const parseResult = this.isJson
? await this.getJsonParseResult(file)
: await csv.parse(this.file, config)
let end = new Date()
if (!parseResult.hasErrors) {
const rowCount = parseResult.rowCount
let period = time.getPeriod(start, end)
parseCsvMsg.type = 'success'
parsingMsg.type = 'success'
if (parseResult.messages.length > 0) {
this.importCsvMessages = this.importCsvMessages.concat(parseResult.messages)
parseCsvMsg.message = `${rowCount} rows are parsed in ${period}.`
this.importMessages = this.importMessages.concat(parseResult.messages)
parsingMsg.message = `${rowCount} rows are parsed in ${period}.`
} else {
// Inform about csv parsing success
parseCsvMsg.message = `${rowCount} rows are parsed successfully in ${period}.`
// Inform about parsing success
parsingMsg.message = `${rowCount} rows are parsed successfully in ${period}.`
}
// Loading indicator for csv parsing is not needed anymore
clearTimeout(parseCsvLoadingIndicator)
// Loading indicator for parsing is not needed anymore
clearTimeout(parsingLoadingIndicator)
// Add info about import start
this.importCsvMessages.push(importMsg)
this.importMessages.push(importMsg)
// Show import progress after 1 second
importLoadingIndicator = setTimeout(() => {
@@ -291,52 +348,105 @@ export default {
this.addedTable = this.tableName
// Inform about import success
period = time.getPeriod(start, end)
importMsg.message = `Importing CSV into a SQLite database is completed in ${period}.`
importMsg.message = `Importing ${this.typeName} into a SQLite database is completed in ${period}.`
importMsg.type = 'success'
// Loading indicator for import is not needed anymore
clearTimeout(importLoadingIndicator)
this.importCsvCompleted = true
this.importCompleted = true
} else {
parseCsvMsg.message = 'Parsing ended with errors.'
parseCsvMsg.type = 'info'
this.importCsvMessages = this.importCsvMessages.concat(parseResult.messages)
parsingMsg.message = 'Parsing ended with errors.'
parsingMsg.type = 'info'
this.importMessages = this.importMessages.concat(parseResult.messages)
}
} catch (err) {
if (parseCsvMsg.type === 'loading') {
parseCsvMsg.type = 'info'
console.error(err)
if (parsingMsg.type === 'loading') {
parsingMsg.type = 'info'
}
if (importMsg.type === 'loading') {
importMsg.type = 'info'
}
this.importCsvMessages.push({
this.importMessages.push({
message: err,
type: 'error'
})
}
clearTimeout(parseCsvLoadingIndicator)
clearTimeout(parsingLoadingIndicator)
clearTimeout(importLoadingIndicator)
this.db.deleteProgressCounter(progressCounterId)
this.disableDialog = false
},
async finish () {
this.$modal.hide(this.dialogName)
const stmt = [
'/*',
const stmt = this.getQueryExample()
const tabId = await this.$store.dispatch('addTab', { query: stmt })
this.$store.commit('setCurrentTabId', tabId)
this.importCompleted = false
this.$emit('finish')
events.send('inquiry.create', null, { auto: true })
},
getQueryExample () {
return this.isNdJson ? this.getNdJsonQueryExample()
: this.isJson ? this.getJsonQueryExample()
: [
'/*',
` * Your CSV file has been imported into ${this.addedTable} table.`,
' * You can run this SQL query to make all CSV records available for charting.',
' */',
`SELECT * FROM "${this.addedTable}"`
].join('\n')
const tabId = await this.$store.dispatch('addTab', { query: stmt })
this.$store.commit('setCurrentTabId', tabId)
this.importCsvCompleted = false
this.$emit('finish')
send('inquiry.create', undefined, { auto: true })
].join('\n')
},
getNdJsonQueryExample () {
try {
const firstRowJson = JSON.parse(this.previewData.values.doc[0])
const firstKey = Object.keys(firstRowJson)[0]
return [
'/*',
` * Your NDJSON file has been imported into ${this.addedTable} table.`,
` * Run this SQL query to get values of property ${firstKey} and make them available for charting.`,
' */',
`SELECT doc->>'${firstKey}'`,
`FROM "${this.addedTable}"`
].join('\n')
} catch (err) {
console.error(err)
return [
'/*',
` * Your NDJSON file has been imported into ${this.addedTable} table.`,
' */',
'SELECT *',
`FROM "${this.addedTable}"`
].join('\n')
}
},
getJsonQueryExample () {
try {
const firstRowJson = JSON.parse(this.previewData.values.doc[0])
const firstKey = Object.keys(firstRowJson)[0]
return [
'/*',
` * Your JSON file has been imported into ${this.addedTable} table.`,
` * Run this SQL query to get values of property ${firstKey} and make them available for charting.`,
' */',
'SELECT *',
`FROM "${this.addedTable}"`,
`JOIN json_each(doc, '$.${firstKey}')`
].join('\n')
} catch (err) {
console.error(err)
return [
'/*',
` * Your NDJSON file has been imported into ${this.addedTable} table.`,
' */',
'SELECT *',
`FROM "${this.addedTable}"`
].join('\n')
}
}
}
}
@@ -347,10 +457,14 @@ export default {
padding-bottom: 0;
}
#csv-json-table-name {
margin-bottom: 24px;
}
.chars {
display: flex;
align-items: flex-end;
margin: 24px 0 20px;
margin: 0 0 20px;
}
.char-input {
margin-right: 44px;
@@ -359,7 +473,7 @@ export default {
margin-top: 18px;
}
.import-csv-errors {
.import-errors {
height: 136px;
margin-top: 8px;
}

View File

@@ -41,13 +41,13 @@
</div>
<div id="error" class="error"></div>
<!--Parse csv dialog -->
<csv-import
ref="addCsv"
<!--Parse csv or json dialog -->
<csv-json-import
ref="addCsvJson"
:file="file"
:db="newDb"
dialog-name="importFromCsv"
@cancel="cancelCsvImport"
dialog-name="importFromCsvJson"
@cancel="cancelImport"
@finish="finish"
/>
</div>
@@ -57,8 +57,8 @@
import fIo from '@/lib/utils/fileIo'
import ChangeDbIcon from '@/components/svg/changeDb'
import database from '@/lib/database'
import CsvImport from '@/components/CsvImport'
import { send } from '@/lib/utils/events'
import CsvJsonImport from '@/components/CsvJsonImport'
import events from '@/lib/utils/events'
export default {
name: 'DbUploader',
@@ -79,7 +79,7 @@ export default {
},
components: {
ChangeDbIcon,
CsvImport
CsvJsonImport
},
data () {
return {
@@ -102,7 +102,7 @@ export default {
}
},
methods: {
cancelCsvImport () {
cancelImport () {
if (this.newDb) {
this.newDb.shutDown()
this.newDb = null
@@ -128,21 +128,22 @@ export default {
if (fIo.isDatabase(file)) {
this.loadDb(file)
} else {
send('database.import', file.size, {
from: 'csv',
const isJson = fIo.isJSON(file) || fIo.isNDJSON(file)
events.send('database.import', file.size, {
from: isJson ? 'json' : 'csv',
new_db: true
})
this.file = file
await this.$nextTick()
const csvImport = this.$refs.addCsv
csvImport.reset()
return Promise.all([csvImport.previewCsv(), this.animationPromise])
.then(csvImport.open)
const csvJsonImportModal = this.$refs.addCsvJson
csvJsonImportModal.reset()
return Promise.all([csvJsonImportModal.preview(), this.animationPromise])
.then(csvJsonImportModal.open)
}
},
browse () {
fIo.getFileFromUser('.db,.sqlite,.sqlite3,.csv')
fIo.getFileFromUser('.db,.sqlite,.sqlite3,.csv,.json,.ndjson')
.then(this.checkFile)
},

View File

@@ -1,6 +1,7 @@
<template>
<div
:class="['icon-btn', { active }, { disabled }]"
<button
:class="['icon-btn', { active }]"
:disabled="disabled"
@click="onClick"
@mouseenter="showTooltip($event, tooltipPosition)"
@mouseleave="hideTooltip"
@@ -12,7 +13,7 @@
<span v-if="tooltip" class="icon-tooltip" :style="tooltipStyle" ref="tooltip">
{{ tooltip }}
</span>
</div>
</button>
</template>
<script>
@@ -38,11 +39,12 @@ export default {
box-sizing: border-box;
width: 26px;
height: 26px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background-color: transparent;
border: none;
}
.icon-btn:hover {
border: 1px solid var(--color-border);
@@ -56,12 +58,12 @@ export default {
fill: var(--color-accent);
}
.disabled.icon-btn .icon >>> path,
.disabled.icon-btn .icon >>> circle {
.icon-btn:disabled .icon >>> path,
.icon-btn:disabled .icon >>> circle {
fill: var(--color-border);
}
.disabled.icon-btn {
.icon-btn:disabled {
cursor: default;
pointer-events: none;
}

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

@@ -18,7 +18,12 @@
ref="table-container"
@scroll="onScrollTable"
>
<table ref="table" class="sqliteviz-table">
<table
ref="table"
class="sqliteviz-table"
tabindex="0"
@keydown="onTableKeydown"
>
<thead>
<tr>
<th v-for="(th, index) in columns" :key="index" ref="th">
@@ -28,9 +33,18 @@
</thead>
<tbody>
<tr v-for="rowIndex in currentPageData.count" :key="rowIndex">
<td v-for="(col, colIndex) in columns" :key="colIndex">
<td
v-for="(col, colIndex) in columns"
:data-col="colIndex"
:data-row="pageSize * (currentPage - 1) + rowIndex - 1"
:data-isNull="isNull(getCellValue(col, rowIndex))"
:data-isBlob="isBlob(getCellValue(col, rowIndex))"
:key="colIndex"
:aria-selected="false"
@click="onCellClick"
>
<div class="cell-data" :style="cellStyle">
{{ dataSet.values[col][rowIndex - 1 + currentPageData.start] }}
{{ getCellText(col, rowIndex) }}
</div>
</td>
</tr>
@@ -44,7 +58,11 @@
<span v-if="preview">for preview</span>
<span v-if="time">in {{ time }}</span>
</div>
<pager v-show="pageCount > 1" :page-count="pageCount" v-model="currentPage" />
<pager
v-show="pageCount > 1"
:page-count="pageCount"
v-model="currentPage"
/>
</div>
</div>
</template>
@@ -62,14 +80,20 @@ export default {
type: Number,
default: 20
},
preview: Boolean
page: {
type: Number,
default: 1
},
preview: Boolean,
selectedCellCoordinates: Object
},
data () {
return {
header: null,
tableWidth: null,
currentPage: 1,
resizeObserver: null
currentPage: this.page,
resizeObserver: null,
selectedCellElement: null
}
},
computed: {
@@ -99,7 +123,40 @@ export default {
}
}
},
mounted () {
this.resizeObserver = new ResizeObserver(this.calculateHeadersWidth)
this.resizeObserver.observe(this.$refs.table)
this.calculateHeadersWidth()
if (this.selectedCellCoordinates) {
const { row, col } = this.selectedCellCoordinates
const cell = this.$refs.table
.querySelector(`td[data-col="${col}"][data-row="${row}"]`)
if (cell) {
this.selectCell(cell)
}
}
},
methods: {
isBlob (value) {
return value && ArrayBuffer.isView(value)
},
isNull (value) {
return value === null
},
getCellValue (col, rowIndex) {
return this.dataSet.values[col][rowIndex - 1 + this.currentPageData.start]
},
getCellText (col, rowIndex) {
const value = this.getCellValue(col, rowIndex)
if (this.isNull(value)) {
return 'NULL'
}
if (this.isBlob(value)) {
return 'BLOB'
}
return value
},
calculateHeadersWidth () {
this.tableWidth = this.$refs['table-container'].offsetWidth
this.$nextTick(() => {
@@ -110,18 +167,95 @@ export default {
},
onScrollTable () {
this.$refs['header-container'].scrollLeft = this.$refs['table-container'].scrollLeft
},
onTableKeydown (e) {
const keyCodeMap = {
37: 'left',
39: 'right',
38: 'up',
40: 'down'
}
if (
!this.selectedCellElement ||
!Object.keys(keyCodeMap).includes(e.keyCode.toString())
) {
return
}
e.preventDefault()
this.moveFocusInTable(this.selectedCellElement, keyCodeMap[e.keyCode])
},
onCellClick (e) {
this.selectCell(e.target.closest('td'), false)
},
selectCell (cell, scrollTo = true) {
if (!cell) {
if (this.selectedCellElement) {
this.selectedCellElement.ariaSelected = 'false'
}
this.selectedCellElement = cell
} else if (!cell.ariaSelected || cell.ariaSelected === 'false') {
if (this.selectedCellElement) {
this.selectedCellElement.ariaSelected = 'false'
}
cell.ariaSelected = 'true'
this.selectedCellElement = cell
} else {
cell.ariaSelected = 'false'
this.selectedCellElement = null
}
if (this.selectedCellElement && scrollTo) {
this.selectedCellElement.scrollIntoView()
}
this.$emit('updateSelectedCell', this.selectedCellElement)
},
moveFocusInTable (initialCell, direction) {
const currentRowIndex = +initialCell.dataset.row
const currentColIndex = +initialCell.dataset.col
let newRowIndex, newColIndex
if (direction === 'right') {
if (currentColIndex === this.columns.length - 1) {
newRowIndex = currentRowIndex + 1
newColIndex = 0
} else {
newRowIndex = currentRowIndex
newColIndex = currentColIndex + 1
}
} else if (direction === 'left') {
if (currentColIndex === 0) {
newRowIndex = currentRowIndex - 1
newColIndex = this.columns.length - 1
} else {
newRowIndex = currentRowIndex
newColIndex = currentColIndex - 1
}
} else if (direction === 'up') {
newRowIndex = currentRowIndex - 1
newColIndex = currentColIndex
} else if (direction === 'down') {
newRowIndex = currentRowIndex + 1
newColIndex = currentColIndex
}
const newCell = this.$refs.table
.querySelector(`td[data-col="${newColIndex}"][data-row="${newRowIndex}"]`)
if (newCell) {
this.selectCell(newCell)
}
}
},
mounted () {
this.resizeObserver = new ResizeObserver(this.calculateHeadersWidth)
this.resizeObserver.observe(this.$refs.table)
this.calculateHeadersWidth()
},
beforeDestroy () {
this.resizeObserver.unobserve(this.$refs.table)
},
watch: {
currentPageData: 'calculateHeadersWidth',
currentPageData () {
this.calculateHeadersWidth()
this.selectCell(null)
},
dataSet () {
this.currentPage = 1
}
@@ -130,4 +264,13 @@ export default {
</script>
<style scoped>
table.sqliteviz-table:focus {
outline: none;
}
.sqliteviz-table tbody td:hover {
background-color: var(--color-bg-light-3);
}
.sqliteviz-table tbody td[aria-selected="true"] {
box-shadow:inset 0 0 0 1px var(--color-accent);
}
</style>

View File

@@ -0,0 +1,20 @@
<template>
<svg
width="28"
height="27"
viewBox="0 0 28 27"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.9475 8.33625L12.7838 13.5L17.9475 18.6638L16.35 20.25L9.60001
13.5L16.35 6.75L17.9475 8.33625Z"
fill="#506784"
/>
</svg>
</template>
<script>
export default {
}
</script>

View File

@@ -0,0 +1,26 @@
<template>
<svg
width="27"
height="27"
viewBox="0 0 27 27"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.3474 8.33625L12.1837 13.5L17.3474 18.6638L15.7499 20.25L8.99991
13.5L15.7499 6.75L17.3474 8.33625Z"
fill="#506784"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.19995 19.8L7.19995 7.20001H9.19995V19.8H7.19995Z"
fill="#506784"
/>
</svg>
</template>
<script>
export default {
}
</script>

View File

@@ -0,0 +1,53 @@
<template>
<svg
width="19"
height="19"
viewBox="0 0 19 19"
fill="none"
>
<g clip-path="url(#clip0_2130_5292)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1.85303 11.3794L1.85303 7.80371L5.86304 7.80371L5.86304
11.3794L1.85303 11.3794ZM7.36304 11.3794L7.36304 7.80371L11.3428
7.80371L11.3428 11.3794L7.36304 11.3794ZM12.8428 11.3794L16.853
11.3794L16.853 7.80371L12.8428 7.80371L12.8428 11.3794ZM15.353
6.30371L16.853 6.30371C17.6815 6.30371 18.353 6.97528 18.353
7.80371L18.353 11.3794C18.353 12.2078 17.6815 12.8794 16.853
12.8794L15.353 12.8794L15.353 14.3111C15.353 15.0153 14.7603 15.5916
14.0358 15.5916L4.67027 15.5916C3.94579 15.5916 3.35303 15.0153 3.35303
14.3111L3.35303 12.8794L1.85303 12.8794C1.0246 12.8794 0.353027 12.2078
0.353027 11.3794L0.353027 7.80371C0.353027 6.97528 1.0246 6.30371
1.85303 6.30371L3.35303 6.30371L3.35303 4.87201C3.35303 4.16349 3.94139
3.59155 4.67027 3.59155L14.0358 3.59155C14.7604 3.59155 15.353 4.16117
15.353 4.87201L15.353 6.30371ZM14.0315 6.30371L14.0315 4.87086L11.887
4.87086L11.887 6.30371L12.8428 6.30371L14.0315 6.30371ZM10.387
6.30371L10.387 4.87086L8.26685 4.87086L8.26685 6.30371L10.387
6.30371ZM6.76685 6.30371L6.76685 4.87086L4.67027 4.87086L4.67027
6.30371L6.76685 6.30371ZM4.67027 12.8794L4.67027 14.3121L6.76685
14.3121L6.76685 12.8794L4.67027 12.8794ZM8.26685 12.8794L8.26685
14.3121L10.387 14.3121L10.387 12.8794L8.26685 12.8794ZM11.887
12.8794L11.887 14.3121L14.0315 14.3121L14.0315 12.8794L11.887 12.8794Z"
fill="#A2B1C6"
/>
</g>
<defs>
<clipPath id="clip0_2130_5292">
<rect
width="18"
height="18"
fill="white"
transform="translate(0.353027 18.5916) rotate(-90)"
/>
</clipPath>
</defs>
</svg>
</template>
<script>
export default {
name: 'RowIcon'
}
</script>

View File

@@ -0,0 +1,50 @@
<template>
<svg
width="19"
height="19"
viewBox="0 0 19 19"
fill="none"
>
<g clip-path="url(#clip0_2131_6054)">
<path
d="M3.53784 11.5846L3.53784 3.14734L11.9751 3.14734V7.676C12.4655 7.51991
12.9771 7.47439 13.4751 7.53264V3.14734C13.4751 2.31891 12.8035 1.64734
11.9751 1.64734L3.53784 1.64734C2.70941 1.64734 2.03784 2.31891 2.03784
3.14734L2.03784 11.5846C2.03784 12.413 2.70942 13.0846 3.53784
13.0846H10.0831C9.771 12.6184 9.58279 12.1055 9.51083
11.5846H3.53784Z"
fill="#A2B1C6"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.7887 9.9291C15.4307 10.8837 15.1773 12.1779 14.2228
12.8199C13.2682 13.4618 11.974 13.2084 11.332 12.2539C10.69 11.2993
10.9434 10.0051 11.898 9.3631C12.8525 8.72113 14.1468 8.97454 14.7887
9.9291ZM14.4606 14.3901L16.6181 17.5982C16.8492 17.9419 17.3153 18.0331
17.659 17.802C18.0027 17.5708 18.0939 17.1048 17.8628 16.7611L15.6884
13.5279C16.7949 12.3365 16.9801 10.4996 16.0334 9.092C14.9292 7.45002
12.7029 7.01412 11.0609 8.1184C9.41891 9.22268 8.98302 11.449 10.0873
13.0909C11.062 14.5403 12.9109 15.05 14.4606 14.3901Z"
fill="#A2B1C6"
/>
</g>
<defs>
<clipPath id="clip0_2131_6054">
<rect
width="18"
height="18"
fill="white"
transform="translate(0.5 18.5916) rotate(-90)"
/>
</clipPath>
</defs>
</svg>
</template>
<script>
export default {
name: 'ViewCellValueIcon'
}
</script>

View File

@@ -7,9 +7,9 @@ const hintsByCode = {
}
export default {
getResult (source) {
getResult (source, columns) {
const result = {
columns: []
columns: columns || []
}
const values = {}
if (source.meta.fields) {
@@ -24,8 +24,18 @@ export default {
return value
})
})
} else if (columns) {
columns.forEach((col, i) => {
values[col] = source.data.map(row => {
let value = row[i]
if (value instanceof Date) {
value = value.toISOString()
}
return value
})
})
} else {
for (let i = 0; i <= source.data[0].length - 1; i++) {
for (let i = 0; source.data[0] && i <= source.data[0].length - 1; i++) {
const colName = `col${i + 1}`
result.columns.push(colName)
values[colName] = source.data.map(row => {
@@ -73,21 +83,26 @@ export default {
comments: false,
step: undefined,
complete: results => {
const res = {
data: this.getResult(results),
delimiter: results.meta.delimiter,
hasErrors: false,
rowCount: results.data.length
let res
try {
res = {
data: this.getResult(results, config.columns),
delimiter: results.meta.delimiter,
hasErrors: false,
rowCount: results.data.length
}
res.messages = results.errors.map(msg => {
msg.type = msg.code === 'UndetectableDelimiter' ? 'info' : 'error'
if (msg.type === 'error') res.hasErrors = true
msg.hint = hintsByCode[msg.code]
return msg
})
} catch (error) {
reject(error)
}
res.messages = results.errors.map(msg => {
msg.type = msg.code === 'UndetectableDelimiter' ? 'info' : 'error'
if (msg.type === 'error') res.hasErrors = true
msg.hint = hintsByCode[msg.code]
return msg
})
resolve(res)
},
error: (error, file) => {
error: error => {
reject(error)
},
download: false,

View File

@@ -8,11 +8,11 @@ function _getDataSourcesFromSqlResult (sqlResult) {
if (!sqlResult) {
return {}
}
const dataSorces = {}
const dataSources = {}
sqlResult.columns.forEach((column, index) => {
dataSorces[column] = sqlResult.values.map(row => row[index])
dataSources[column] = sqlResult.values.map(row => row[index])
})
return dataSorces
return dataSources
}
export default class Sql {

View File

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

View File

@@ -1,6 +1,6 @@
import { nanoid } from 'nanoid'
import fu from '@/lib/utils/fileIo'
import { send } from '@/lib/utils/events'
import events from '@/lib/utils/events'
import migration from './_migrations'
const migrate = migration._migrate
@@ -33,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
@@ -106,7 +105,7 @@ export default {
.then(str => {
const inquires = this.deserialiseInquiries(str)
send('inquiry.import', inquires.length)
events.send('inquiry.import', inquires.length)
return inquires
})
@@ -115,7 +114,7 @@ export default {
const jsonStr = this.serialiseInquiries(inquiryList)
fu.exportToFile(jsonStr, fileName)
send('inquiry.export', inquiryList.length)
events.send('inquiry.export', inquiryList.length)
},
async readPredefinedInquiries () {

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
}
}

View File

@@ -2,9 +2,11 @@ import Lib from 'plotly.js/src/lib'
import dataUrlToBlob from 'dataurl-to-blob'
export default {
async copyCsv (str) {
async copyText (str, notifyMessage) {
await navigator.clipboard.writeText(str)
Lib.notifier('CSV copied to clipboard successfully', 'long')
if (notifyMessage) {
Lib.notifier(notifyMessage, 'long')
}
},
async copyImage (source) {

View File

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

View File

@@ -1,4 +1,10 @@
export default {
isJSON (file) {
return file && file.type === 'application/json'
},
isNDJSON (file) {
return file && file.name.endsWith('.ndjson')
},
isDatabase (file) {
const dbTypes = ['application/vnd.sqlite3', 'application/x-sqlite3']
return file.type
@@ -51,19 +57,20 @@ export default {
},
importFile () {
const reader = new FileReader()
return this.getFileFromUser('.json')
.then(file => {
return new Promise((resolve, reject) => {
reader.onload = e => {
resolve(e.target.result)
}
reader.readAsText(file)
})
return this.getFileContent(file)
})
},
getFileContent (file) {
const reader = new FileReader()
return new Promise(resolve => {
reader.onload = e => resolve(e.target.result)
reader.readAsText(file)
})
},
readFile (path) {
return fetch(path)
},

View File

@@ -1,4 +1,4 @@
import { send } from '@/lib/utils/events'
import events from '@/lib/utils/events'
let refresh = false
function invokeServiceWorkerUpdateFlow (registration) {
@@ -44,6 +44,6 @@ if ('serviceWorker' in navigator) {
})
window.addEventListener('appinstalled', () => {
send('pwa.install')
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 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) {
state.currentTabId = id
},
setCurrentTab (state, tab) {
state.currentTab = tab
try {
state.currentTabId = id
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

@@ -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,6 +164,7 @@ 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'
@@ -169,7 +177,8 @@ export default {
DeleteIcon,
CloseIcon,
TextField,
CheckBox
CheckBox,
LoadingIndicator
},
mixins: [tooltipMixin],
data () {
@@ -248,15 +257,21 @@ export default {
}
}
},
created () {
storedInquiries.readPredefinedInquiries()
.then(inquiries => {
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)
})
.catch(console.error)
.finally(() => {
this.inquiries = storedInquiries.getStoredInquiries()
})
this.$store.commit('setPredefinedInquiriesLoaded', true)
} catch (e) {
console.error(e)
}
this.$store.commit('setLoadingPredefinedInquiries', false)
}
},
mounted () {
this.resizeObserver = new ResizeObserver(this.calcMaxTableHeight)
@@ -323,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
@@ -352,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
@@ -368,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
@@ -380,9 +400,6 @@ 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) {
storedInquiries.export(inquiryList, fileName)
},
@@ -441,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

@@ -60,7 +60,7 @@ import TextField from '@/components/TextField'
import CloseIcon from '@/components/svg/close'
import storedInquiries from '@/lib/storedInquiries'
import AppDiagnosticInfo from './AppDiagnosticInfo'
import { send } from '@/lib/utils/events'
import events from '@/lib/utils/events'
export default {
name: 'MainMenu',
@@ -80,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)
@@ -115,7 +106,7 @@ export default {
}
})
send('inquiry.create', undefined, { auto: false })
events.send('inquiry.create', null, { auto: false })
},
cancelSave () {
this.$modal.hide('save')
@@ -145,13 +136,15 @@ export default {
// Update tab in store
this.$store.commit('updateTab', {
index: this.currentInquiry.tabIndex,
name: value.name,
id: value.id,
query: value.query,
viewType: value.viewType,
viewOptions: value.viewOptions,
isSaved: true
tab: this.currentInquiry,
newValues: {
name: value.name,
id: value.id,
query: value.query,
viewType: value.viewType,
viewOptions: value.viewOptions,
isSaved: true
}
})
// Restore data:
@@ -169,7 +162,7 @@ export default {
// Signal about saving
this.$root.$emit('inquirySaved')
send('inquiry.save')
events.send('inquiry.save')
},
_keyListener (e) {
if (this.$route.path === '/workspace') {

View File

@@ -10,7 +10,7 @@
</div>
<db-uploader id="db-edit" type="small" />
<export-icon tooltip="Export database" @click="exportToFile"/>
<add-table-icon @click="addCsv"/>
<add-table-icon @click="addCsvJson"/>
</div>
<div v-show="schemaVisible" class="schema">
<table-description
@@ -21,26 +21,26 @@
/>
</div>
<!--Parse csv dialog -->
<csv-import
ref="addCsv"
<!--Parse csv or json dialog -->
<csv-json-import
ref="addCsvJson"
:file="file"
:db="$store.state.db"
dialog-name="addCsv"
dialog-name="addCsvJson"
/>
</div>
</template>
<script>
import fIo from '@/lib/utils/fileIo'
import { send } from '@/lib/utils/events'
import events from '@/lib/utils/events'
import TableDescription from './TableDescription'
import TextField from '@/components/TextField'
import TreeChevron from '@/components/svg/treeChevron'
import DbUploader from '@/components/DbUploader'
import ExportIcon from '@/components/svg/export'
import AddTableIcon from '@/components/svg/addTable'
import CsvImport from '@/components/CsvImport'
import CsvJsonImport from '@/components/CsvJsonImport'
export default {
name: 'Schema',
@@ -51,7 +51,7 @@ export default {
DbUploader,
ExportIcon,
AddTableIcon,
CsvImport
CsvJsonImport
},
data () {
return {
@@ -80,16 +80,17 @@ export default {
exportToFile () {
this.$store.state.db.export(`${this.dbName}.sqlite`)
},
async addCsv () {
this.file = await fIo.getFileFromUser('.csv')
async addCsvJson () {
this.file = await fIo.getFileFromUser('.csv,.json,.ndjson')
await this.$nextTick()
const csvImport = this.$refs.addCsv
csvImport.reset()
await csvImport.previewCsv()
csvImport.open()
const csvJsonImportModal = this.$refs.addCsvJson
csvJsonImportModal.reset()
await csvJsonImportModal.preview()
csvJsonImportModal.open()
send('database.import', this.file.size, {
from: 'csv',
const isJson = fIo.isJSON(this.file) || fIo.isNDJSON(this.file)
events.send('database.import', this.file.size, {
from: isJson ? 'json' : 'csv',
new_db: false
})
}

View File

@@ -31,7 +31,7 @@ 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 { send } from '@/lib/utils/events'
import events from '@/lib/utils/events'
export default {
name: 'Chart',
@@ -66,17 +66,18 @@ export default {
notifyOnLogging: 1
})
this.$watch(
() => JSON.stringify(
this.state.data.map(trace => `${trace.type}-${trace.mode}`)
),
() => this.state && this.state.data && this.state.data
.map(trace => `${trace.type}${trace.mode ? '-' + trace.mode : ''}`)
.join(','),
(value) => {
send('viz_plotly.render', undefined, {
events.send('viz_plotly.render', null, {
type: value,
pivot: !!this.forPivot
})
},
{ deep: true }
)
this.$emit('update:importToSvgEnabled', true)
},
mounted () {
this.resizeObserver = new ResizeObserver(this.handleResize)

View File

@@ -23,7 +23,7 @@ import pivotHelper from './pivotHelper'
import Chart from '@/views/Main/Workspace/Tabs/Tab/DataView/Chart'
import chartHelper from '@/lib/chartHelper'
import Vue from 'vue'
import { send } from '@/lib/utils/events'
import events from '@/lib/utils/events'
const ChartClass = Vue.extend(Chart)
export default {
@@ -96,7 +96,7 @@ export default {
'update:importToSvgEnabled',
this.viewStandartChart || this.viewCustomChart
)
send('viz_pivot.render', undefined, {
events.send('viz_pivot.render', null, {
type: this.pivotOptions.rendererName
})
}

View File

@@ -41,7 +41,6 @@
>
<png-icon />
</icon-button>
<icon-button
:disabled="!importToSvgEnabled"
tooltip="Save as SVG"
@@ -95,7 +94,7 @@ import ClipboardIcon from '@/components/svg/clipboard'
import cIo from '@/lib/utils/clipboardIo'
import loadingDialog from '@/components/LoadingDialog'
import time from '@/lib/utils/time'
import { send } from '@/lib/utils/events'
import events from '@/lib/utils/events'
export default {
name: 'DataView',
@@ -207,11 +206,11 @@ export default {
eventLabels.pivot = this.plotlyInPivot
}
send(
events.send(
this.mode === 'chart' || this.plotlyInPivot
? 'viz_plotly.export'
: 'viz_pivot.export',
undefined,
null,
eventLabels
)
}

View File

@@ -0,0 +1,69 @@
<template>
<div class="record-navigator">
<icon-button
:disabled="value === 0"
tooltip="First row"
tooltip-position="top-left"
class="first"
@click="$emit('input', 0)"
>
<edge-arrow-icon :disabled="false" />
</icon-button>
<icon-button
:disabled="value === 0"
tooltip="Previous row"
tooltip-position="top-left"
class="prev"
@click="$emit('input', value - 1)"
>
<arrow-icon :disabled="false" />
</icon-button>
<icon-button
:disabled="value === total - 1"
tooltip="Next row"
tooltip-position="top-left"
class="next"
@click="$emit('input', value + 1)"
>
<arrow-icon :disabled="false" />
</icon-button>
<icon-button
:disabled="value === total - 1"
tooltip="Last row"
tooltip-position="top-left"
class="last"
@click="$emit('input', total - 1)"
>
<edge-arrow-icon :disabled="false" />
</icon-button>
</div>
</template>
<script>
import IconButton from '@/components/IconButton'
import ArrowIcon from '@/components/svg/arrow'
import EdgeArrowIcon from '@/components/svg/edgeArrow'
export default {
components: {
IconButton,
ArrowIcon,
EdgeArrowIcon
},
props: {
value: Number,
total: Number
}
}
</script>
<style scoped>
.record-navigator {
display: flex;
}
.record-navigator .next,
.record-navigator .last {
transform: rotate(180deg);
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<div class="record-view">
<div class="table-container">
<table
ref="table"
class="sqliteviz-table"
tabindex="0"
@keydown="onTableKeydown"
>
<thead>
<tr>
<th/>
<th>
<div class="cell-data">
Row #{{ currentRowIndex + 1 }}
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(col, index) in columns" :key="index">
<th class="column-cell">{{ col }}</th>
<td
:data-col="index"
:data-row="currentRowIndex"
:data-isNull="isNull(getCellValue(col))"
:data-isBlob="isBlob(getCellValue(col))"
:key="index"
:aria-selected="false"
@click="onCellClick"
>
<div class="cell-data">
{{ getCellText(col) }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="table-footer">
<div class="table-footer-count">
{{ rowCount }} {{rowCount === 1 ? 'row' : 'rows'}} retrieved
<span v-if="time">in {{ time }}</span>
</div>
<row-navigator v-model="currentRowIndex" :total="rowCount"/>
</div>
</div>
</template>
<script>
import RowNavigator from './RowNavigator.vue'
export default {
components: { RowNavigator },
props: {
dataSet: Object,
time: String,
rowIndex: { type: Number, default: 0 },
selectedColumnIndex: Number
},
data () {
return {
selectedCellElement: null,
currentRowIndex: this.rowIndex
}
},
computed: {
columns () {
return this.dataSet.columns
},
rowCount () {
return this.dataSet.values[this.columns[0]].length
}
},
mounted () {
const col = this.selectedColumnIndex
const row = this.currentRowIndex
const cell = this.$refs.table
.querySelector(`td[data-col="${col}"][data-row="${row}"]`)
if (cell) {
this.selectCell(cell)
}
},
watch: {
async currentRowIndex () {
await this.$nextTick()
if (this.selectedCellElement) {
const previouslySelected = this.selectedCellElement
this.selectCell(null)
this.selectCell(previouslySelected)
}
}
},
methods: {
isBlob (value) {
return value && ArrayBuffer.isView(value)
},
isNull (value) {
return value === null
},
getCellValue (col) {
return this.dataSet.values[col][this.currentRowIndex]
},
getCellText (col) {
const value = this.getCellValue(col)
if (this.isNull(value)) {
return 'NULL'
}
if (this.isBlob(value)) {
return 'BLOB'
}
return value
},
onTableKeydown (e) {
const keyCodeMap = {
38: 'up',
40: 'down'
}
if (
!this.selectedCellElement ||
!Object.keys(keyCodeMap).includes(e.keyCode.toString())
) {
return
}
e.preventDefault()
this.moveFocusInTable(this.selectedCellElement, keyCodeMap[e.keyCode])
},
onCellClick (e) {
this.selectCell(e.target.closest('td'), false)
},
selectCell (cell, scrollTo = true) {
if (!cell) {
if (this.selectedCellElement) {
this.selectedCellElement.ariaSelected = 'false'
}
this.selectedCellElement = cell
} else if (!cell.ariaSelected || cell.ariaSelected === 'false') {
if (this.selectedCellElement) {
this.selectedCellElement.ariaSelected = 'false'
}
cell.ariaSelected = 'true'
this.selectedCellElement = cell
} else {
cell.ariaSelected = 'false'
this.selectedCellElement = null
}
if (this.selectedCellElement && scrollTo) {
this.selectedCellElement.scrollIntoView()
this.selectedCellElement.closest('.table-container').scrollTo({ left: 0 })
}
this.$emit('updateSelectedCell', this.selectedCellElement)
},
moveFocusInTable (initialCell, direction) {
const currentColIndex = +initialCell.dataset.col
const newColIndex = direction === 'up'
? currentColIndex - 1
: currentColIndex + 1
const newCell = this.$refs.table
.querySelector(`td[data-col="${newColIndex}"][data-row="${this.currentRowIndex}"]`)
if (newCell) {
this.selectCell(newCell)
}
}
}
}
</script>
<style scoped>
table.sqliteviz-table:focus {
outline: none;
}
.sqliteviz-table tbody td:hover {
background-color: var(--color-bg-light-3);
}
.sqliteviz-table tbody td[aria-selected="true"] {
box-shadow: inset 0 0 0 1px var(--color-accent);
}
table.sqliteviz-table {
margin-top: 0;
}
.sqliteviz-table thead tr th {
border-bottom: 1px solid var(--color-border-light);
}
.sqliteviz-table tbody tr th {
font-size: 14px;
font-weight: 600;
box-sizing: border-box;
background-color: var(--color-bg-dark);
color: var(--color-text-light);
border-bottom: 1px solid var(--color-border-light);
border-right: 1px solid var(--color-border-light);
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.table-footer {
align-items: center;
}
.record-view {
display: flex;
flex-direction: column;
height: 100%;
}
.table-container {
flex-grow: 1;
overflow: auto;
}
.column-cell {
max-width: 150px;
}
</style>

View File

@@ -0,0 +1,207 @@
<template>
<div class="value-viewer">
<div class="value-viewer-toolbar">
<button
v-for="format in formats"
:key="format.value"
type="button"
:aria-selected="currentFormat === format.value"
:class="format.value"
@click="currentFormat = format.value"
>
{{ format.text }}
</button>
<button
type="button"
class="copy"
@click="copyToClipboard"
>
Copy
</button>
</div>
<div class="value-body">
<codemirror
v-if="currentFormat === 'json' && formattedJson"
:value="formattedJson"
:options="cmOptions"
class="json-value"
/>
<pre
v-if="currentFormat === 'text'"
:class="['text-value', { 'meta-value': isNull || isBlob }]"
>{{ cellText }}</pre>
<logs
v-if="messages && messages.length > 0"
:messages="messages"
class="messages"
/>
</div>
</div>
</template>
<script>
import { codemirror } from 'vue-codemirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/mode/javascript/javascript.js'
import 'codemirror/addon/fold/foldcode.js'
import 'codemirror/addon/fold/foldgutter.js'
import 'codemirror/addon/fold/foldgutter.css'
import 'codemirror/addon/fold/brace-fold.js'
import 'codemirror/theme/neo.css'
import cIo from '@/lib/utils/clipboardIo'
import Logs from '@/components/Logs'
export default {
components: {
codemirror,
Logs
},
props: {
cellValue: [String, Number, Uint8Array]
},
data () {
return {
formats: [
{ text: 'Text', value: 'text' },
{ text: 'JSON', value: 'json' }
],
currentFormat: 'text',
cmOptions: {
tabSize: 4,
mode: { name: 'javascript', json: true },
theme: 'neo',
lineNumbers: true,
line: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
readOnly: true
},
formattedJson: '',
messages: []
}
},
computed: {
isBlob () {
return this.cellValue && ArrayBuffer.isView(this.cellValue)
},
isNull () {
return this.cellValue === null
},
cellText () {
const value = this.cellValue
if (this.isNull) {
return 'NULL'
}
if (this.isBlob) {
return 'BLOB'
}
return value
}
},
watch: {
currentFormat () {
this.messages = []
this.formattedJson = ''
if (this.currentFormat === 'json') {
this.formatJson(this.cellValue)
}
},
cellValue () {
this.messages = []
if (this.currentFormat === 'json') {
this.formatJson(this.cellValue)
}
}
},
methods: {
formatJson (jsonStr) {
try {
this.formattedJson = JSON.stringify(
JSON.parse(jsonStr), null, 4
)
} catch (e) {
console.error(e)
this.formattedJson = ''
this.messages = [{
type: 'error',
message: 'Can\'t parse JSON.'
}]
}
},
copyToClipboard () {
cIo.copyText(this.currentFormat === 'json'
? this.formattedJson
: this.cellValue,
'The value is copied to clipboard.'
)
}
}
}
</script>
<style scoped>
.value-viewer {
background-color: var(--color-white);
height: 100%;
display: flex;
flex-direction: column;
}
.value-viewer-toolbar {
display: flex;
justify-content: end;
}
.value-body {
flex-grow: 1;
overflow: auto;
}
.text-value {
padding: 0 8px;
margin: 0;
color: var(--color-text-base);
}
.json-value {
margin-top: -4px;
}
.text-value.meta-value {
font-style: italic;
color: var(--color-text-light-2);
}
.messages {
margin: 0 8px;
}
.value-viewer-toolbar button {
font-size: 10px;
height: 20px;
padding: 0 8px;
border: none;
background: transparent;
color: var(--color-text-base);
border-radius: var(--border-radius-small);
}
.value-viewer-toolbar button:hover {
background-color: var(--color-bg-light);
}
.value-viewer-toolbar button[aria-selected="true"] {
color: var(--color-accent);
}
>>> .vue-codemirror {
height: 100%;
max-height: 100%;
}
>>> .CodeMirror {
height: 100%;
max-height: 100%;
}
>>> .CodeMirror-cursor {
width: 1px;
background: var(--color-text-base);
}
</style>

View File

@@ -1,31 +1,31 @@
<template>
<div class="run-result-panel" ref="runResultPanel">
<div class="run-result-panel-content">
<div
v-show="result === null && !isGettingResults && !error"
class="table-preview result-before"
>
Run your query and get results here
</div>
<div v-if="isGettingResults" class="table-preview result-in-progress">
<loading-indicator :size="30"/>
Fetching results...
</div>
<div
v-show="result === undefined && !isGettingResults && !error"
class="table-preview result-empty"
>
No rows retrieved according to your query
</div>
<logs v-if="error" :messages="[error]"/>
<sql-table
v-if="result"
:data-set="result"
:time="time"
:pageSize="pageSize"
class="straight"
/>
</div>
<component
:is="viewValuePanelVisible ? 'splitpanes':'div'"
:before="{ size: 50, max: 100 }"
:after="{ size: 50, max: 100 }"
:default="{ before: 50, after: 50 }"
class="run-result-panel-content"
>
<template #left-pane>
<div :id="'run-result-left-pane-'+tab.id" class="result-set-container"/>
</template>
<div :id="'run-result-result-set-'+tab.id" class="result-set-container"/>
<template #right-pane v-if="viewValuePanelVisible">
<div class="value-viewer-container">
<value-viewer
v-show="selectedCell"
:cellValue="selectedCell
? result.values[result.columns[selectedCell.dataset.col]][selectedCell.dataset.row]
: ''"
/>
<div v-show="!selectedCell" class="table-preview">
No cell selected to view
</div>
</div>
</template>
</component>
<side-tool-bar @switchTo="$emit('switchTo', $event)" panel="table">
<icon-button
:disabled="!result"
@@ -44,6 +44,26 @@
>
<clipboard-icon/>
</icon-button>
<icon-button
:disabled="!result"
tooltip="View record"
tooltip-position="top-left"
:active="viewRecord"
@click="toggleViewRecord"
>
<row-icon/>
</icon-button>
<icon-button
:disabled="!result"
tooltip="View value"
tooltip-position="top-left"
:active="viewValuePanelVisible"
@click="toggleViewValuePanel"
>
<view-cell-value-icon/>
</icon-button>
</side-tool-bar>
<loading-dialog
@@ -56,6 +76,48 @@
@action="copyToClipboard"
@cancel="cancelCopy"
/>
<teleport :to="resultSetTeleportTarget">
<div>
<div
v-show="result === null && !isGettingResults && !error"
class="table-preview result-before"
>
Run your query and get results here
</div>
<div v-if="isGettingResults" class="table-preview result-in-progress">
<loading-indicator :size="30"/>
Fetching results...
</div>
<div
v-show="result === undefined && !isGettingResults && !error"
class="table-preview result-empty"
>
No rows retrieved according to your query
</div>
<logs v-if="error" :messages="[error]"/>
<sql-table
v-if="result && !viewRecord"
:data-set="result"
:time="time"
:pageSize="pageSize"
:page="defaultPage"
:selected-cell-coordinates="defaultSelectedCell"
class="straight"
@updateSelectedCell="onUpdateSelectedCell"
/>
<record
ref="recordView"
v-if="result && viewRecord"
:data-set="result"
:time="time"
:selected-column-index="selectedCell ? +selectedCell.dataset.col : 0"
:rowIndex="selectedCell ? +selectedCell.dataset.row : 0"
@updateSelectedCell="onUpdateSelectedCell"
/>
</div>
</teleport>
</div>
</template>
@@ -63,26 +125,43 @@
import Logs from '@/components/Logs'
import SqlTable from '@/components/SqlTable'
import LoadingIndicator from '@/components/LoadingIndicator'
import SideToolBar from './SideToolBar'
import SideToolBar from '../SideToolBar'
import Splitpanes from '@/components/Splitpanes'
import ExportToCsvIcon from '@/components/svg/exportToCsv'
import ClipboardIcon from '@/components/svg/clipboard'
import ViewCellValueIcon from '@/components/svg/viewCellValue'
import RowIcon from '@/components/svg/row'
import IconButton from '@/components/IconButton'
import csv from '@/lib/csv'
import fIo from '@/lib/utils/fileIo'
import cIo from '@/lib/utils/clipboardIo'
import time from '@/lib/utils/time'
import loadingDialog from '@/components/LoadingDialog'
import { send } from '@/lib/utils/events'
import events from '@/lib/utils/events'
import Teleport from 'vue2-teleport'
import ValueViewer from './ValueViewer'
import Record from './Record/index.vue'
export default {
name: 'RunResult',
props: ['result', 'isGettingResults', 'error', 'time'],
props: {
tab: Object,
result: Object,
isGettingResults: Boolean,
error: Object,
time: [String, Number]
},
data () {
return {
resizeObserver: null,
pageSize: 20,
preparingCopy: false,
dataToCopy: null
dataToCopy: null,
viewValuePanelVisible: false,
selectedCell: null,
viewRecord: false,
defaultPage: 1,
defaultSelectedCell: null
}
},
components: {
@@ -93,7 +172,23 @@ export default {
ExportToCsvIcon,
IconButton,
ClipboardIcon,
loadingDialog
ViewCellValueIcon,
RowIcon,
loadingDialog,
ValueViewer,
Record,
Splitpanes,
Teleport
},
computed: {
resultSetTeleportTarget () {
const base = `#${this.viewValuePanelVisible
? 'run-result-left-pane'
: 'run-result-result-set'
}`
const tabIdPostfix = `-${this.tab.id}`
return base + tabIdPostfix
}
},
mounted () {
this.resizeObserver = new ResizeObserver(this.handleResize)
@@ -103,6 +198,12 @@ export default {
beforeDestroy () {
this.resizeObserver.unobserve(this.$refs.runResultPanel)
},
watch: {
result () {
this.defaultSelectedCell = null
this.selectedCell = null
}
},
methods: {
handleResize () {
this.calculatePageSize()
@@ -119,7 +220,7 @@ export default {
exportToCsv () {
if (this.result && this.result.values) {
send('resultset.export',
events.send('resultset.export',
this.result.values[this.result.columns[0]].length,
{ to: 'csv' }
)
@@ -130,7 +231,7 @@ export default {
async prepareCopy () {
if (this.result && this.result.values) {
send('resultset.export',
events.send('resultset.export',
this.result.values[this.result.columns[0]].length,
{ to: 'clipboard' }
)
@@ -160,13 +261,35 @@ export default {
},
copyToClipboard () {
cIo.copyCsv(this.dataToCopy)
cIo.copyText(this.dataToCopy, 'CSV copied to clipboard successfully')
this.$modal.hide('prepareCSVCopy')
},
cancelCopy () {
this.dataToCopy = null
this.$modal.hide('prepareCSVCopy')
},
toggleViewValuePanel () {
this.viewValuePanelVisible = !this.viewValuePanelVisible
},
toggleViewRecord () {
if (this.viewRecord) {
this.defaultSelectedCell = {
row: this.$refs.recordView.currentRowIndex,
col: this.selectedCell ? +this.selectedCell.dataset.col : 0
}
this.defaultPage = Math.ceil(
(this.$refs.recordView.currentRowIndex + 1) / this.pageSize
)
}
this.viewRecord = !this.viewRecord
},
onUpdateSelectedCell (e) {
this.selectedCell = e
}
}
}
@@ -180,12 +303,24 @@ export default {
}
.run-result-panel-content {
position: relative;
flex-grow: 1;
height: 100%;
width: 0;
}
.result-set-container,
.result-set-container > div {
position: relative;
height: 100%;
width: 100%;
box-sizing: border-box;
}
.value-viewer-container {
height: 100%;
width: 100%;
background-color: var(--color-white);
position: relative;
}
.table-preview {
position: absolute;
@@ -194,6 +329,7 @@ export default {
transform: translate(-50%, -50%);
color: var(--color-text-base);
font-size: 13px;
text-align: center;
}
.result-in-progress {

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,46 @@
<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"
:tab="tab"
: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,15 +56,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 { send } from '@/lib/utils/events'
import events from '@/lib/utils/events'
export default {
name: 'Tab',
props: [
'id', 'initName', 'initQuery', 'initViewOptions', 'tabIndex', 'isPredefined', 'initViewType'
],
props: {
tab: Object
},
components: {
SqlEditor,
DataView,
@@ -72,21 +74,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: {
@@ -94,54 +89,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
send('inquiry.panel', undefined, { panel: to })
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())
if (this.result && this.result.values) {
send('resultset.create',
this.result.values[this.result.columns[0]].length
)
}
send('query.run', parseFloat(this.time), { status: 'success' })
} catch (err) {
this.error = {
type: 'error',
message: err
}
send('query.run', 0, { status: 'error' })
}
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,7 +20,7 @@
import Splitpanes from '@/components/Splitpanes'
import Schema from './Schema'
import Tabs from './Tabs'
import { send } from '@/lib/utils/events'
import events from '@/lib/utils/events'
export default {
name: 'Workspace',
@@ -28,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',
@@ -51,7 +57,7 @@ export default {
const tabId = await this.$store.dispatch('addTab', { query: stmt })
this.$store.commit('setCurrentTabId', tabId)
send('inquiry.create', undefined, { auto: true })
events.send('inquiry.create', null, { auto: true })
}
}
}

View File

@@ -1,7 +1,7 @@
<template>
<div>
<main-menu />
<keep-alive include="Workspace">
<keep-alive include="Workspace,Inquiries">
<router-view id="main-view" />
</keep-alive>
</div>

View File

@@ -2,10 +2,10 @@ import { expect } from 'chai'
import sinon from 'sinon'
import Vuex from 'vuex'
import { mount } from '@vue/test-utils'
import CsvImport from '@/components/CsvImport'
import CsvJsonImport from '@/components/CsvJsonImport'
import csv from '@/lib/csv'
describe('CsvImport.vue', () => {
describe('CsvJsonImport.vue', () => {
let state = {}
let actions = {}
let mutations = {}
@@ -40,11 +40,11 @@ describe('CsvImport.vue', () => {
}
// mount the component
wrapper = mount(CsvImport, {
wrapper = mount(CsvJsonImport, {
store,
propsData: {
file,
dialogName: 'addCsv',
dialogName: 'addCsvJson',
db
}
})
@@ -74,11 +74,11 @@ describe('CsvImport.vue', () => {
}]
})
wrapper.vm.previewCsv()
wrapper.vm.preview()
await wrapper.vm.open()
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-modal="addCsv"]').exists()).to.equal(true)
expect(wrapper.find('#csv-table-name input').element.value).to.equal('my_data')
expect(wrapper.find('[data-modal="addCsvJson"]').exists()).to.equal(true)
expect(wrapper.find('#csv-json-table-name input').element.value).to.equal('my_data')
expect(wrapper.findComponent({ name: 'delimiter-selector' }).vm.value).to.equal('|')
expect(wrapper.find('#quote-char input').element.value).to.equal('"')
expect(wrapper.find('#escape-char input').element.value).to.equal('"')
@@ -93,8 +93,8 @@ describe('CsvImport.vue', () => {
.to.include('Information about row 0. Comma was used as a standart delimiter.')
expect(wrapper.findComponent({ name: 'logs' }).text())
.to.include('Preview parsing is completed in')
expect(wrapper.find('#csv-finish').isVisible()).to.equal(false)
expect(wrapper.find('#csv-import').isVisible()).to.equal(true)
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
})
it('reparses when parameters changes', async () => {
@@ -111,7 +111,7 @@ describe('CsvImport.vue', () => {
rowCount: 1
})
wrapper.vm.previewCsv()
wrapper.vm.preview()
wrapper.vm.open()
await csv.parse.returnValues[0]
await wrapper.vm.$nextTick()
@@ -234,7 +234,7 @@ describe('CsvImport.vue', () => {
rowCount: 1
})
wrapper.vm.previewCsv()
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
@@ -243,8 +243,8 @@ describe('CsvImport.vue', () => {
resolveParsing = resolve
}))
await wrapper.find('#csv-table-name input').setValue('foo')
await wrapper.find('#csv-import').trigger('click')
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await wrapper.vm.$nextTick()
// "Parsing CSV..." in the logs
@@ -262,11 +262,11 @@ describe('CsvImport.vue', () => {
expect(wrapper.find('#quote-char input').element.disabled).to.equal(true)
expect(wrapper.find('#escape-char input').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').element.disabled).to.equal(true)
expect(wrapper.find('#import-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#import-finish').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').isVisible()).to.equal(false)
expect(wrapper.find('#csv-import').isVisible()).to.equal(true)
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
await resolveParsing()
await parse.returnValues[1]
@@ -306,7 +306,7 @@ describe('CsvImport.vue', () => {
messages: []
})
wrapper.vm.previewCsv()
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
@@ -315,8 +315,8 @@ describe('CsvImport.vue', () => {
resolveImport = resolve
}))
await wrapper.find('#csv-table-name input').setValue('foo')
await wrapper.find('#csv-import').trigger('click')
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await csv.parse.returnValues[1]
await wrapper.vm.$nextTick()
@@ -329,11 +329,11 @@ describe('CsvImport.vue', () => {
expect(wrapper.find('#quote-char input').element.disabled).to.equal(true)
expect(wrapper.find('#escape-char input').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').element.disabled).to.equal(true)
expect(wrapper.find('#import-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#import-finish').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').isVisible()).to.equal(false)
expect(wrapper.find('#csv-import').isVisible()).to.equal(true)
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
await resolveImport()
})
@@ -377,12 +377,12 @@ describe('CsvImport.vue', () => {
resolveImport = resolve
}))
wrapper.vm.previewCsv()
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-table-name input').setValue('foo')
await wrapper.find('#csv-import').trigger('click')
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await csv.parse.returnValues[1]
await wrapper.vm.$nextTick()
@@ -397,11 +397,11 @@ describe('CsvImport.vue', () => {
expect(wrapper.find('#quote-char input').element.disabled).to.equal(true)
expect(wrapper.find('#escape-char input').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').element.disabled).to.equal(true)
expect(wrapper.find('#import-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#import-finish').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').isVisible()).to.equal(false)
expect(wrapper.find('#csv-import').isVisible()).to.equal(true)
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
await resolveImport()
})
@@ -440,12 +440,12 @@ describe('CsvImport.vue', () => {
}]
})
wrapper.vm.previewCsv()
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-table-name input').setValue('foo')
await wrapper.find('#csv-import').trigger('click')
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await csv.parse.returnValues[1]
await wrapper.vm.$nextTick()
@@ -460,11 +460,11 @@ describe('CsvImport.vue', () => {
expect(wrapper.find('#quote-char input').element.disabled).to.equal(false)
expect(wrapper.find('#escape-char input').element.disabled).to.equal(false)
expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(false)
expect(wrapper.find('#csv-cancel').element.disabled).to.equal(false)
expect(wrapper.find('#csv-finish').element.disabled).to.equal(false)
expect(wrapper.find('#import-cancel').element.disabled).to.equal(false)
expect(wrapper.find('#import-finish').element.disabled).to.equal(false)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(false)
expect(wrapper.find('#csv-finish').isVisible()).to.equal(false)
expect(wrapper.find('#csv-import').isVisible()).to.equal(true)
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
})
it('has proper state before import is completed', async () => {
@@ -501,12 +501,12 @@ describe('CsvImport.vue', () => {
wrapper.vm.db.addTableFromCsv = sinon.stub()
.resolves(new Promise(resolve => { resolveImport = resolve }))
wrapper.vm.previewCsv()
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-table-name input').setValue('foo')
await wrapper.find('#csv-import').trigger('click')
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await csv.parse.returnValues[1]
await wrapper.vm.$nextTick()
@@ -525,11 +525,11 @@ describe('CsvImport.vue', () => {
expect(wrapper.find('#quote-char input').element.disabled).to.equal(true)
expect(wrapper.find('#escape-char input').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').element.disabled).to.equal(true)
expect(wrapper.find('#import-cancel').element.disabled).to.equal(true)
expect(wrapper.find('#import-finish').element.disabled).to.equal(true)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(true)
expect(wrapper.find('#csv-finish').isVisible()).to.equal(false)
expect(wrapper.find('#csv-import').isVisible()).to.equal(true)
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
expect(wrapper.vm.db.addTableFromCsv.getCall(0).args[0]).to.equal('foo') // table name
// After resolving - loading indicator is not shown
@@ -570,12 +570,12 @@ describe('CsvImport.vue', () => {
messages: []
})
wrapper.vm.previewCsv()
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-table-name input').setValue('foo')
await wrapper.find('#csv-import').trigger('click')
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await csv.parse.returnValues[1]
await wrapper.vm.$nextTick()
@@ -589,10 +589,10 @@ describe('CsvImport.vue', () => {
expect(wrapper.find('#quote-char input').element.disabled).to.equal(false)
expect(wrapper.find('#escape-char input').element.disabled).to.equal(false)
expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(false)
expect(wrapper.find('#csv-cancel').element.disabled).to.equal(false)
expect(wrapper.find('#csv-finish').element.disabled).to.equal(false)
expect(wrapper.find('#import-cancel').element.disabled).to.equal(false)
expect(wrapper.find('#import-finish').element.disabled).to.equal(false)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(false)
expect(wrapper.find('#csv-finish').isVisible()).to.equal(true)
expect(wrapper.find('#import-finish').isVisible()).to.equal(true)
})
it('import fails', async () => {
@@ -627,12 +627,12 @@ describe('CsvImport.vue', () => {
wrapper.vm.db.addTableFromCsv = sinon.stub().rejects(new Error('fail'))
wrapper.vm.previewCsv()
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-table-name input').setValue('foo')
await wrapper.find('#csv-import').trigger('click')
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await csv.parse.returnValues[1]
await wrapper.vm.$nextTick()
@@ -647,10 +647,10 @@ describe('CsvImport.vue', () => {
expect(wrapper.find('#quote-char input').element.disabled).to.equal(false)
expect(wrapper.find('#escape-char input').element.disabled).to.equal(false)
expect(wrapper.findComponent({ name: 'check-box' }).vm.disabled).to.equal(false)
expect(wrapper.find('#csv-cancel').element.disabled).to.equal(false)
expect(wrapper.find('#csv-finish').element.disabled).to.equal(false)
expect(wrapper.find('#import-cancel').element.disabled).to.equal(false)
expect(wrapper.find('#import-finish').element.disabled).to.equal(false)
expect(wrapper.findComponent({ name: 'close-icon' }).vm.disabled).to.equal(false)
expect(wrapper.find('#csv-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
})
it('import finish', async () => {
@@ -668,19 +668,19 @@ describe('CsvImport.vue', () => {
messages: []
})
wrapper.vm.previewCsv()
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-import').trigger('click')
await wrapper.find('#import-start').trigger('click')
await wrapper.vm.$nextTick()
await wrapper.find('#csv-finish').trigger('click')
await wrapper.find('#import-finish').trigger('click')
expect(actions.addTab.calledOnce).to.equal(true)
await actions.addTab.returnValues[0]
expect(mutations.setCurrentTabId.calledOnceWith(state, newTabId)).to.equal(true)
expect(wrapper.find('[data-modal="addCsv"]').exists()).to.equal(false)
expect(wrapper.find('[data-modal="addCsvJson"]').exists()).to.equal(false)
expect(wrapper.emitted('finish')).to.have.lengthOf(1)
})
@@ -699,16 +699,16 @@ describe('CsvImport.vue', () => {
messages: []
})
await wrapper.vm.previewCsv()
await wrapper.vm.preview()
await wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-import').trigger('click')
await wrapper.find('#import-start').trigger('click')
await wrapper.vm.$nextTick()
await wrapper.find('#csv-cancel').trigger('click')
await wrapper.find('#import-cancel').trigger('click')
expect(wrapper.find('[data-modal="addCsv"]').exists()).to.equal(false)
expect(wrapper.find('[data-modal="addCsvJson"]').exists()).to.equal(false)
expect(wrapper.vm.db.execute.calledOnceWith('DROP TABLE "my_data"')).to.equal(true)
expect(wrapper.vm.db.refreshSchema.calledOnce).to.equal(true)
expect(wrapper.emitted('cancel')).to.have.lengthOf(1)
@@ -716,29 +716,29 @@ describe('CsvImport.vue', () => {
it('checks table name', async () => {
sinon.stub(csv, 'parse').resolves()
await wrapper.vm.previewCsv()
await wrapper.vm.preview()
await wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-table-name input').setValue('foo')
await wrapper.find('#csv-json-table-name input').setValue('foo')
await clock.tick(400)
await wrapper.vm.$nextTick()
expect(wrapper.find('#csv-table-name .text-field-error').text()).to.equal('')
expect(wrapper.find('#csv-json-table-name .text-field-error').text()).to.equal('')
wrapper.vm.db.validateTableName = sinon.stub().rejects(new Error('this is a bad table name'))
await wrapper.find('#csv-table-name input').setValue('bar')
await wrapper.find('#csv-json-table-name input').setValue('bar')
await clock.tick(400)
await wrapper.vm.$nextTick()
expect(wrapper.find('#csv-table-name .text-field-error').text())
expect(wrapper.find('#csv-json-table-name .text-field-error').text())
.to.equal('this is a bad table name. Try another table name.')
await wrapper.find('#csv-table-name input').setValue('')
await wrapper.find('#csv-json-table-name input').setValue('')
await clock.tick(400)
await wrapper.vm.$nextTick()
expect(wrapper.find('#csv-table-name .text-field-error').text()).to.equal('')
expect(wrapper.find('#csv-json-table-name .text-field-error').text()).to.equal('')
await wrapper.find('#csv-import').trigger('click')
expect(wrapper.find('#csv-table-name .text-field-error').text())
await wrapper.find('#import-start').trigger('click')
expect(wrapper.find('#csv-json-table-name .text-field-error').text())
.to.equal("Table name can't be empty")
expect(wrapper.vm.db.addTableFromCsv.called).to.equal(false)
})

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { mount, shallowMount } from '@vue/test-utils'
import DelimiterSelector from '@/components/CsvImport/DelimiterSelector'
import DelimiterSelector from '@/components/CsvJsonImport/DelimiterSelector'
describe('DelimiterSelector', async () => {
it('shows the name of value', async () => {

View File

@@ -153,16 +153,16 @@ describe('DbUploader.vue', () => {
}
})
const CsvImport = wrapper.vm.$refs.addCsv
const CsvImport = wrapper.vm.$refs.addCsvJson
sinon.stub(CsvImport, 'reset')
sinon.stub(CsvImport, 'previewCsv').resolves()
sinon.stub(CsvImport, 'preview').resolves()
sinon.stub(CsvImport, 'open')
await wrapper.find('.drop-area').trigger('click')
await wrapper.vm.$nextTick()
expect(CsvImport.reset.calledOnce).to.equal(true)
await wrapper.vm.animationPromise
expect(CsvImport.previewCsv.calledOnce).to.equal(true)
expect(CsvImport.preview.calledOnce).to.equal(true)
await wrapper.vm.$nextTick()
expect(CsvImport.open.calledOnce).to.equal(true)
wrapper.destroy()
@@ -186,9 +186,9 @@ describe('DbUploader.vue', () => {
}
})
const CsvImport = wrapper.vm.$refs.addCsv
const CsvImport = wrapper.vm.$refs.addCsvJson
sinon.stub(CsvImport, 'reset')
sinon.stub(CsvImport, 'previewCsv').resolves()
sinon.stub(CsvImport, 'preview').resolves()
sinon.stub(CsvImport, 'open')
await wrapper.find('.drop-area').trigger('click')

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

@@ -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

@@ -160,39 +160,39 @@ describe('SQLite extensions', function () {
it('supports transitive_closure', async function () {
const actual = await db.execute(`
CREATE TABLE node(
node_id INTEGER NOT NULL PRIMARY KEY,
parent_id INTEGER,
name VARCHAR(127),
FOREIGN KEY (parent_id) REFERENCES node(node_id)
);
CREATE INDEX node_parent_id_idx ON node(parent_id);
CREATE VIRTUAL TABLE node_closure USING transitive_closure(
tablename = "node",
idcolumn = "node_id",
parentcolumn = "parent_id"
node_id INTEGER NOT NULL PRIMARY KEY,
parent_id INTEGER,
name VARCHAR(127),
FOREIGN KEY (parent_id) REFERENCES node(node_id)
);
CREATE INDEX node_parent_id_idx ON node(parent_id);
INSERT INTO node VALUES
(1, NULL, 'tests'),
(2, 1, 'lib'),
(3, 2, 'database'),
(4, 2, 'utils'),
(5, 2, 'storedQueries.spec.js'),
(6, 3, '_sql.spec.js'),
(7, 3, '_statements.spec.js'),
(8, 3, 'database.spec.js'),
(9, 3, 'sqliteExtensions.spec.js'),
(10, 4, 'fileIo.spec.js'),
(11, 4, 'time.spec.js');
CREATE VIRTUAL TABLE node_closure USING transitive_closure(
tablename = "node",
idcolumn = "node_id",
parentcolumn = "parent_id"
);
SELECT name
FROM node
WHERE node_id IN (
SELECT nc.id
FROM node_closure AS nc
WHERE nc.root = 2 AND nc.depth = 2
);
INSERT INTO node VALUES
(1, NULL, 'tests'),
(2, 1, 'lib'),
(3, 2, 'database'),
(4, 2, 'utils'),
(5, 2, 'storedQueries.spec.js'),
(6, 3, '_sql.spec.js'),
(7, 3, '_statements.spec.js'),
(8, 3, 'database.spec.js'),
(9, 3, 'sqliteExtensions.spec.js'),
(10, 4, 'fileIo.spec.js'),
(11, 4, 'time.spec.js');
SELECT name
FROM node
WHERE node_id IN (
SELECT nc.id
FROM node_closure AS nc
WHERE nc.root = 2 AND nc.depth = 2
);
`)
expect(actual.values).to.eql({
name: [
@@ -293,7 +293,7 @@ describe('SQLite extensions', function () {
it('supports decimal', async function () {
const actual = await db.execute(`
select
SELECT
decimal_add(decimal('0.1'), decimal('0.2')) "add",
decimal_sub(0.2, 0.1) sub,
decimal_mul(power(2, 69), 2) mul,
@@ -430,4 +430,110 @@ describe('SQLite extensions', function () {
]
})
})
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]
})
})
it('supports simple Lua functions', async function () {
const actual = await db.execute(`
INSERT INTO
luafunctions(name, src)
VALUES
('lua_inline', 'return {"arg"}, {"rv"}, "simple", function(arg) return arg + 1 end'),
('lua_full', '
local input = {"arg"}
local output = {"rv"}
local function func(x)
return math.sin(math.pi) + x
end
return input, output, "simple", func
');
SELECT lua_inline(1), lua_full(1) - 1 < 0.000001;
`)
expect(actual.values).to.eql({ 'lua_inline(1)': [2], 'lua_full(1) - 1 < 0.000001': [1] })
})
it('supports aggregate Lua functions', async function () {
const actual = await db.execute(`
INSERT INTO
luafunctions(name, src)
VALUES
('lua_sum', '
local inputs = {"item"}
local outputs = {"sum"}
local function func(item)
if aggregate_now(item) then
return item
end
local sum = 0
while true do
if aggregate_now(item) then
break
end
sum = sum + item
item = coroutine.yield()
end
return sum
end
return inputs, outputs, "aggregate", func
');
SELECT SUM(value), lua_sum(value) FROM generate_series(1, 10);
`)
expect(actual.values).to.eql({ 'SUM(value)': [55], 'lua_sum(value)': [55] })
})
it('supports table-valued Lua functions', async function () {
const actual = await db.execute(`
INSERT INTO
luafunctions(name, src)
VALUES
('lua_match', '
local inputs = {"pattern", "s"}
local outputs = {"idx", "elm"}
local function func(pattern, s)
local i = 1
for k in s:gmatch(pattern) do
coroutine.yield(i, k)
i = i + 1
end
end
return inputs, outputs, "table", func
');
SELECT * FROM lua_match('%w+', 'hello world from Lua');
`)
expect(actual.values).to.eql({ idx: [1, 2, 3, 4], elm: ['hello', 'world', 'from', 'Lua'] })
})
})

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: {
dataView: {
getOptionsForSave () {
return ['chart']
}
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: {
dataView: {
getOptionsForSave () {
return ['chart']
}
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,12 +407,10 @@ describe('storedInquiries.js', () => {
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
initName: 'foo predefined',
$refs: {
dataView: {
getOptionsForSave () {
return ['chart']
}
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

@@ -7,9 +7,9 @@ describe('clipboardIo.js', async () => {
sinon.restore()
})
it('copyCsv', async () => {
it('copyText', async () => {
sinon.stub(navigator.clipboard, 'writeText').resolves(true)
await cIo.copyCsv('id\tname\r\n1\t2')
await cIo.copyText('id\tname\r\n1\t2')
expect(navigator.clipboard.writeText.calledOnceWith('id\tname\r\n1\t2'))
})

View File

@@ -29,12 +29,12 @@ describe('time.js', () => {
})
it('sleep resolves after n ms', async () => {
let before = Date.now()
let before = performance.now()
await time.sleep(10)
expect(Date.now() - before).to.be.least(10)
expect(performance.now() - before).to.be.least(10)
before = Date.now()
before = performance.now()
await time.sleep(30)
expect(Date.now() - before).to.be.least(30)
expect(performance.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 tab = {
id: 1,
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ isSaved: false }],
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 tab = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ isSaved: false }],
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 tab = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ isSaved: false }],
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 tab = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ isSaved: false }],
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 tab = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ isSaved: false }],
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 tab = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ isSaved: false }],
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 tab = {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ isSaved: false }],
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 tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ id: 1, name: 'foo', isSaved: false }],
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,
name: 'foo',
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true
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 tab = {
id: 1,
name: null,
tempName: 'Untitled',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ id: 1, name: null, tempName: 'Untitled', isSaved: false }],
currentTab: tab,
tabs: [tab],
db: {}
}
const mutations = {
@@ -424,13 +442,17 @@ describe('MainMenu.vue', () => {
})
it('Saves the inquiry with a new name', async () => {
const tab = {
id: 1,
name: null,
tempName: 'Untitled',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ id: 1, name: null, tempName: 'Untitled', isSaved: false }],
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,
name: 'foo',
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true
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,23 +513,26 @@ describe('MainMenu.vue', () => {
})
it('Saves a predefined inquiry with a new name', async () => {
const state = {
currentTab: {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0,
isPredefined: true,
result: {
columns: ['id', 'name'],
values: [
[1, 'Harry Potter'],
[2, 'Drako Malfoy']
]
},
viewType: 'chart',
viewOptions: []
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isPredefined: true,
result: {
columns: ['id', 'name'],
values: [
[1, 'Harry Potter'],
[2, 'Drako Malfoy']
]
},
tabs: [{ id: 1, name: 'foo', isSaved: false, isPredefined: true }],
viewType: 'chart',
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,
name: 'bar',
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: [],
isSaved: true
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 tab = {
id: 1,
name: null,
tempName: 'Untitled',
query: 'SELECT * FROM foo',
execute: sinon.stub(),
isSaved: false
}
const state = {
currentTab: {
query: 'SELECT * FROM foo',
execute: sinon.stub(),
tabIndex: 0
},
tabs: [{ id: 1, name: null, tempName: 'Untitled', isSaved: false }],
currentTab: tab,
tabs: [tab],
db: {}
}
const mutations = {

View File

@@ -152,20 +152,20 @@ describe('Schema.vue', () => {
const store = new Vuex.Store({ state, actions, mutations })
const wrapper = mount(Schema, { store, localVue })
sinon.spy(wrapper.vm.$refs.addCsv, 'previewCsv')
sinon.spy(wrapper.vm, 'addCsv')
sinon.spy(wrapper.vm.$refs.addCsv, 'loadFromCsv')
sinon.spy(wrapper.vm.$refs.addCsvJson, 'preview')
sinon.spy(wrapper.vm, 'addCsvJson')
sinon.spy(wrapper.vm.$refs.addCsvJson, 'loadToDb')
await wrapper.findComponent({ name: 'add-table-icon' }).find('svg').trigger('click')
await wrapper.vm.$refs.addCsv.previewCsv.returnValues[0]
await wrapper.vm.addCsv.returnValues[0]
await wrapper.vm.$refs.addCsvJson.preview.returnValues[0]
await wrapper.vm.addCsvJson.returnValues[0]
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-modal="addCsv"]').exists()).to.equal(true)
await wrapper.find('#csv-import').trigger('click')
await wrapper.vm.$refs.addCsv.loadFromCsv.returnValues[0]
await wrapper.find('#csv-finish').trigger('click')
expect(wrapper.find('[data-modal="addCsv"]').exists()).to.equal(false)
expect(wrapper.find('[data-modal="addCsvJson"]').exists()).to.equal(true)
await wrapper.find('#import-start').trigger('click')
await wrapper.vm.$refs.addCsvJson.loadToDb.returnValues[0]
await wrapper.find('#import-finish').trigger('click')
expect(wrapper.find('[data-modal="addCsvJson"]').exists()).to.equal(false)
await state.db.refreshSchema.returnValues[0]
expect(wrapper.vm.$store.state.db.schema).to.eql([

View File

@@ -54,6 +54,10 @@ describe('DataView.vue', () => {
const pivot = wrapper.findComponent({ name: 'pivot' }).vm
sinon.spy(pivot, 'saveAsSvg')
// Switch to Custom Chart renderer
pivot.pivotOptions.rendererName = 'Custom chart'
await pivot.$nextTick()
// Export to svg
await svgBtn.trigger('click')
expect(pivot.saveAsSvg.calledOnce).to.equal(true)

View File

@@ -1,155 +0,0 @@
import { expect } from 'chai'
import { mount, createWrapper } from '@vue/test-utils'
import RunResult from '@/views/Main/Workspace/Tabs/Tab/RunResult'
import csv from '@/lib/csv'
import sinon from 'sinon'
describe('RunResult.vue', () => {
afterEach(() => {
sinon.restore()
})
it('shows alert when ClipboardItem is not supported', async () => {
const ClipboardItem = window.ClipboardItem
delete window.ClipboardItem
sinon.spy(window, 'alert')
const wrapper = mount(RunResult, {
propsData: {
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
expect(
window.alert.calledOnceWith(
"Your browser doesn't support copying into the clipboard. " +
'If you use Firefox you can enable it ' +
'by setting dom.events.asyncClipboard.clipboardItem to true.'
)
).to.equal(true)
window.ClipboardItem = ClipboardItem
})
it('copy to clipboard more than 1 sec', async () => {
sinon.stub(window.navigator.clipboard, 'writeText').resolves()
const clock = sinon.useFakeTimers()
const wrapper = mount(RunResult, {
propsData: {
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
sinon.stub(csv, 'serialize').callsFake(() => {
clock.tick(5000)
})
// Click copy to clipboard
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
// The dialog is shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(true)
// ... with Building message...
expect(wrapper.find('.dialog-body').text()).to.equal('Building CSV...')
// Switch to microtasks (let serialize run)
clock.tick(0)
await wrapper.vm.$nextTick()
// The dialog is shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(true)
// ... with Ready message...
expect(wrapper.find('.dialog-body').text()).to.equal('CSV is ready')
// Click copy
await wrapper.find('.dialog-buttons-container button.primary').trigger('click')
// The dialog is not shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(false)
})
it('copy to clipboard less than 1 sec', async () => {
sinon.stub(window.navigator.clipboard, 'writeText').resolves()
const clock = sinon.useFakeTimers()
const wrapper = mount(RunResult, {
propsData: {
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
sinon.spy(wrapper.vm, 'copyToClipboard')
sinon.stub(csv, 'serialize').callsFake(() => {
clock.tick(500)
})
// Click copy to clipboard
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
// Switch to microtasks (let serialize run)
clock.tick(0)
await wrapper.vm.$nextTick()
// The dialog is not shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(false)
// copyToClipboard is called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
})
it('cancel long copy', async () => {
sinon.stub(window.navigator.clipboard, 'writeText').resolves()
const clock = sinon.useFakeTimers()
const wrapper = mount(RunResult, {
propsData: {
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
sinon.spy(wrapper.vm, 'copyToClipboard')
sinon.stub(csv, 'serialize').callsFake(() => {
clock.tick(5000)
})
// Click copy to clipboard
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
// Switch to microtasks (let serialize run)
clock.tick(0)
await wrapper.vm.$nextTick()
// Click cancel
await wrapper.find('.dialog-buttons-container button.secondary').trigger('click')
// The dialog is not shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(false)
// copyToClipboard is not called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
})
})

View File

@@ -0,0 +1,116 @@
import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import Record from '@/views/Main/Workspace/Tabs/Tab/RunResult/Record'
describe('Record.vue', () => {
it('shows record with selected cell', async () => {
const wrapper = mount(Record, {
propsData: {
dataSet: {
columns: ['id', 'name'],
values: {
id: [1, 2],
name: ['foo', 'bar']
}
},
rowIndex: 1,
selectedColumnIndex: 1
}
})
const rows = wrapper.findAll('tbody tr')
expect(rows).to.have.lengthOf(2)
expect(rows.at(0).findAll('th').at(0).text()).to.equals('id')
expect(rows.at(0).findAll('td').at(0).text()).to.equals('2')
expect(rows.at(1).findAll('th').at(0).text()).to.equals('name')
expect(rows.at(1).findAll('td').at(0).text()).to.equals('bar')
const selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('bar')
})
it('switches to the next or previous row', async () => {
const wrapper = mount(Record, {
propsData: {
dataSet: {
columns: ['id', 'name'],
values: {
id: [1, 2, 3],
name: ['foo', 'bar', 'baz']
}
},
rowIndex: 0,
selectedColumnIndex: 0
}
})
let rows = wrapper.findAll('tbody tr')
expect(rows).to.have.lengthOf(2)
expect(rows.at(0).findAll('td').at(0).text()).to.equals('1')
expect(rows.at(1).findAll('td').at(0).text()).to.equals('foo')
let selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('1')
await wrapper.find('.next').trigger('click')
rows = wrapper.findAll('tbody tr')
expect(rows.at(0).findAll('td').at(0).text()).to.equals('2')
expect(rows.at(1).findAll('td').at(0).text()).to.equals('bar')
selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('2')
await wrapper.find('.prev').trigger('click')
rows = wrapper.findAll('tbody tr')
expect(rows.at(0).findAll('td').at(0).text()).to.equals('1')
expect(rows.at(1).findAll('td').at(0).text()).to.equals('foo')
selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('1')
await wrapper.find('.last').trigger('click')
rows = wrapper.findAll('tbody tr')
expect(rows.at(0).findAll('td').at(0).text()).to.equals('3')
expect(rows.at(1).findAll('td').at(0).text()).to.equals('baz')
selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('3')
await wrapper.find('.first').trigger('click')
rows = wrapper.findAll('tbody tr')
expect(rows.at(0).findAll('td').at(0).text()).to.equals('1')
expect(rows.at(1).findAll('td').at(0).text()).to.equals('foo')
selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('1')
})
it('removes selection when click on selected cell', async () => {
const wrapper = mount(Record, {
propsData: {
dataSet: {
columns: ['id', 'name'],
values: {
id: [1, 2],
name: ['foo', 'bar']
}
},
rowIndex: 1,
selectedColumnIndex: 1
}
})
const selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
await selectedCell.trigger('click')
const selectedCellAfterClick = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCellAfterClick.exists()).to.equals(false)
})
})

View File

@@ -0,0 +1,348 @@
import { expect } from 'chai'
import { mount, createWrapper } from '@vue/test-utils'
import RunResult from '@/views/Main/Workspace/Tabs/Tab/RunResult'
import csv from '@/lib/csv'
import sinon from 'sinon'
describe('RunResult.vue', () => {
afterEach(() => {
sinon.restore()
})
it('shows alert when ClipboardItem is not supported', async () => {
const ClipboardItem = window.ClipboardItem
delete window.ClipboardItem
sinon.spy(window, 'alert')
const wrapper = mount(RunResult, {
propsData: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
expect(
window.alert.calledOnceWith(
"Your browser doesn't support copying into the clipboard. " +
'If you use Firefox you can enable it ' +
'by setting dom.events.asyncClipboard.clipboardItem to true.'
)
).to.equal(true)
window.ClipboardItem = ClipboardItem
})
it('copy to clipboard more than 1 sec', async () => {
sinon.stub(window.navigator.clipboard, 'writeText').resolves()
const clock = sinon.useFakeTimers()
const wrapper = mount(RunResult, {
propsData: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
sinon.stub(csv, 'serialize').callsFake(() => {
clock.tick(5000)
})
// Click copy to clipboard
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
// The dialog is shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(true)
// ... with Building message...
expect(wrapper.find('.dialog-body').text()).to.equal('Building CSV...')
// Switch to microtasks (let serialize run)
clock.tick(0)
await wrapper.vm.$nextTick()
// The dialog is shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(true)
// ... with Ready message...
expect(wrapper.find('.dialog-body').text()).to.equal('CSV is ready')
// Click copy
await wrapper.find('.dialog-buttons-container button.primary').trigger('click')
// The dialog is not shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(false)
})
it('copy to clipboard less than 1 sec', async () => {
sinon.stub(window.navigator.clipboard, 'writeText').resolves()
const clock = sinon.useFakeTimers()
const wrapper = mount(RunResult, {
propsData: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
sinon.spy(wrapper.vm, 'copyToClipboard')
sinon.stub(csv, 'serialize').callsFake(() => {
clock.tick(500)
})
// Click copy to clipboard
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
// Switch to microtasks (let serialize run)
clock.tick(0)
await wrapper.vm.$nextTick()
// The dialog is not shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(false)
// copyToClipboard is called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(true)
})
it('cancel long copy', async () => {
sinon.stub(window.navigator.clipboard, 'writeText').resolves()
const clock = sinon.useFakeTimers()
const wrapper = mount(RunResult, {
propsData: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
id: [1],
name: ['foo']
}
}
}
})
sinon.spy(wrapper.vm, 'copyToClipboard')
sinon.stub(csv, 'serialize').callsFake(() => {
clock.tick(5000)
})
// Click copy to clipboard
const copyBtn = createWrapper(wrapper.findComponent({ name: 'clipboardIcon' }).vm.$parent)
await copyBtn.trigger('click')
// Switch to microtasks (let serialize run)
clock.tick(0)
await wrapper.vm.$nextTick()
// Click cancel
await wrapper.find('.dialog-buttons-container button.secondary').trigger('click')
// The dialog is not shown...
expect(wrapper.find('[data-modal="prepareCSVCopy"]').exists()).to.equal(false)
// copyToClipboard is not called
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
})
it('shows value of selected cell - result set', async () => {
const wrapper = mount(RunResult, {
propsData: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
id: [1, 2],
name: ['foo', 'bar']
}
}
}
})
// Open cell value panel
const viewValueBtn = createWrapper(
wrapper.findComponent({ name: 'viewCellValueIcon' }).vm.$parent
)
await viewValueBtn.trigger('click')
/*
Result set:
|1 | foo
+--+-----
|2 | bar
*/
// Click on '1' cell
const rows = wrapper.findAll('table tbody tr')
await rows.at(0).findAll('td').at(0).trigger('click')
expect(wrapper.find('.value-body').text()).to.equals('1')
// Go to 'foo' with right arrow key
await wrapper.find('table').trigger('keydown.right')
expect(wrapper.find('.value-body').text()).to.equals('foo')
// Go to 'bar' with down arrow key
await wrapper.find('table').trigger('keydown.down')
expect(wrapper.find('.value-body').text()).to.equals('bar')
// Go to '2' with left arrow key
await wrapper.find('table').trigger('keydown.left')
expect(wrapper.find('.value-body').text()).to.equals('2')
// Go to '1' with up arrow key
await wrapper.find('table').trigger('keydown.up')
expect(wrapper.find('.value-body').text()).to.equals('1')
// Click on 'bar' cell
await rows.at(1).findAll('td').at(1).trigger('click')
expect(wrapper.find('.value-body').text()).to.equals('bar')
// Click on 'bar' cell again
await rows.at(1).findAll('td').at(1).trigger('click')
expect(wrapper.find('.value-viewer-container .table-preview').text())
.to.equals('No cell selected to view')
})
it('shows value of selected cell - record view', async () => {
const wrapper = mount(RunResult, {
propsData: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
id: [1, 2],
name: ['foo', 'bar']
}
}
}
})
// Open cell value panel
const viewValueBtn = createWrapper(
wrapper.findComponent({ name: 'viewCellValueIcon' }).vm.$parent
)
await viewValueBtn.trigger('click')
// Go to record view
const vierRecordBtn = createWrapper(
wrapper.findComponent({ name: 'rowIcon' }).vm.$parent
)
await vierRecordBtn.trigger('click')
/*
Record 1:
|id | 1
+-----+-----
|name | foo
Record 2:
|id | 2
+-----+-----
|name | bar
*/
// Click '1' is selected by default
expect(wrapper.find('.value-body').text()).to.equals('1')
// Go to 'foo' with down arrow key
await wrapper.find('table').trigger('keydown.down')
expect(wrapper.find('.value-body').text()).to.equals('foo')
// Go to next record
await wrapper.find('.icon-btn.next').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.find('.value-body').text()).to.equals('bar')
// Go to '2' with up arrow key
await wrapper.find('table').trigger('keydown.up')
expect(wrapper.find('.value-body').text()).to.equals('2')
// Go to prev record
await wrapper.find('.icon-btn.prev').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.find('.value-body').text()).to.equals('1')
// Click on 'foo' cell
const rows = wrapper.findAll('table tbody tr')
await rows.at(1).find('td').trigger('click')
expect(wrapper.find('.value-body').text()).to.equals('foo')
// Click on 'foo' cell again
await rows.at(1).find('td').trigger('click')
expect(wrapper.find('.value-viewer-container .table-preview').text())
.to.equals('No cell selected to view')
})
it('keeps selected cell when switch between record and regular view', async () => {
const wrapper = mount(RunResult, {
propsData: {
tab: { id: 1 },
result: {
columns: ['id', 'name'],
values: {
id: [...Array(30)].map((x, i) => i),
name: [...Array(30)].map((x, i) => `name-${i}`)
}
}
}
})
// Open cell value panel
const viewValueBtn = createWrapper(
wrapper.findComponent({ name: 'viewCellValueIcon' }).vm.$parent
)
await viewValueBtn.trigger('click')
// Click on 'name-1' cell
const rows = wrapper.findAll('table tbody tr')
await rows.at(1).findAll('td').at(1).trigger('click')
expect(wrapper.find('.value-body').text()).to.equals('name-1')
// Go to record view
const vierRecordBtn = createWrapper(
wrapper.findComponent({ name: 'rowIcon' }).vm.$parent
)
await vierRecordBtn.trigger('click')
// 'name-1' is selected
expect(wrapper.find('.value-body').text()).to.equals('name-1')
let selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('name-1')
// Go to last record
await wrapper.find('.icon-btn.last').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.find('.value-body').text()).to.equals('name-29')
// Go to '29' with up arrow key
await wrapper.find('table').trigger('keydown.up')
expect(wrapper.find('.value-body').text()).to.equals('29')
// Go to regular view
await vierRecordBtn.trigger('click')
// '29' is selected
expect(wrapper.find('.value-body').text()).to.equals('29')
selectedCell = wrapper
.find('.sqliteviz-table tbody td[aria-selected="true"]')
expect(selectedCell.text()).to.equals('29')
})
})

View File

@@ -0,0 +1,44 @@
import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import ValueViewer from '@/views/Main/Workspace/Tabs/Tab/RunResult/ValueViewer'
import sinon from 'sinon'
describe('ValueViewer.vue', () => {
afterEach(() => {
sinon.restore()
})
it('shows value in text mode', async () => {
const wrapper = mount(ValueViewer, {
propsData: {
cellValue: 'foo'
}
})
expect(wrapper.find('.value-body').text()).to.equals('foo')
})
it('shows error in json mode if the value is not json', async () => {
const wrapper = mount(ValueViewer, {
propsData: {
cellValue: 'foo'
}
})
await wrapper.find('button.json').trigger('click')
expect(wrapper.find('.value-body').text()).to.equals('Can\'t parse JSON.')
})
it('copy to clipboard', async () => {
sinon.stub(window.navigator.clipboard, 'writeText').resolves()
const wrapper = mount(ValueViewer, {
propsData: {
cellValue: 'foo'
}
})
await wrapper.find('button.copy').trigger('click')
expect(window.navigator.clipboard.writeText.calledOnceWith('foo'))
.to.equal(true)
})
})

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'
}
},
getCursor: sinon.stub()
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'
}
}
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'
}
},
getCursor: sinon.stub()
getRange () {
return 'se'
}
}
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'
}
},
getCursor: sinon.stub()
getRange () {
return 'sele'
}
}
const hints = getHints(editor, {})

View File

@@ -31,13 +31,23 @@ describe('Tab.vue', () => {
store,
stubs: ['chart'],
propsData: {
id: 1,
initName: 'foo',
initQuery: 'SELECT * FROM foo',
initViewType: 'chart',
initViewOptions: [],
tabIndex: 0,
isPredefined: false
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
}
}
})
@@ -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)
})
})