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

34 Commits

Author SHA1 Message Date
lana-k
f55a8caa92 #121 tests 2025-01-12 21:42:17 +01:00
lana-k
87f9f9eb01 use actions, add store tests 2025-01-05 22:30:12 +01:00
lana-k
d6408bdd85 #121 save inquiries in store 2025-01-05 21:06:06 +01:00
lana-k
378b9fb580 #113 upgrade plotly 2024-09-23 16:46:50 +02:00
lana-k
244ba9eb08 #116 add JSON/NDJSON 2024-09-17 11:35:53 +02:00
lana-k
53e5194295 #116 update tests 2024-09-16 23:49:02 +02:00
lana-k
04274ef19a #116 fix lint 2024-09-15 18:08:46 +02:00
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
67 changed files with 6308 additions and 4947 deletions

View File

@@ -14,10 +14,10 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: 10.x
node-version: 16.x
- name: Update npm
run: npm install -g npm@7
run: npm install -g npm@8
- name: npm install and build
run: |

View File

@@ -17,7 +17,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: 10.x
node-version: 16.x
- name: Install browsers
run: |
export DEBIAN_FRONTEND=noninteractive
@@ -25,7 +25,7 @@ jobs:
sudo apt-get install -y chromium-browser firefox
- name: Update npm
run: npm install -g npm@7
run: npm install -g npm@8
- name: Install the project
run: npm install

View File

@@ -3,7 +3,7 @@
# docker build -t sqliteviz/test -f Dockerfile.test .
#
FROM node:12
FROM node:12.22-buster
RUN set -ex; \
apt update; \

View File

@@ -4,11 +4,12 @@
# sqliteviz
Sqliteviz is a single-page offline-first PWA for fully client-side visualisation of SQLite databases or CSV files.
Sqliteviz is a single-page offline-first PWA for fully client-side visualisation
of SQLite databases, CSV, JSON or NDJSON files.
With sqliteviz you can:
- run SQL queries against a SQLite database and create [Plotly][11] charts and pivot tables based on the result sets
- import a CSV file into a SQLite database and visualize imported data
- import a CSV/JSON/NDJSON file into a SQLite database and visualize imported data
- export result set to CSV file
- manage inquiries and run them against different databases
- import/export inquiries from/to a JSON file

View File

@@ -45,6 +45,8 @@ 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.
@@ -103,3 +105,4 @@ described in [this message from SQLite Forum][12]:
[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

@@ -69,6 +69,19 @@
],
"metadata": {}
},
{
"cell_type": "code",
"source": [
"!du -b lib | head -n 2"
],
"outputs": [],
"execution_count": null,
"metadata": {
"collapsed": false,
"outputHidden": false,
"inputHidden": true
}
},
{
"cell_type": "code",
"source": [
@@ -176,7 +189,7 @@
},
"language_info": {
"name": "python",
"version": "3.10.7",
"version": "3.10.14",
"mimetype": "text/x-python",
"codemirror_mode": {
"name": "ipython",

View File

@@ -24,6 +24,7 @@ cflags = (
# Compile-time optimisation
'-Os', # reduces the code size about in half comparing to -O2
'-flto',
'-Isrc', '-Isrc/lua',
)
emflags = (
# Base
@@ -61,6 +62,15 @@ def build(src: Path, dst: Path):
'-c', src / 'extension-functions.c',
'-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([
@@ -68,6 +78,7 @@ def build(src: Path, dst: Path):
*emflags,
out / 'sqlite3.o',
out / 'extension-functions.o',
out / 'sqlitelua.o',
'-o', out / 'sql-wasm.js',
])

View File

@@ -1,7 +1,9 @@
import logging
import re
import shutil
import subprocess
import sys
import tarfile
import zipfile
from io import BytesIO
from pathlib import Path
@@ -30,8 +32,14 @@ extension_urls = (
# =====================
('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'),
)
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'
@@ -59,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')
@@ -70,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)
@@ -90,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.

7069
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "sqliteviz",
"version": "0.23.2",
"version": "0.25.0",
"license": "Apache-2.0",
"private": true,
"scripts": {
@@ -10,7 +10,7 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"codemirror": "^5.57.0",
"codemirror": "^5.65.18",
"core-js": "^3.6.5",
"dataurl-to-blob": "^0.0.1",
"html2canvas": "^1.1.4",
@@ -18,11 +18,11 @@
"nanoid": "^3.1.12",
"papaparse": "^5.4.1",
"pivottable": "^2.23.0",
"plotly.js": "^1.58.4",
"plotly.js": "^2.35.2",
"promise-worker": "^2.0.1",
"react": "^16.13.1",
"react-chart-editor": "^0.45.0",
"react-dom": "^16.13.1",
"react": "^16.14.0",
"react-chart-editor": "^0.46.1",
"react-dom": "^16.14.0",
"sql.js": "file:./lib/sql-js",
"vue": "^2.6.11",
"vue-codemirror": "^4.0.6",

View File

@@ -1,6 +1,6 @@
{
"background_color": "white",
"description": "Sqliteviz is a single-page application for fully client-side visualisation of SQLite databases or CSV.",
"description": "Sqliteviz is a single-page application for fully client-side visualisation of SQLite databases, CSV, JSON or NDJSON.",
"display": "fullscreen",
"icons": [
{

View File

@@ -4,6 +4,29 @@
</div>
</template>
<script>
import storedInquiries from '@/lib/storedInquiries'
export default {
created () {
this.$store.commit('setInquiries', storedInquiries.getStoredInquiries())
},
computed: {
inquiries () {
return this.$store.state.inquiries
}
},
watch: {
inquiries: {
deep: true,
handler () {
storedInquiries.updateStorage(this.inquiries)
}
}
}
}
</script>
<style>
@font-face {
font-family: "Open Sans";

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>
@@ -115,7 +116,7 @@ import fIo from '@/lib/utils/fileIo'
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,108 @@ 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')
events.send('inquiry.create', null, { 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 +460,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 +476,7 @@ export default {
margin-top: 18px;
}
.import-csv-errors {
.import-errors {
height: 136px;
margin-top: 8px;
}

View File

@@ -10,7 +10,8 @@
@click="browse"
>
<div class="text">
Drop the database or CSV file here or click to choose a file from your computer.
Drop the database, CSV, JSON or NDJSON file here
or click to choose a file from your computer.
</div>
</div>
</div>
@@ -41,13 +42,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,7 +58,7 @@
import fIo from '@/lib/utils/fileIo'
import ChangeDbIcon from '@/components/svg/changeDb'
import database from '@/lib/database'
import CsvImport from '@/components/CsvImport'
import CsvJsonImport from '@/components/CsvJsonImport'
import events from '@/lib/utils/events'
export default {
@@ -79,7 +80,7 @@ export default {
},
components: {
ChangeDbIcon,
CsvImport
CsvJsonImport
},
data () {
return {
@@ -102,7 +103,7 @@ export default {
}
},
methods: {
cancelCsvImport () {
cancelImport () {
if (this.newDb) {
this.newDb.shutDown()
this.newDb = null
@@ -128,21 +129,22 @@ export default {
if (fIo.isDatabase(file)) {
this.loadDb(file)
} else {
const isJson = fIo.isJSON(file) || fIo.isNDJSON(file)
events.send('database.import', file.size, {
from: 'csv',
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

@@ -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>
@@ -57,19 +75,25 @@ export default {
components: { Pager },
props: {
dataSet: Object,
time: String,
time: [String, Number],
pageSize: {
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

@@ -34,7 +34,7 @@
</defs>
</svg>
<span class="icon-tooltip" :style="tooltipStyle" ref="tooltip">
Add new table from CSV
Add new table from CSV, JSON or NDJSON
</span>
</span>
</template>

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

@@ -22,7 +22,7 @@
/>
</svg>
<span class="icon-tooltip" :style="tooltipStyle" ref="tooltip">
Load another database or CSV
Load another database, CSV, JSON or NDJSON
</span>
</div>
</template>

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 => {
@@ -76,7 +86,7 @@ export default {
let res
try {
res = {
data: this.getResult(results),
data: this.getResult(results, config.columns),
delimiter: results.meta.delimiter,
hasErrors: false,
rowCount: results.data.length

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

@@ -36,38 +36,6 @@ export default {
return inquiryTab.isPredefined || !inquiryTab.name
},
save (inquiryTab, newName) {
const value = {
id: inquiryTab.isPredefined ? nanoid() : inquiryTab.id,
query: inquiryTab.query,
viewType: inquiryTab.dataView.mode,
viewOptions: inquiryTab.dataView.getOptionsForSave(),
name: newName || inquiryTab.name
}
// Get inquiries from local storage
const myInquiries = this.getStoredInquiries()
// Set createdAt
if (newName) {
value.createdAt = new Date()
} else {
var inquiryIndex = myInquiries.findIndex(oldInquiry => oldInquiry.id === inquiryTab.id)
value.createdAt = myInquiries[inquiryIndex].createdAt
}
// Insert in inquiries list
if (newName) {
myInquiries.push(value)
} else {
myInquiries[inquiryIndex] = value
}
// Save to local storage
this.updateStorage(myInquiries)
return value
},
updateStorage (inquiries) {
localStorage.setItem('myInquiries', JSON.stringify({ version: this.version, inquiries }))
},

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,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,5 @@
import Tab from '@/lib/tab'
import { nanoid } from 'nanoid'
export default {
async addTab ({ state }, inquiry = {}) {
@@ -13,5 +14,69 @@ export default {
}
return inquiry.id
},
async saveInquiry ({ state }, { inquiryTab, newName }) {
const value = {
id: inquiryTab.isPredefined ? nanoid() : inquiryTab.id,
query: inquiryTab.query,
viewType: inquiryTab.dataView.mode,
viewOptions: inquiryTab.dataView.getOptionsForSave(),
name: newName || inquiryTab.name
}
// Get inquiries from local storage
const myInquiries = state.inquiries
// Set createdAt
if (newName) {
value.createdAt = new Date()
} else {
var inquiryIndex = myInquiries.findIndex(oldInquiry => oldInquiry.id === inquiryTab.id)
value.createdAt = myInquiries[inquiryIndex].createdAt
}
// Insert in inquiries list
if (newName) {
myInquiries.push(value)
} else {
myInquiries.splice(inquiryIndex, 1, value)
}
return value
},
addInquiry ({ state }, newInquiry) {
state.inquiries.push(newInquiry)
},
deleteInquiries ({ state, commit }, inquiryIdSet) {
state.inquiries = state.inquiries.filter(
inquiry => !inquiryIdSet.has(inquiry.id)
)
// Close deleted inquiries if it was opened
const tabs = state.tabs
let i = tabs.length - 1
while (i > -1) {
if (inquiryIdSet.has(tabs[i].id)) {
commit('deleteTab', tabs[i])
}
i--
}
},
renameInquiry ({ state, commit }, {inquiryId, newName}) {
const renamingInquiry = state.inquiries
.find(inquiry => inquiry.id === inquiryId)
renamingInquiry.name = newName
// update tab, if renamed inquiry is opened
const tab = state.tabs.find(tab => tab.id === renamingInquiry.id)
if (tab) {
commit('updateTab', {
tab,
newValues: {
name: newName
}
})
}
}
}

View File

@@ -60,5 +60,8 @@ export default {
},
setPredefinedInquiriesLoaded (state, value) {
state.predefinedInquiriesLoaded = value
},
setInquiries (state, value) {
state.inquiries = value
}
}

View File

@@ -3,6 +3,7 @@ export default {
currentTab: null,
currentTabId: null,
untitledLastIndex: 0,
inquiries: [],
predefinedInquiries: [],
loadingPredefinedInquiries: false,
predefinedInquiriesLoaded: false,

View File

@@ -183,7 +183,6 @@ export default {
mixins: [tooltipMixin],
data () {
return {
inquiries: [],
filter: null,
newName: null,
processedInquiryId: null,
@@ -198,6 +197,9 @@ export default {
}
},
computed: {
inquiries () {
return this.$store.state.inquiries
},
predefinedInquiries () {
return this.$store.state.predefinedInquiries.map(inquiry => {
inquiry.isPredefined = true
@@ -258,7 +260,6 @@ export default {
}
},
async created () {
this.inquiries = storedInquiries.getStoredInquiries()
const loadingPredefinedInquiries = this.$store.state.loadingPredefinedInquiries
const predefinedInquiriesLoaded = this.$store.state.predefinedInquiriesLoaded
if (!predefinedInquiriesLoaded && !loadingPredefinedInquiries) {
@@ -330,31 +331,17 @@ export default {
this.errorMsg = "Inquiry name can't be empty"
return
}
const processedInquiry = this.inquiries[this.processedInquiryIndex]
processedInquiry.name = this.newName
this.$set(this.inquiries, this.processedInquiryIndex, processedInquiry)
this.$store.dispatch('renameInquiry', {
inquiryId: this.processedInquiryId,
newName: this.newName
})
// update inquiries in local storage
storedInquiries.updateStorage(this.inquiries)
// update tab, if renamed inquiry is opened
const tab = this.$store.state.tabs
.find(tab => tab.id === processedInquiry.id)
if (tab) {
this.$store.commit('updateTab', {
tab,
newValues: {
name: this.newName
}
})
}
// hide dialog
this.$modal.hide('rename')
},
duplicateInquiry (index) {
const newInquiry = storedInquiries.duplicateInquiry(this.showedInquiries[index])
this.inquiries.push(newInquiry)
storedInquiries.updateStorage(this.inquiries)
this.$store.dispatch('addInquiry', newInquiry)
},
showDeleteDialog (idsSet) {
this.deleteGroup = idsSet.size > 1
@@ -366,39 +353,19 @@ export default {
deleteInquiry () {
this.$modal.hide('delete')
if (!this.deleteGroup) {
this.inquiries.splice(this.processedInquiryIndex, 1)
// Close deleted inquiry tab if it was opened
const tab = this.$store.state.tabs
.find(tab => tab.id === this.processedInquiryId)
if (tab) {
this.$store.commit('deleteTab', tab)
}
this.$store.dispatch('deleteInquiries', new Set().add(this.processedInquiryId))
// Clear checkbox
if (this.selectedInquiriesIds.has(this.processedInquiryId)) {
this.selectedInquiriesIds.delete(this.processedInquiryId)
}
} else {
this.inquiries = this.inquiries.filter(
inquiry => !this.selectedInquiriesIds.has(inquiry.id)
)
// Close deleted inquiries if it was opened
const tabs = this.$store.state.tabs
let i = tabs.length - 1
while (i > -1) {
if (this.selectedInquiriesIds.has(tabs[i].id)) {
this.$store.commit('deleteTab', tabs[i])
}
i--
}
this.$store.dispatch('deleteInquiries', this.selectedInquiriesIds)
// Clear checkboxes
this.selectedInquiriesIds.clear()
}
this.selectedInquiriesCount = this.selectedInquiriesIds.size
storedInquiries.updateStorage(this.inquiries)
},
exportToFile (inquiryList, fileName) {
storedInquiries.export(inquiryList, fileName)
@@ -414,8 +381,7 @@ export default {
importInquiries () {
storedInquiries.importInquiries()
.then(importedInquiries => {
this.inquiries = this.inquiries.concat(importedInquiries)
storedInquiries.updateStorage(this.inquiries)
this.$store.commit('setInquiries', this.inquiries.concat(importedInquiries))
})
},

View File

@@ -122,7 +122,7 @@ export default {
this.saveInquiry()
}
},
saveInquiry () {
async saveInquiry () {
const isNeedName = storedInquiries.isTabNeedName(this.currentInquiry)
if (isNeedName && !this.name) {
this.errorMsg = 'Inquiry name can\'t be empty'
@@ -132,7 +132,10 @@ export default {
const tabView = this.currentInquiry.view
// Save inquiry
const value = storedInquiries.save(this.currentInquiry, this.name)
const value = await this.$store.dispatch('saveInquiry', {
inquiryTab:this.currentInquiry,
newName:this.name
})
// Update tab in store
this.$store.commit('updateTab', {

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,12 +21,12 @@
/>
</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>
@@ -40,7 +40,7 @@ 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()
const isJson = fIo.isJSON(this.file) || fIo.isNDJSON(this.file)
events.send('database.import', this.file.size, {
from: 'csv',
from: isJson ? 'json' : 'csv',
new_db: false
})
}

View File

@@ -25,7 +25,7 @@
<script>
import plotly from 'plotly.js'
import 'react-chart-editor/lib/react-chart-editor.min.css'
import 'react-chart-editor/lib/react-chart-editor.css'
import PlotlyEditor from 'react-chart-editor'
import chartHelper from '@/lib/chartHelper'
@@ -77,6 +77,7 @@ export default {
},
{ deep: true }
)
this.$emit('update:importToSvgEnabled', true)
},
mounted () {
this.resizeObserver = new ResizeObserver(this.handleResize)

View File

@@ -41,7 +41,6 @@
>
<png-icon />
</icon-button>
<icon-button
:disabled="!importToSvgEnabled"
tooltip="Save as SVG"

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,9 +125,12 @@
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'
@@ -73,16 +138,30 @@ import cIo from '@/lib/utils/clipboardIo'
import time from '@/lib/utils/time'
import loadingDialog from '@/components/LoadingDialog'
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()
@@ -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

@@ -29,6 +29,7 @@
<teleport :to="`#${tab.layout.table}-${tab.id}`">
<run-result
:tab="tab"
:result="tab.result"
:is-getting-results="tab.isGettingResults"
:error="tab.error"

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>

51
tests/App.spec.js Normal file
View File

@@ -0,0 +1,51 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { shallowMount } from '@vue/test-utils'
import Vuex from 'vuex'
import App from '@/App'
import storedInquiries from '@/lib/storedInquiries'
import mutations from '@/store/mutations'
describe('App.vue', () => {
afterEach(() => {
sinon.restore()
})
it('Gets inquiries', () => {
sinon.stub(storedInquiries, 'getStoredInquiries').returns([
{id: 1}, {id: 2}, {id: 3}
])
const state = {
predefinedInquiries: [],
inquiries: []
}
const store = new Vuex.Store({ state, mutations })
shallowMount(App, { store, stubs: ['router-view'] })
expect(state.inquiries).to.eql([{id: 1}, {id: 2}, {id: 3}])
})
it('Updates inquiries when they change in store', async () => {
sinon.stub(storedInquiries, 'getStoredInquiries').returns([
{id: 1, name: 'foo'}, {id: 2, name: 'baz'}, {id: 3, name: 'bar'}
])
sinon.spy(storedInquiries, 'updateStorage')
const state = {
predefinedInquiries: [],
inquiries: []
}
const store = new Vuex.Store({ state, mutations })
const wrapper = shallowMount(App, { store, stubs: ['router-view'] })
store.state.inquiries.splice(0, 1, {id: 1, name: 'new foo name'})
await wrapper.vm.$nextTick()
expect(storedInquiries.updateStorage.calledTwice).to.equal(true)
expect(storedInquiries.updateStorage.args[1][0]).to.eql([
{id: 1, name: 'new foo name'}, {id: 2, name: 'baz'}, {id: 3, name: 'bar'}
])
})
})

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 = {}
@@ -13,7 +13,7 @@ describe('CsvImport.vue', () => {
let clock
let wrapper
const newTabId = 1
const file = { name: 'my data.csv' }
const file = new File([], 'my data.csv')
beforeEach(() => {
clock = sinon.useFakeTimers()
@@ -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,12 @@ 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('.dialog-header').text()).to.equal('CSV import')
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 +94,36 @@ 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)
expect(wrapper.find('#import-start').attributes().disabled).to.equal(undefined)
})
it('disables import if no rows found', async () => {
sinon.stub(csv, 'parse').resolves({
delimiter: '|',
data: {
columns: ['col2', 'col1'],
values: {
col1: [],
col2: []
}
},
rowCount: 0,
messages: []
})
await wrapper.vm.preview()
await wrapper.vm.open()
await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr')
expect(rows).to.have.lengthOf(0)
expect(wrapper.findComponent({ name: 'logs' }).text())
.to.include('No rows to import.')
expect(wrapper.find('.no-data').isVisible()).to.equal(true)
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
expect(wrapper.find('#import-start').attributes().disabled).to.equal('disabled')
})
it('reparses when parameters changes', async () => {
@@ -111,7 +140,7 @@ describe('CsvImport.vue', () => {
rowCount: 1
})
wrapper.vm.previewCsv()
wrapper.vm.preview()
wrapper.vm.open()
await csv.parse.returnValues[0]
await wrapper.vm.$nextTick()
@@ -231,20 +260,32 @@ describe('CsvImport.vue', () => {
col2: ['foo']
}
},
rowCount: 1
rowCount: 1,
messages: []
})
wrapper.vm.previewCsv()
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
let resolveParsing
parse.onCall(1).returns(new Promise(resolve => {
resolveParsing = resolve
resolveParsing = () => resolve({
delimiter: '|',
data: {
columns: ['col1', 'col2'],
values: {
col1: [1],
col2: ['foo']
}
},
rowCount: 1,
messages: []
})
}))
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 +303,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 +347,7 @@ describe('CsvImport.vue', () => {
messages: []
})
wrapper.vm.previewCsv()
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
@@ -315,8 +356,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 +370,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 +418,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 +438,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 +481,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 +501,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 +542,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 +566,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 +611,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 +630,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 +668,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 +688,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 +709,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,47 +740,525 @@ 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)
})
it('checks table name', async () => {
sinon.stub(csv, 'parse').resolves()
await wrapper.vm.previewCsv()
sinon.stub(csv, 'parse').resolves({
data: {},
hasErrors: false,
messages: []
})
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)
})
})
describe('CsvJsonImport.vue - json', () => {
let state = {}
let actions = {}
let mutations = {}
let store = {}
let clock
let wrapper
const newTabId = 1
const file = new File(
[new Blob(
[JSON.stringify({ foo: [1, 2, 3] }, null, 2)],
{ type: 'application/json' }
)],
'my data.json',
{ type: 'application/json' })
beforeEach(() => {
clock = sinon.useFakeTimers()
// mock store state and mutations
state = {}
mutations = {
setDb: sinon.stub(),
setCurrentTabId: sinon.stub()
}
actions = {
addTab: sinon.stub().resolves(newTabId)
}
store = new Vuex.Store({ state, mutations, actions })
const db = {
sanitizeTableName: sinon.stub().returns('my_data'),
addTableFromCsv: sinon.stub().resolves(),
createProgressCounter: sinon.stub().returns(1),
deleteProgressCounter: sinon.stub(),
validateTableName: sinon.stub().resolves(),
execute: sinon.stub().resolves(),
refreshSchema: sinon.stub().resolves()
}
// mount the component
wrapper = mount(CsvJsonImport, {
store,
propsData: {
file,
dialogName: 'addCsvJson',
db
}
})
})
afterEach(() => {
sinon.restore()
})
it('previews', async () => {
await wrapper.vm.preview()
await wrapper.vm.open()
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-modal="addCsvJson"]').exists()).to.equal(true)
expect(wrapper.find('.dialog-header').text()).to.equal('JSON import')
expect(wrapper.find('#csv-json-table-name input').element.value).to.equal('my_data')
expect(wrapper.findComponent({ name: 'delimiter-selector' }).exists()).to.equal(false)
expect(wrapper.find('#quote-char input').exists()).to.equal(false)
expect(wrapper.find('#escape-char input').exists()).to.equal(false)
expect(wrapper.findComponent({ name: 'check-box' }).exists()).to.equal(false)
const rows = wrapper.findAll('tbody tr')
expect(rows).to.have.lengthOf(1)
expect(rows.at(0).findAll('td').at(0).text()).to.equal([
'{',
' "foo": [',
' 1,',
' 2,',
' 3',
' ]',
'}'
].join('\n')
)
expect(wrapper.findComponent({ name: 'logs' }).text())
.to.include('Preview parsing is completed in')
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
})
it('has proper state before parsing is complete', async () => {
const getJsonParseResult = sinon.stub(wrapper.vm, 'getJsonParseResult')
getJsonParseResult.onCall(0).returns({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1,
hasErrors: false,
messages: []
})
let resolveParsing
getJsonParseResult.onCall(1).returns(new Promise(resolve => {
resolveParsing = () => resolve({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1,
hasErrors: false,
messages: []
})
}))
await wrapper.vm.preview()
await wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await wrapper.vm.$nextTick()
// "Parsing JSON..." in the logs
expect(wrapper.findComponent({ name: 'logs' }).findAll('.msg').at(1).text())
.to.equal('Parsing JSON...')
// After 1 second - loading indicator is shown
await clock.tick(1000)
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(true)
// All the dialog controls are disabled
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('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
await resolveParsing()
await getJsonParseResult.returnValues[1]
// Loading indicator is not shown when parsing is compete
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(false)
})
it('has proper state before import is completed', async () => {
const getJsonParseResult = sinon.spy(wrapper.vm, 'getJsonParseResult')
let resolveImport = sinon.stub()
wrapper.vm.db.addTableFromCsv = sinon.stub()
.resolves(new Promise(resolve => { resolveImport = resolve }))
await wrapper.vm.preview()
await wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await getJsonParseResult.returnValues[1]
await wrapper.vm.$nextTick()
// Parsing success in the logs
expect(wrapper.findComponent({ name: 'logs' }).findAll('.msg').at(2).text())
.to.equal('Importing JSON into a SQLite database...')
// After 1 second - loading indicator is shown
await clock.tick(1000)
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(true)
// All the dialog controls are disabled
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('#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
await resolveImport()
await wrapper.vm.db.addTableFromCsv.returnValues[0]
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(false)
})
it('import success', async () => {
const getJsonParseResult = sinon.spy(wrapper.vm, 'getJsonParseResult')
await wrapper.vm.preview()
await wrapper.vm.open()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await getJsonParseResult.returnValues[1]
await wrapper.vm.$nextTick()
// Import success in the logs
const logs = wrapper.findComponent({ name: 'logs' }).findAll('.msg')
expect(logs).to.have.lengthOf(3)
expect(logs.at(2).text()).to.contain('Importing JSON into a SQLite database is completed in')
// All the dialog controls are enabled
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('#import-finish').isVisible()).to.equal(true)
})
})
describe('CsvJsonImport.vue - ndjson', () => {
let state = {}
let actions = {}
let mutations = {}
let store = {}
let clock
let wrapper
const newTabId = 1
const file = new File([], 'my data.ndjson')
beforeEach(() => {
clock = sinon.useFakeTimers()
// mock store state and mutations
state = {}
mutations = {
setDb: sinon.stub(),
setCurrentTabId: sinon.stub()
}
actions = {
addTab: sinon.stub().resolves(newTabId)
}
store = new Vuex.Store({ state, mutations, actions })
const db = {
sanitizeTableName: sinon.stub().returns('my_data'),
addTableFromCsv: sinon.stub().resolves(),
createProgressCounter: sinon.stub().returns(1),
deleteProgressCounter: sinon.stub(),
validateTableName: sinon.stub().resolves(),
execute: sinon.stub().resolves(),
refreshSchema: sinon.stub().resolves()
}
// mount the component
wrapper = mount(CsvJsonImport, {
store,
propsData: {
file,
dialogName: 'addCsvJson',
db
}
})
})
afterEach(() => {
sinon.restore()
})
it('previews', async () => {
sinon.stub(csv, 'parse').resolves({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1,
messages: []
})
wrapper.vm.preview()
await wrapper.vm.open()
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-modal="addCsvJson"]').exists()).to.equal(true)
expect(wrapper.find('.dialog-header').text()).to.equal('JSON import')
expect(wrapper.find('#csv-json-table-name input').element.value).to.equal('my_data')
expect(wrapper.findComponent({ name: 'delimiter-selector' }).exists()).to.equal(false)
expect(wrapper.find('#quote-char input').exists()).to.equal(false)
expect(wrapper.find('#escape-char input').exists()).to.equal(false)
expect(wrapper.findComponent({ name: 'check-box' }).exists()).to.equal(false)
const rows = wrapper.findAll('tbody tr')
expect(rows).to.have.lengthOf(1)
expect(rows.at(0).findAll('td').at(0).text()).to.equal('{ "foo": [ 1, 2, 3 ] }')
expect(wrapper.findComponent({ name: 'logs' }).text())
.to.include('Preview parsing is completed in')
expect(wrapper.find('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
})
it('has proper state before parsing is complete', async () => {
const parse = sinon.stub(csv, 'parse')
parse.onCall(0).resolves({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1
})
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
let resolveParsing
parse.onCall(1).returns(new Promise(resolve => {
resolveParsing = () => resolve({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1,
messages: []
})
}))
await wrapper.find('#csv-json-table-name input').setValue('foo')
await wrapper.find('#import-start').trigger('click')
await wrapper.vm.$nextTick()
// "Parsing JSON..." in the logs
expect(wrapper.findComponent({ name: 'logs' }).findAll('.msg').at(1).text())
.to.equal('Parsing JSON...')
// After 1 second - loading indicator is shown
await clock.tick(1000)
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(true)
// All the dialog controls are disabled
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('#import-finish').isVisible()).to.equal(false)
expect(wrapper.find('#import-start').isVisible()).to.equal(true)
await resolveParsing()
await parse.returnValues[1]
// Loading indicator is not shown when parsing is compete
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(false)
})
it('has proper state before import is completed', async () => {
const parse = sinon.stub(csv, 'parse')
parse.onCall(0).resolves({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1,
hasErrors: false,
messages: []
})
parse.onCall(1).resolves({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1,
hasErrors: false,
messages: []
})
let resolveImport = sinon.stub()
wrapper.vm.db.addTableFromCsv = sinon.stub()
.resolves(new Promise(resolve => { resolveImport = resolve }))
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
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()
// Parsing success in the logs
expect(wrapper.findComponent({ name: 'logs' }).findAll('.msg').at(2).text())
.to.equal('Importing JSON into a SQLite database...')
// After 1 second - loading indicator is shown
await clock.tick(1000)
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(true)
// All the dialog controls are disabled
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('#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
await resolveImport()
await wrapper.vm.db.addTableFromCsv.returnValues[0]
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(false)
})
it('import success', async () => {
const parse = sinon.stub(csv, 'parse')
parse.onCall(0).resolves({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 1,
hasErrors: false,
messages: []
})
// we need to separate calles because messages will mutate
parse.onCall(1).resolves({
delimiter: '|',
data: {
columns: ['doc'],
values: {
doc: ['{ "foo": [ 1, 2, 3 ] }']
}
},
rowCount: 2,
hasErrors: false,
messages: []
})
wrapper.vm.preview()
wrapper.vm.open()
await wrapper.vm.$nextTick()
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()
// Import success in the logs
const logs = wrapper.findComponent({ name: 'logs' }).findAll('.msg')
expect(logs).to.have.lengthOf(3)
expect(logs.at(2).text()).to.contain('Importing JSON into a SQLite database is completed in')
// All the dialog controls are enabled
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('#import-finish').isVisible()).to.equal(true)
})
})

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

@@ -31,7 +31,7 @@ describe('DbUploader.vue', () => {
it('loads db on click and redirects to /workspace', async () => {
// mock getting a file from user
const file = { name: 'test.db' }
const file = new File([], 'test.db')
sinon.stub(fu, 'getFileFromUser').resolves(file)
// mock db loading
@@ -85,7 +85,7 @@ describe('DbUploader.vue', () => {
})
// mock a file dropped by a user
const file = { name: 'test.db' }
const file = new File([], 'test.db')
const dropData = { dataTransfer: new DataTransfer() }
Object.defineProperty(dropData.dataTransfer, 'files', {
value: [file],
@@ -103,7 +103,7 @@ describe('DbUploader.vue', () => {
it("doesn't redirect if already on /workspace", async () => {
// mock getting a file from user
const file = { name: 'test.db' }
const file = new File([], 'test.db')
sinon.stub(fu, 'getFileFromUser').resolves(file)
// mock db loading
@@ -136,7 +136,7 @@ describe('DbUploader.vue', () => {
it('shows parse dialog if gets csv file', async () => {
// mock getting a file from user
const file = { name: 'test.csv' }
const file = new File([], 'test.csv')
sinon.stub(fu, 'getFileFromUser').resolves(file)
// mock router
@@ -153,24 +153,92 @@ 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()
})
it('deletes temporary db if CSV import is canceled', async () => {
it('shows parse dialog if gets json file', async () => {
// mock getting a file from user
const file = { name: 'test.csv' }
const file = new File([], 'test.json', { type: 'application/json' })
sinon.stub(fu, 'getFileFromUser').resolves(file)
// mock router
const $router = { push: sinon.stub() }
const $route = { path: '/workspace' }
// mount the component
const wrapper = mount(DbUploader, {
attachTo: place,
store,
mocks: { $router, $route },
propsData: {
type: 'illustrated'
}
})
const JsonImport = wrapper.vm.$refs.addCsvJson
sinon.stub(JsonImport, 'reset')
sinon.stub(JsonImport, 'preview').resolves()
sinon.stub(JsonImport, 'open')
await wrapper.find('.drop-area').trigger('click')
await wrapper.vm.$nextTick()
expect(JsonImport.reset.calledOnce).to.equal(true)
await wrapper.vm.animationPromise
expect(JsonImport.preview.calledOnce).to.equal(true)
await wrapper.vm.$nextTick()
expect(JsonImport.open.calledOnce).to.equal(true)
wrapper.destroy()
})
it('shows parse dialog if gets ndjson file', async () => {
// mock getting a file from user
const file = new File([], 'test.ndjson')
sinon.stub(fu, 'getFileFromUser').resolves(file)
// mock router
const $router = { push: sinon.stub() }
const $route = { path: '/workspace' }
// mount the component
const wrapper = mount(DbUploader, {
attachTo: place,
store,
mocks: { $router, $route },
propsData: {
type: 'illustrated'
}
})
const JsonImport = wrapper.vm.$refs.addCsvJson
sinon.stub(JsonImport, 'reset')
sinon.stub(JsonImport, 'preview').resolves()
sinon.stub(JsonImport, 'open')
await wrapper.find('.drop-area').trigger('click')
await wrapper.vm.$nextTick()
expect(JsonImport.reset.calledOnce).to.equal(true)
await wrapper.vm.animationPromise
expect(JsonImport.preview.calledOnce).to.equal(true)
await wrapper.vm.$nextTick()
expect(JsonImport.open.calledOnce).to.equal(true)
wrapper.destroy()
})
it('deletes temporary db if import is canceled', async () => {
// mock getting a file from user
const file = new File([], 'test.csv')
sinon.stub(fu, 'getFileFromUser').resolves(file)
// mock router
@@ -186,9 +254,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

@@ -28,7 +28,26 @@ describe('csv.js', () => {
})
})
it('getResult without fields', () => {
it('getResult without fields but with columns', () => {
const source = {
data: [
[1, 'foo', new Date('2021-06-30T14:10:24.717Z')],
[2, 'bar', new Date('2021-07-30T14:10:15.717Z')]
],
meta: {}
}
const columns = ['id', 'name', 'date']
expect(csv.getResult(source, columns)).to.eql({
columns: ['id', 'name', 'date'],
values: {
id: [1, 2],
name: ['foo', 'bar'],
date: ['2021-06-30T14:10:24.717Z', '2021-07-30T14:10:15.717Z']
}
})
})
it('getResult without fields and columns', () => {
const source = {
data: [
[1, 'foo', new Date('2021-06-30T14:10:24.717Z')],

View File

@@ -455,4 +455,85 @@ describe('SQLite extensions', function () {
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

@@ -342,87 +342,4 @@ describe('storedInquiries.js', () => {
createdAt: '2020-11-03T14:17:49.524Z'
}])
})
it('save adds new inquiry in the storage', () => {
const now = new Date()
const nowPlusMinute = new Date(now.getTime() + 60 * 1000)
const tab = {
id: 1,
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
name: null,
dataView: {
getOptionsForSave () {
return ['chart']
}
}
}
const value = storedInquiries.save(tab, 'foo')
expect(value.id).to.equal(tab.id)
expect(value.name).to.equal('foo')
expect(value.query).to.equal(tab.query)
expect(value.viewOptions).to.eql(['chart'])
expect(value).to.have.property('createdAt').which.within(now, nowPlusMinute)
const inquiries = storedInquiries.getStoredInquiries()
expect(JSON.stringify(inquiries)).to.equal(JSON.stringify([value]))
})
it('save updates existing inquiry in the storage', () => {
const tab = {
id: 1,
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
name: null,
dataView: {
getOptionsForSave () {
return ['chart']
}
}
}
const first = storedInquiries.save(tab, 'foo')
tab.name = 'foo'
tab.query = 'select * from foo'
storedInquiries.save(tab)
const inquiries = storedInquiries.getStoredInquiries()
const second = inquiries[0]
expect(inquiries).has.lengthOf(1)
expect(second.id).to.equal(first.id)
expect(second.name).to.equal(first.name)
expect(second.query).to.equal(tab.query)
expect(second.viewOptions).to.eql(['chart'])
expect(new Date(second.createdAt).getTime()).to.equal(first.createdAt.getTime())
})
it("save adds a new inquiry with new id if it's based on predefined inquiry", () => {
const now = new Date()
const nowPlusMinute = new Date(now.getTime() + 60 * 1000)
const tab = {
id: 1,
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
name: 'foo predefined',
dataView: {
getOptionsForSave () {
return ['chart']
}
},
isPredefined: true
}
storedInquiries.save(tab, 'foo')
const inquiries = storedInquiries.getStoredInquiries()
expect(inquiries).has.lengthOf(1)
expect(inquiries[0]).to.have.property('id').which.not.equal(tab.id)
expect(inquiries[0].name).to.equal('foo')
expect(inquiries[0].query).to.equal(tab.query)
expect(inquiries[0].viewOptions).to.eql(['chart'])
expect(new Date(inquiries[0].createdAt)).to.be.within(now, nowPlusMinute)
})
})

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

@@ -106,10 +106,65 @@ describe('fileIo.js', () => {
await expect(fIo.readAsArrayBuffer(blob)).to.be.rejectedWith('Problem parsing input file.')
})
it('isJSON', () => {
let file = { type: 'application/json' }
expect(fIo.isJSON(file)).to.equal(true)
file = { type: 'application/x-sqlite3' }
expect(fIo.isJSON(file)).to.equal(false)
file = { type: '', name: 'test.db' }
expect(fIo.isJSON(file)).to.equal(false)
file = { type: '', name: 'test.sqlite' }
expect(fIo.isJSON(file)).to.equal(false)
file = { type: '', name: 'test.sqlite3' }
expect(fIo.isJSON(file)).to.equal(false)
file = { type: '', name: 'test.csv' }
expect(fIo.isJSON(file)).to.equal(false)
file = { type: '', name: 'test.ndjson' }
expect(fIo.isJSON(file)).to.equal(false)
file = { type: 'text', name: 'test.db' }
expect(fIo.isJSON(file)).to.equal(false)
})
it('isNDJSON', () => {
let file = { type: 'application/json', name: 'test.json' }
expect(fIo.isNDJSON(file)).to.equal(false)
file = { type: 'application/x-sqlite3', name: 'test.sqlite3' }
expect(fIo.isNDJSON(file)).to.equal(false)
file = { type: '', name: 'test.db' }
expect(fIo.isNDJSON(file)).to.equal(false)
file = { type: '', name: 'test.sqlite' }
expect(fIo.isNDJSON(file)).to.equal(false)
file = { type: '', name: 'test.sqlite3' }
expect(fIo.isNDJSON(file)).to.equal(false)
file = { type: '', name: 'test.csv' }
expect(fIo.isNDJSON(file)).to.equal(false)
file = { type: '', name: 'test.ndjson' }
expect(fIo.isNDJSON(file)).to.equal(true)
file = { type: 'text', name: 'test.db' }
expect(fIo.isNDJSON(file)).to.equal(false)
})
it('isDatabase', () => {
let file = { type: 'application/vnd.sqlite3' }
expect(fIo.isDatabase(file)).to.equal(true)
file = { type: 'application/json' }
expect(fIo.isDatabase(file)).to.equal(false)
file = { type: 'application/x-sqlite3' }
expect(fIo.isDatabase(file)).to.equal(true)
@@ -125,6 +180,9 @@ describe('fileIo.js', () => {
file = { type: '', name: 'test.csv' }
expect(fIo.isDatabase(file)).to.equal(false)
file = { type: '', name: 'test.ndjson' }
expect(fIo.isDatabase(file)).to.equal(false)
file = { type: 'text', name: 'test.db' }
expect(fIo.isDatabase(file)).to.equal(false)
})

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

@@ -1,7 +1,13 @@
import { expect } from 'chai'
import actions from '@/store/actions'
const { addTab } = actions
const {
addTab,
addInquiry,
deleteInquiries,
renameInquiry,
saveInquiry
} = actions
describe('actions', () => {
it('addTab adds new blank tab', async () => {
@@ -81,4 +87,156 @@ describe('actions', () => {
expect(state.tabs).to.have.lengthOf(2)
expect(state.untitledLastIndex).to.equal(0)
})
it('addInquiry', async () => {
const state = {
inquiries: [1,2,3]
}
await addInquiry({ state }, 4)
expect(state.inquiries).to.eql([1,2,3,4])
})
it('deleteInquiries', async () => {
const state = {
inquiries: [{ id: 1 }, { id: 2 }, { id: 3 }],
tabs: [{ id: 3 }, { id: 2 }]
}
const commit = sinon.spy()
await deleteInquiries({ state, commit }, new Set().add(2))
expect(state.inquiries).to.eql([{ id: 1 }, { id: 3 }])
expect(commit.calledWith('deleteTab', { id: 2 })).to.equal(true)
})
it('renameInquiry', async () => {
const state = {
inquiries: [
{ id: 1, name: 'foo'},
{ id: 2, name: 'bar' },
{ id: 3, name: 'baz' },
],
tabs: [{ id: 1, name: 'foo'}, { id: 2, name: 'bar' }]
}
const commit = sinon.spy()
await renameInquiry({ state, commit }, {inquiryId: 2, newName: 'new name'})
expect(state.inquiries).to.eql([
{ id: 1, name: 'foo'},
{ id: 2, name: 'new name' },
{ id: 3, name: 'baz' },
])
expect(commit.calledWith('updateTab', {
tab: { id: 2, name: 'bar' },
newValues: {
name: 'new name'
}
})).to.equal(true)
})
it('saveInquiry adds new inquiry in the storage', async () => {
const now = new Date()
const nowPlusMinute = new Date(now.getTime() + 60 * 1000)
const tab = {
id: 1,
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
name: null,
dataView: {
getOptionsForSave () {
return ['chart']
}
}
}
const state = {
inquiries: [],
tabs: [tab],
}
const value = await saveInquiry({ state }, {
inquiryTab: tab,
newName: 'foo'
})
expect(value.id).to.equal(tab.id)
expect(value.name).to.equal('foo')
expect(value.query).to.equal(tab.query)
expect(value.viewOptions).to.eql(['chart'])
expect(value).to.have.property('createdAt').which.within(now, nowPlusMinute)
expect(state.inquiries).to.eql([value])
})
it('save updates existing inquiry in the storage', async () => {
const tab = {
id: 1,
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
name: null,
dataView: {
getOptionsForSave () {
return ['chart']
}
}
}
const state = {
inquiries: [],
tabs: [tab],
}
const first = await saveInquiry({ state }, {
inquiryTab: tab,
newName: 'foo'
})
tab.name = 'foo'
tab.query = 'select * from foo'
await saveInquiry({ state }, { inquiryTab: tab })
const inquiries = state.inquiries
const second = inquiries[0]
expect(inquiries).has.lengthOf(1)
expect(second.id).to.equal(first.id)
expect(second.name).to.equal(first.name)
expect(second.query).to.equal(tab.query)
expect(second.viewOptions).to.eql(['chart'])
expect(new Date(second.createdAt).getTime()).to.equal(first.createdAt.getTime())
})
it("save adds a new inquiry with new id if it's based on predefined inquiry", async () => {
const now = new Date()
const nowPlusMinute = new Date(now.getTime() + 60 * 1000)
const tab = {
id: 1,
query: 'select * from foo',
viewType: 'chart',
viewOptions: [],
name: 'foo predefined',
dataView: {
getOptionsForSave () {
return ['chart']
}
},
isPredefined: true
}
const state = {
inquiries: [],
tabs: [tab],
}
await saveInquiry({ state }, {
inquiryTab: tab,
newName: 'foo'
})
const inquiries = state.inquiries
expect(inquiries).has.lengthOf(1)
expect(inquiries[0]).to.have.property('id').which.not.equal(tab.id)
expect(inquiries[0].name).to.equal('foo')
expect(inquiries[0].query).to.equal(tab.query)
expect(inquiries[0].viewOptions).to.eql(['chart'])
expect(new Date(inquiries[0].createdAt)).to.be.within(now, nowPlusMinute)
})
})

View File

@@ -8,7 +8,8 @@ const {
updatePredefinedInquiries,
setDb,
setLoadingPredefinedInquiries,
setPredefinedInquiriesLoaded
setPredefinedInquiriesLoaded,
setInquiries
} = mutations
describe('mutations', () => {
@@ -360,4 +361,13 @@ describe('mutations', () => {
setPredefinedInquiriesLoaded(state, true)
expect(state.predefinedInquiriesLoaded).to.equal(true)
})
it('setInquiries', () => {
const state = {
inquiries: []
}
setInquiries(state, [1,2,3])
expect(state.inquiries).to.eql([1,2,3])
})
})

View File

@@ -5,6 +5,7 @@ import Vuex from 'vuex'
import Inquiries from '@/views/Main/Inquiries'
import storedInquiries from '@/lib/storedInquiries'
import mutations from '@/store/mutations'
import actions from '@/store/actions'
import fu from '@/lib/utils/fileIo'
describe('Inquiries.vue', () => {
@@ -14,16 +15,16 @@ describe('Inquiries.vue', () => {
it('Shows start-guide message if there are no saved and predefined inquiries', () => {
sinon.stub(storedInquiries, 'readPredefinedInquiries').resolves([])
sinon.stub(storedInquiries, 'getStoredInquiries').returns([])
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: []
}
const mutations = {
setPredefinedInquiriesLoaded: sinon.stub(),
updatePredefinedInquiries: sinon.stub(),
setLoadingPredefinedInquiries: sinon.stub()
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = shallowMount(Inquiries, { store })
expect(wrapper.find('#start-guide').exists()).to.equal(true)
@@ -40,32 +41,32 @@ describe('Inquiries.vue', () => {
createdAt: '2020-03-08T19:57:56.299Z'
}
])
sinon.stub(storedInquiries, 'getStoredInquiries').returns([
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
},
{
id: 2,
name: 'bar',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-12-04T18:53:56.299Z'
}
])
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
},
{
id: 2,
name: 'bar',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-12-04T18:53:56.299Z'
}
]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = shallowMount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
expect(wrapper.find('#start-guide').exists()).to.equal(false)
@@ -94,29 +95,30 @@ describe('Inquiries.vue', () => {
createdAt: '2020-03-08T19:57:56.299Z'
}
])
sinon.stub(storedInquiries, 'getStoredInquiries').returns([
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
},
{
id: 2,
name: 'bar',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-12-04T18:53:56.299Z'
}
])
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
},
{
id: 2,
name: 'bar',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-12-04T18:53:56.299Z'
}
]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await wrapper.find('#toolbar-search input').setValue('OO')
await wrapper.vm.$nextTick()
@@ -138,29 +140,30 @@ describe('Inquiries.vue', () => {
createdAt: '2020-03-08T19:57:56.299Z'
}
])
sinon.stub(storedInquiries, 'getStoredInquiries').returns([
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
},
{
id: 2,
name: 'bar',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-12-04T18:53:56.299Z'
}
])
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
},
{
id: 2,
name: 'bar',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-12-04T18:53:56.299Z'
}
]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await wrapper.find('#toolbar-search input').setValue('baz')
await wrapper.vm.$nextTick()
@@ -181,24 +184,24 @@ describe('Inquiries.vue', () => {
createdAt: '2020-03-08T19:57:56.299Z'
}
])
sinon.stub(storedInquiries, 'getStoredInquiries').returns([
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
])
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = shallowMount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr')
@@ -208,26 +211,25 @@ describe('Inquiries.vue', () => {
it('Exports one inquiry', async () => {
sinon.stub(storedInquiries, 'readPredefinedInquiries').resolves([])
sinon.stub(storedInquiries, 'getStoredInquiries').returns([
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
])
sinon.stub(storedInquiries, 'serialiseInquiries').returns('I am a serialized inquiry')
sinon.stub(fu, 'exportToFile')
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
await wrapper.findComponent({ name: 'ExportIcon' }).find('svg').trigger('click')
expect(fu.exportToFile.calledOnceWith('I am a serialized inquiry', 'foo.json')).to.equals(true)
@@ -243,7 +245,6 @@ describe('Inquiries.vue', () => {
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
sinon.stub(storedInquiries, 'getStoredInquiries').returns([inquiryInStorage])
sinon.stub(storedInquiries, 'updateStorage')
const newInquiry = {
id: 2,
@@ -255,13 +256,13 @@ describe('Inquiries.vue', () => {
}
sinon.stub(storedInquiries, 'duplicateInquiry').returns(newInquiry)
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [inquiryInStorage]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
await wrapper.findComponent({ name: 'CopyIcon' }).find('svg').trigger('click')
@@ -271,9 +272,7 @@ describe('Inquiries.vue', () => {
expect(rows).to.have.lengthOf(2)
expect(rows.at(1).findAll('td').at(0).text()).to.equals('foo copy')
expect(rows.at(1).findAll('td').at(1).text()).to.contains('3 December 2020 20:57')
expect(
storedInquiries.updateStorage.calledOnceWith(sinon.match([inquiryInStorage, newInquiry]))
).to.equals(true)
expect(state.inquiries).to.eql([inquiryInStorage, newInquiry])
})
it('The copy of the inquiry is not selected if all inquiries were selected before duplication',
@@ -287,8 +286,6 @@ describe('Inquiries.vue', () => {
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
sinon.stub(storedInquiries, 'getStoredInquiries').returns([inquiryInStorage])
sinon.stub(storedInquiries, 'updateStorage')
const newInquiry = {
id: 2,
name: 'foo copy',
@@ -299,13 +296,13 @@ describe('Inquiries.vue', () => {
}
sinon.stub(storedInquiries, 'duplicateInquiry').returns(newInquiry)
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [inquiryInStorage]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
await wrapper.findComponent({ ref: 'mainCheckBox' }).find('.checkbox-container')
.trigger('click')
@@ -326,11 +323,11 @@ describe('Inquiries.vue', () => {
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
sinon.stub(storedInquiries, 'getStoredInquiries').returns([inquiryInStorage])
const state = {
tabs: [],
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [inquiryInStorage]
}
const actions = { addTab: sinon.stub().resolves(1) }
sinon.spy(mutations, 'setCurrentTabId')
@@ -342,7 +339,6 @@ describe('Inquiries.vue', () => {
mocks: { $router }
})
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
await wrapper.find('tbody tr').trigger('click')
@@ -364,42 +360,40 @@ describe('Inquiries.vue', () => {
createdAt: '2020-03-08T19:57:56.299Z'
}
])
sinon.stub(storedInquiries, 'getStoredInquiries').returns([])
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: []
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'RenameIcon' }).exists()).to.equals(false)
})
it('Renames an inquiry', async () => {
sinon.stub(storedInquiries, 'readPredefinedInquiries').resolves([])
sinon.stub(storedInquiries, 'getStoredInquiries').returns([
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
])
sinon.stub(storedInquiries, 'updateStorage')
const state = {
tabs: [{ id: 1, name: 'foo' }],
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
// click Rename icon in the grid
@@ -419,19 +413,20 @@ describe('Inquiries.vue', () => {
.findAll('.dialog-buttons-container button').wrappers
.find(button => button.text() === 'Rename')
.trigger('click')
await wrapper.vm.$nextTick()
// check that the name in the grid is changed
expect(wrapper.find('tbody tr td').text()).to.equals('bar')
// check that storage is updated
expect(storedInquiries.updateStorage.calledOnceWith(sinon.match([{
expect(state.inquiries).to.eql([{
id: 1,
name: 'bar',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}]))).to.equals(true)
}])
// check that coresponding tab also changed the name
expect(state.tabs[0].name).to.equals('bar')
@@ -442,26 +437,25 @@ describe('Inquiries.vue', () => {
it('Shows an error if try to rename to empty string', async () => {
sinon.stub(storedInquiries, 'readPredefinedInquiries').resolves([])
sinon.stub(storedInquiries, 'getStoredInquiries').returns([
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
])
sinon.stub(storedInquiries, 'updateStorage')
const state = {
tabs: [{ id: 1, name: 'foo' }],
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
// click Rename icon in the grid
@@ -492,7 +486,6 @@ describe('Inquiries.vue', () => {
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
sinon.stub(storedInquiries, 'getStoredInquiries').returns([inquiryInStorage])
sinon.stub(storedInquiries, 'updateStorage')
const importedInquiry = {
id: 2,
@@ -504,13 +497,13 @@ describe('Inquiries.vue', () => {
}
sinon.stub(storedInquiries, 'importInquiries').resolves([importedInquiry])
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [inquiryInStorage]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = shallowMount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
// click Import
@@ -520,9 +513,7 @@ describe('Inquiries.vue', () => {
expect(rows).to.have.lengthOf(2)
expect(rows.at(1).findAll('td').at(0).text()).to.equals('bar')
expect(rows.at(1).findAll('td').at(1).text()).to.equals('3 December 2020 20:57')
expect(storedInquiries.updateStorage.calledOnceWith(
sinon.match([inquiryInStorage, importedInquiry])
)).to.equals(true)
expect(state.inquiries).to.eql([inquiryInStorage, importedInquiry])
})
it('Imported inquiries are not selected if master check box was checked', async () => {
@@ -535,7 +526,6 @@ describe('Inquiries.vue', () => {
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
sinon.stub(storedInquiries, 'getStoredInquiries').returns([inquiryInStorage])
sinon.stub(storedInquiries, 'updateStorage')
const importedInquiry = {
id: 2,
@@ -547,13 +537,13 @@ describe('Inquiries.vue', () => {
}
sinon.stub(storedInquiries, 'importInquiries').resolves([importedInquiry])
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [inquiryInStorage]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
// click on master checkbox
@@ -580,16 +570,15 @@ describe('Inquiries.vue', () => {
createdAt: '2020-03-08T19:57:56.299Z'
}
])
sinon.stub(storedInquiries, 'getStoredInquiries').returns([])
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: []
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'DeleteIcon' }).exists()).to.equals(false)
})
@@ -612,18 +601,17 @@ describe('Inquiries.vue', () => {
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
sinon.stub(storedInquiries, 'getStoredInquiries').returns([foo, bar])
sinon.stub(storedInquiries, 'updateStorage')
const state = {
tabs: [{ id: 1 }, { id: 2 }],
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [foo, bar]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
// click Delete icon in the first row of the grid
await wrapper.findComponent({ name: 'DeleteIcon' }).find('svg').trigger('click')
@@ -649,7 +637,7 @@ describe('Inquiries.vue', () => {
expect(state.tabs[0].id).to.equals(2)
// check that storage is updated
expect(storedInquiries.updateStorage.calledOnceWith(sinon.match([bar]))).to.equals(true)
expect(state.inquiries).to.eql([bar])
// check that delete dialog is closed
expect(wrapper.find('[data-modal="delete"]').exists()).to.equal(false)
@@ -666,25 +654,24 @@ describe('Inquiries.vue', () => {
createdAt: '2020-03-08T19:57:56.299Z'
}
])
sinon.stub(storedInquiries, 'getStoredInquiries').returns([
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
])
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [
{
id: 1,
name: 'foo',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-11-03T19:57:56.299Z'
}
]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
expect(wrapper.find('#toolbar-btns-export').isVisible()).to.equal(false)
@@ -726,26 +713,25 @@ describe('Inquiries.vue', () => {
viewOptions: [],
createdAt: '2020-03-08T19:57:56.299Z'
}
sinon.stub(storedInquiries, 'getStoredInquiries').returns([inquiryInStore, {
id: 2,
name: 'bar',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-03-08T19:57:56.299Z'
}])
sinon.stub(storedInquiries, 'serialiseInquiries').returns('I am a serialized inquiries')
sinon.stub(fu, 'exportToFile')
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [inquiryInStore, {
id: 2,
name: 'bar',
query: '',
viewType: 'chart',
viewOptions: [],
createdAt: '2020-03-08T19:57:56.299Z'
}]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr')
@@ -783,19 +769,18 @@ describe('Inquiries.vue', () => {
viewOptions: [],
createdAt: '2020-03-08T19:57:56.299Z'
}
sinon.stub(storedInquiries, 'getStoredInquiries').returns([inquiryInStore])
sinon.stub(storedInquiries, 'serialiseInquiries').returns('I am a serialized inquiries')
sinon.stub(fu, 'exportToFile')
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [inquiryInStore]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
await wrapper.findComponent({ ref: 'mainCheckBox' }).find('.checkbox-container')
@@ -846,19 +831,18 @@ describe('Inquiries.vue', () => {
viewOptions: [],
createdAt: '2020-03-08T19:57:56.299Z'
}
sinon.stub(storedInquiries, 'getStoredInquiries').returns([foo, bar, baz])
sinon.stub(storedInquiries, 'updateStorage')
const state = {
tabs: [{ id: 1 }, { id: 2 }, { id: 0 }, { id: 3 }],
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [foo, bar, baz]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr')
@@ -893,7 +877,7 @@ describe('Inquiries.vue', () => {
expect(state.tabs[1].id).to.equals(3)
// check that storage is updated
expect(storedInquiries.updateStorage.calledOnceWith(sinon.match([baz]))).to.equals(true)
expect(state.inquiries).to.eql([baz])
// check that delete dialog is closed
expect(wrapper.find('[data-modal="delete"]').exists()).to.equal(false)
@@ -925,18 +909,17 @@ describe('Inquiries.vue', () => {
viewOptions: [],
createdAt: '2020-03-08T19:57:56.299Z'
}
sinon.stub(storedInquiries, 'getStoredInquiries').returns([foo, bar])
sinon.stub(storedInquiries, 'updateStorage')
const state = {
tabs: [],
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [foo, bar]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
const rows = wrapper.findAll('tbody tr')
@@ -968,7 +951,7 @@ describe('Inquiries.vue', () => {
expect(wrapper.findAll('tbody tr').at(1).find('td').text()).to.equals('bar')
// check that storage is updated
expect(storedInquiries.updateStorage.calledOnceWith(sinon.match([bar]))).to.equals(true)
expect(state.inquiries).to.eql([bar])
// check that delete dialog is closed
expect(wrapper.find('[data-modal="delete"]').exists()).to.equal(false)
@@ -1000,18 +983,17 @@ describe('Inquiries.vue', () => {
viewOptions: [],
createdAt: '2020-03-08T19:57:56.299Z'
}
sinon.stub(storedInquiries, 'getStoredInquiries').returns([foo, bar])
sinon.stub(storedInquiries, 'updateStorage')
const state = {
tabs: [],
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [foo, bar]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
await wrapper.findComponent({ ref: 'mainCheckBox' }).find('.checkbox-container')
@@ -1039,7 +1021,7 @@ describe('Inquiries.vue', () => {
expect(wrapper.findAll('tbody tr').at(0).find('td').text()).to.contains('hello_world')
// check that storage is updated
expect(storedInquiries.updateStorage.calledOnceWith(sinon.match([]))).to.equals(true)
expect(state.inquiries).to.eql([])
// check that delete dialog is closed
expect(wrapper.find('[data-modal="delete"]').exists()).to.equal(false)
@@ -1063,16 +1045,15 @@ describe('Inquiries.vue', () => {
viewOptions: [],
createdAt: '2020-03-08T19:57:56.299Z'
}
sinon.stub(storedInquiries, 'getStoredInquiries').returns([foo, bar])
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [foo, bar]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
const mainCheckBox = wrapper.findComponent({ ref: 'mainCheckBox' })
@@ -1122,16 +1103,15 @@ describe('Inquiries.vue', () => {
viewOptions: [],
createdAt: '2020-03-08T19:57:56.299Z'
}
sinon.stub(storedInquiries, 'getStoredInquiries').returns([foo, bar])
const state = {
predefinedInquiries: []
predefinedInquiries: [],
inquiries: [foo, bar]
}
const store = new Vuex.Store({ state, mutations })
const store = new Vuex.Store({ state, mutations, actions })
const wrapper = mount(Inquiries, { store })
await storedInquiries.readPredefinedInquiries.returnValues[0]
await storedInquiries.getStoredInquiries.returnValues[0]
await wrapper.vm.$nextTick()
const mainCheckBox = wrapper.findComponent({ ref: 'mainCheckBox' })

View File

@@ -349,16 +349,19 @@ describe('MainMenu.vue', () => {
const mutations = {
updateTab: sinon.stub()
}
const store = new Vuex.Store({ state, mutations })
const actions = {
saveInquiry: sinon.stub().returns({
name: 'foo',
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
})
}
const store = new Vuex.Store({ state, mutations, actions })
const $route = { path: '/workspace' }
sinon.stub(storedInquiries, 'isTabNeedName').returns(false)
sinon.stub(storedInquiries, 'save').returns({
name: 'foo',
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
})
wrapper = mount(MainMenu, {
store,
@@ -371,8 +374,11 @@ describe('MainMenu.vue', () => {
// check that the dialog is closed
expect(wrapper.find('[data-modal="save"]').exists()).to.equal(false)
// check that the inquiry was saved via storedInquiries.save (newName='')
expect(storedInquiries.save.calledOnceWith(state.currentTab, '')).to.equal(true)
// check that the inquiry was saved via saveInquiry (newName='')
expect(actions.saveInquiry.calledOnce).to.equal(true)
expect(actions.saveInquiry.args[0][1]).to.eql({
inquiryTab:state.currentTab, newName: ''
})
// check that the tab was updated
expect(mutations.updateTab.calledOnceWith(state, sinon.match({
@@ -408,16 +414,18 @@ describe('MainMenu.vue', () => {
const mutations = {
updateTab: sinon.stub()
}
const store = new Vuex.Store({ state, mutations })
const actions = {
saveInquiry: sinon.stub().returns({
name: 'foo',
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
})
}
const store = new Vuex.Store({ state, mutations, actions })
const $route = { path: '/workspace' }
sinon.stub(storedInquiries, 'isTabNeedName').returns(true)
sinon.stub(storedInquiries, 'save').returns({
name: 'foo',
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
})
wrapper = mount(MainMenu, {
store,
@@ -458,16 +466,18 @@ describe('MainMenu.vue', () => {
const mutations = {
updateTab: sinon.stub()
}
const store = new Vuex.Store({ state, mutations })
const actions = {
saveInquiry: sinon.stub().returns({
name: 'foo',
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
})
}
const store = new Vuex.Store({ state, mutations, actions })
const $route = { path: '/workspace' }
sinon.stub(storedInquiries, 'isTabNeedName').returns(true)
sinon.stub(storedInquiries, 'save').returns({
name: 'foo',
id: 1,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
})
wrapper = mount(MainMenu, {
store,
@@ -489,11 +499,17 @@ describe('MainMenu.vue', () => {
.find(button => button.text() === 'Save')
.trigger('click')
await wrapper.vm.$nextTick()
// check that the dialog is closed
expect(wrapper.find('[data-modal="save"]').exists()).to.equal(false)
// check that the inquiry was saved via storedInquiries.save (newName='foo')
expect(storedInquiries.save.calledOnceWith(state.currentTab, 'foo')).to.equal(true)
// check that the inquiry was saved via saveInquiry (newName='foo')
expect(actions.saveInquiry.calledOnce).to.equal(true)
expect(actions.saveInquiry.args[0][1]).to.eql({
inquiryTab:state.currentTab,
newName:'foo'
})
// check that the tab was updated
expect(mutations.updateTab.calledOnceWith(state, sinon.match({
@@ -538,16 +554,18 @@ describe('MainMenu.vue', () => {
const mutations = {
updateTab: sinon.stub()
}
const store = new Vuex.Store({ state, mutations })
const actions = {
saveInquiry: sinon.stub().returns({
name: 'bar',
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
})
}
const store = new Vuex.Store({ state, mutations, actions })
const $route = { path: '/workspace' }
sinon.stub(storedInquiries, 'isTabNeedName').returns(true)
sinon.stub(storedInquiries, 'save').returns({
name: 'bar',
id: 2,
query: 'SELECT * FROM foo',
viewType: 'chart',
viewOptions: []
})
wrapper = mount(MainMenu, {
store,
@@ -572,11 +590,17 @@ describe('MainMenu.vue', () => {
.find(button => button.text() === 'Save')
.trigger('click')
await wrapper.vm.$nextTick()
// check that the dialog is closed
expect(wrapper.find('[data-modal="save"]').exists()).to.equal(false)
// check that the inquiry was saved via storedInquiries.save (newName='bar')
expect(storedInquiries.save.calledOnceWith(state.currentTab, 'bar')).to.equal(true)
// check that the inquiry was saved via saveInquiry (newName='bar')
expect(actions.saveInquiry.calledOnce).to.equal(true)
expect(actions.saveInquiry.args[0][1]).to.eql({
inquiryTab:state.currentTab,
newName: 'bar'
})
// check that the tab was updated
expect(mutations.updateTab.calledOnceWith(state, sinon.match({
@@ -625,15 +649,17 @@ describe('MainMenu.vue', () => {
const mutations = {
updateTab: sinon.stub()
}
const store = new Vuex.Store({ state, mutations })
const actions = {
saveInquiry: sinon.stub().returns({
name: 'bar',
id: 2,
query: 'SELECT * FROM foo',
chart: []
})
}
const store = new Vuex.Store({ state, mutations, actions })
const $route = { path: '/workspace' }
sinon.stub(storedInquiries, 'isTabNeedName').returns(true)
sinon.stub(storedInquiries, 'save').returns({
name: 'bar',
id: 2,
query: 'SELECT * FROM foo',
chart: []
})
wrapper = mount(MainMenu, {
store,
@@ -656,7 +682,7 @@ describe('MainMenu.vue', () => {
expect(wrapper.find('[data-modal="save"]').exists()).to.equal(false)
// check that the inquiry was not saved via storedInquiries.save
expect(storedInquiries.save.called).to.equal(false)
expect(actions.saveInquiry.called).to.equal(false)
// check that the tab was not updated
expect(mutations.updateTab.called).to.equal(false)

View File

@@ -125,7 +125,7 @@ describe('Schema.vue', () => {
})
it('adds table', async () => {
const file = { name: 'test.csv' }
const file = new File([], 'test.csv')
sinon.stub(fIo, 'getFileFromUser').resolves(file)
sinon.stub(csv, 'parse').resolves({
@@ -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)
})
})