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

50 Commits

Author SHA1 Message Date
lana-k
8a9f4b3c0a add Help link 2021-05-18 15:04:35 +02:00
lana-k
77468d34ae add release notes 2021-05-18 15:04:19 +02:00
lana-k
a0577ec0ce Fix gradient for Safari 2021-05-17 21:34:42 +02:00
lana-k
e7d1398546 Rewrite reg exp: make them work in Safari #52 2021-05-17 21:34:24 +02:00
lana-k
aa52048d51 Fix file type detection #48
file.type is empty on some Windows machines (Registry settings affects)
2021-05-17 21:32:09 +02:00
lana-k
33913f8f5c fix lint 2021-05-14 16:47:01 +02:00
lana-k
51eb7a543c Merge branch 'master' of github.com:lana-k/sqliteviz 2021-05-14 16:43:13 +02:00
lana-k
a3fb38b23c SQL query execution state in UI #3
- use LoadingIndicator
- use Logs
2021-05-14 16:42:58 +02:00
lana-k
3bb40b4eb7 Improve LoadingIndicator
- size parameters
- smooth animation (use ony transform)
2021-05-14 16:40:55 +02:00
lana-k
6864bf84f8 Update README.md 2021-05-06 21:36:20 +02:00
lana-k
9f1b3823f6 Update README.md 2021-05-06 20:46:36 +02:00
lana-k
7574f529c3 add hidden state for file in animation 2021-05-06 15:17:15 +02:00
lana-k
653f8eff7b minor changes in animation 2021-05-06 15:05:58 +02:00
lana-k
9b3dda6cff delete .nojekyll from master 2021-05-05 23:43:51 +02:00
lana-k
d94604ebfb Merge branch 'master' of github.com:lana-k/sqliteviz 2021-05-05 23:31:17 +02:00
lana-k
16868ef430 add clean-exclude 2021-05-05 23:30:33 +02:00
lana-k
b162c7043e Create .nojekyll 2021-05-05 23:20:46 +02:00
lana-k
8e856063b8 allow files with underscores on GitHub pages 2021-05-05 23:12:47 +02:00
lana-k
8684b4cef9 remove console.log 2021-05-05 21:46:29 +02:00
lana-k
bcaebd4840 Create an empty database #44 2021-05-05 21:44:44 +02:00
lana-k
4619461af8 change period format 2021-05-05 15:08:54 +02:00
lana-k
9fff1d699a update sql.js #43 2021-05-05 15:08:12 +02:00
lana-k
5ab19c3fae show hint in codemirror on Ctrl+Space 2021-05-04 16:33:37 +02:00
lana-k
cc483f4720 change code structure 2021-05-04 14:13:58 +02:00
lana-k
a07f2d3d99 update plotly 2021-05-02 20:59:03 +02:00
lana-k
b9844b8696 refine pwa app icons 2021-05-02 20:46:27 +02:00
lana-k
464bff3db8 delete screenshots 2021-05-02 20:45:24 +02:00
lana-k
00e434e142 fix loading db after csv:
new tab is not opened now
2021-05-02 14:09:02 +02:00
lana-k
5d6280abec add default table in hint options 2021-05-02 14:04:46 +02:00
lana-k
7a39e905b9 trim csv column names 2021-04-30 20:49:37 +02:00
lana-k
297ea2c18a fix path to service worker 2021-04-30 19:42:26 +02:00
lana-k
1f2327a724 fix global css in index.html 2021-04-30 19:04:08 +02:00
lana-k
6d512422cf Update README.md 2021-04-30 17:57:00 +02:00
lana-k
8ce9a01372 Update README.md 2021-04-30 17:54:54 +02:00
lana-k
acd56a85cb Update README.md 2021-04-30 17:54:12 +02:00
lana-k
a45e218e3f Update README.md 2021-04-30 17:50:01 +02:00
lana-k
a2bc495259 7 sec for tests 2021-04-30 14:58:27 +02:00
lana-k
7f4b167dc2 fix service worker registration #12 2021-04-30 14:14:41 +02:00
lana-k
5ded99e89f TextField: display div with label if label passed 2021-04-29 20:47:09 +02:00
lana-k
a991b02a20 First load loading indicator #42 2021-04-29 20:45:49 +02:00
lana-k
9b6aa3d6c7 add manifest and offline support #12 2021-04-29 16:19:25 +02:00
lana-k
92022f9083 resolve exportToFile in a test 2021-04-28 11:41:25 +02:00
lana-k
15636fed5f increase timeout for tests;
fix warnings in tests
2021-04-28 10:46:40 +02:00
lana-k
9ed53e0d25 Export db #34 2021-04-27 22:51:36 +02:00
lana-k
35baaf2722 Update schema view after script running #38 2021-04-27 15:54:51 +02:00
lana-k
453098b410 Support all CSV media types #37 2021-04-27 13:42:58 +02:00
lana-k
a469de3674 dasharray with units (fix for Firefox) #27 2021-04-24 16:54:16 +02:00
lana-k
24411ac18f fix error handling for web worker in Firefox #27 2021-04-24 16:53:19 +02:00
lana-k
a7ef152140 Enable touch events in headless Firefox 2021-04-24 16:52:15 +02:00
lana-k
97c0c6191b run tests in Firefox 2021-04-24 16:00:31 +02:00
98 changed files with 4166 additions and 1874 deletions

17
.github/workflows/config.grenrc.js vendored Normal file
View File

@@ -0,0 +1,17 @@
module.exports = {
dataSource: 'milestones',
ignoreIssuesWith: [
'wontfix',
'duplicate'
],
milestoneMatch: 'v{{tag_name}}',
template: {
issue: '- {{name}} [{{text}}]({{url}})',
changelogTitle: "## Release notes\n\n",
release: "{{body}}",
},
groupBy: {
'Enhancements:': ["enhancement", "internal"],
'Bug fixes:': ["bug"]
}
}

View File

@@ -25,16 +25,26 @@ jobs:
cd dist
zip -9 -r dist.zip . -x "js/*.map"
- name: Create Release Notes
run: |
npm install github-release-notes@0.16.0 -g
gren changelog --generate --config="/.github/workflows/config.grenrc.js"
env:
GREN_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create release
uses: ncipollo/release-action@v1
with:
artifacts: "dist/dist.zip"
token: ${{ secrets.GITHUB_TOKEN }}
bodyFile: "CHANGELOG.md"
- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@3.6.2
uses: JamesIves/github-pages-deploy-action@4.1.1
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: build # The branch the action should deploy to.
FOLDER: dist/ # The folder the action should deploy.
CLEAN: false # Automatically remove deleted files from the deploy branch
token: ${{ secrets.GITHUB_TOKEN }}
branch: build # The branch the action should deploy to.
folder: dist/ # The folder the action should deploy.
clean: true # Automatically remove deleted files from the deploy branch
clean-exclude: .nojekyll

View File

@@ -15,10 +15,11 @@ jobs:
uses: actions/setup-node@v1
with:
node-version: 10.x
- name: Install chromium
run:
sudo DEBIAN_FRONTEND=noninteractive apt-get update &&
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y chromium-browser
- name: Install browsers
run: |
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update
sudo apt-get install -y chromium-browser firefox
- name: Install the project
run: npm install

163
README.md
View File

@@ -4,153 +4,23 @@
# sqliteviz
Sqliteviz is a single-page application for fully client-side visualisation of SQLite databases or CSV.
Sqliteviz is a single-page offline-first PWA for fully client-side visualisation of SQLite databases or CSV files.
## Get started
With sqliteviz you can:
- run SQL queries against a SQLite database and create [Plotly][11] charts based on the result sets
- import a CSV file into a SQLite database and visualize imported data
- manage queries and chart settings and run them against different databases
- import/export queries and chart settings to/from a JSON file
- export a modified SQLite database
- use it offline from your OS application menu like any other desktop app
The latest release of sqliteviz is running on [Github pages][6]. The simplest way to start is to use sqliteviz there.
https://user-images.githubusercontent.com/24638357/117355518-fa332680-aeb2-11eb-8a69-fbcea4f7aeb0.mp4
### Choose a databese or CSV file
## Quickstart
The latest release of sqliteviz is deployed on GitHub Pages at [lana-k.github.io/sqliteviz][6].
You can choose a database or CSV file right on the welcom page (fig. 1). The supported file extentions: `.csv`, `.db`,`.sqlite` and `.sqlite3`.
<p align="center">
<img class="figure" src="src/assets/images/Screenshot_welcome.png" width="400"/>
</p>
<p align="center">
<sub>
Fig. 1: Welcome page
</sub>
</p>
If you choose a CSV file it will be parsed. Then sqliteviz creates a new database with data from the CSV in `csv_import` table. You can change parsing settings in the dialog which is shown automatically if you choose a CSV file (fig. 2).
<p align="center">
<img class="figure" src="src/assets/images/Screenshot_csv.png" width="650"/>
</p>
<p align="center">
<sub>
Fig. 2: Import CSV dialog
</sub>
</p>
Choosing a database or CSV file is not a mandatory step. You can skip it and manipulate queries in sqliteviz without a database. Choose a database or CSV later in the left panel of the `Editor` when it's time to run a query (fig. 3).
<p align="center">
<img class="figure" src="src/assets/images/Screenshot_editor.png" width="650"/>
</p>
<p align="center">
<sub>
Fig. 3: Editor (neither database nor CSV is chosen)
</sub>
</p>
After chosing a database or CSV you can browse tables, columns and their types in the left panel of the `Editor` (fig. 4).
<p align="center">
<img class="figure" src="src/assets/images/Screenshot_editor_with_db.png" width="650"/>
</p>
<p align="center">
<sub>
Fig. 4: Editor (database is chosen)
</sub>
</p>
### Create a query
#### Open a new tab
Press `Create` button in the top toolbar or use `Ctrl+b`(`Cmd+b` for MacOS) keyboard shortcut to open a new tab for a query. The tab consists of two parts: a query text editor on the top and a result panel on the bottom.
In the query text editor part you can specify a `SELECT` statement for getting data.
The result panel has two modes: table view (fig. 4, fig. 5) and chart view (fig. 3). In the table view you can see the result of query running (fig. 5). In the chart view there is a chart editor component which allows to build a visialization from the result set.
#### Run a query
Press `Run` button in the top toolbar or use `Ctrl+r` or `Ctrl+Enter`(`Cmd+r` or `Cmd+Enter` for MacOS) keyboard shortcut to execute a query in the current opened tab.
> **Note:** Running is not available if neither a database nor CSV was chosen or a query for the current tab is not specified.
The query result will be displayed in the result panel in table mode (fig. 5).
<p align="center">
<img class="figure" src="src/assets/images/Screenshot_result.png" width="650"/>
</p>
<p align="center">
<sub>
Fig. 5: Query results
</sub>
</p>
#### Create a chart
After running a query you can switch result panel to the chart mode and create a chart with a `react-chart-editor` component. The same component with some additional features is used in Plotly Chart Studio. Explore its [documentation][7] to learn how to build charts with `react-chart-editor`.
### Save a query
Press `Save` button in the top toolbar or use `Ctrl+s`(`Cmd+s` for MacOS) keyboard shortcut to save a query in the current opened tab to local storage of your browser.
After that the query will be in the list on `My queries` page.
> **Note:** Only the text of the query and chart settings will be saved. The result of the query execution won't be saved.
## Working with saved queries
You can find all queries that you saved in `My queries` (fig. 6).
<p align="center">
<img class="figure" src="src/assets/images/Screenshot_my_queries.png" width="600"/>
</p>
<p align="center">
<sub>
Fig. 6: My queries
</sub>
</p>
To manipulate one query hover the cursor over the row with the query and choose the action:
* <img src="src/assets/images/rename.svg"/> - rename a query
* <img src="src/assets/images/copy.svg"/> - duplicate a query
* <img src="src/assets/images/file-export.svg"/> - export a query to json file
* <img src="src/assets/images/delete.svg"/> - delete a query
To edit the text of a query or its chart settings click on the respective row. You will be redirected to `Editor` where the chosen query will be opened in a tab.
> **Note:** After opening a query there will be no chart for it even if you specified it and saved. That is so because there is no data to build the chart. Run the query and all saved chart settings will be applied.
You can also delete or export to file a group of queries. Select queries with checkboxes and press `Delete`/`Export` button above the grid (fig. 7).
<p align="center">
<img class="figure" src="src/assets/images/Screenshot_group.png" width="600"/>
</p>
<p align="center">
<sub>
Fig. 7: My queries: a group of queries is selected
</sub>
</p>
> **Note:** Some operations are not available for predefined queries (see below).
## Import queries
Click `Import` button on `My queries` page to import queries from JSON file generated by export.
## Predefined queries
If you run sqliteviz on your own server you can specify predefined queries. These queries will be in `My queries` list for all users working with sqliteviz on your server.
To create a list of predefined queries choose queries in `My queries` list and export them to `queries.json`. Then place this file on the server in the same directory as `index.html`.
A user can't edit, rename or delete a predefined query. The rest operations are available.
## Wiki
For user documentation, check out sqliteviz [Wiki][7].
## Motivation
It's a kind of middleground between [Plotly Falcon][1] and [Redash][2].
@@ -163,8 +33,9 @@ It is built on top of [react-chart-editor][3], [sql.js][4] and [Vue-Codemirror][
[3]: https://github.com/plotly/react-chart-editor
[4]: https://github.com/sql-js/sql.js
[5]: https://github.com/vuejs/vue
[6]: https://lana-k.github.io/sqliteviz
[7]: https://plotly.com/chart-studio-help/tutorials/#basic
[6]: https://lana-k.github.io/sqliteviz/
[7]: https://github.com/lana-k/sqliteviz/wiki
[8]: https://github.com/surmon-china/vue-codemirror#readme
[9]: https://www.papaparse.com/
[10]: https://github.com/lana-k/sqliteviz/wiki/Predefined-queries
[11]: https://github.com/plotly/plotly.js

View File

@@ -76,9 +76,17 @@ module.exports = function (config) {
// enable / disable watching file and executing tests whenever any file changes
autoWatch: false,
customLaunchers: {
FirefoxHeadlessTouch: {
base: 'FirefoxHeadless',
prefs: {
'dom.w3c_touch_events.enabled': 1
}
}
},
// start these browsers
// available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
browsers: ['ChromiumHeadless'],
browsers: ['ChromiumHeadless', 'FirefoxHeadlessTouch'],
// Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits
@@ -86,10 +94,13 @@ module.exports = function (config) {
// Concurrency level
// how many browser should be started simultaneous
concurrency: Infinity,
concurrency: 2,
client: {
captureConsole: true
captureConsole: true,
mocha: {
timeout: 7000
}
},
browserConsoleLogOptions: {
terminal: true,
@@ -130,7 +141,7 @@ module.exports = function (config) {
]
},
{
test: /\.worker\.js$/,
test: /worker\.js$/,
loader: 'worker-loader'
},
{

4070
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "sqliteviz",
"version": "1.0.0",
"version": "0.11.0",
"license": "Apache-2.0",
"private": true,
"scripts": {
@@ -15,12 +15,12 @@
"debounce": "^1.2.0",
"nanoid": "^3.1.12",
"papaparse": "^5.3.0",
"plotly.js": "^1.57.1",
"plotly.js": "^1.58.4",
"promise-worker": "^2.0.1",
"react": "^16.13.1",
"react-chart-editor": "^0.42.0",
"react-dom": "^16.13.1",
"sql.js": "^1.3.0",
"sql.js": "^1.5.0",
"sqlite-parser": "^1.0.1",
"vue": "^2.6.11",
"vue-codemirror": "^4.0.6",
@@ -48,9 +48,11 @@
"eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^6.2.2",
"karma": "^3.1.4",
"karma-firefox-launcher": "^2.1.0",
"karma-webpack": "^4.0.2",
"vue-cli-plugin-ui-karma": "^0.2.5",
"vue-template-compiler": "^2.6.11",
"workbox-webpack-plugin": "^6.1.5",
"worker-loader": "^3.0.8"
}
}

BIN
public/Logo192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/Logo48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/Logo512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 798 B

After

Width:  |  Height:  |  Size: 774 B

View File

@@ -5,13 +5,94 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.png">
<link rel="manifest" href="<%= BASE_URL %>manifest.webmanifest">
<title><%= htmlWebpackPlugin.options.title %></title>
<style>
#sqliteviz-loading-wrapper {
position: fixed;
width: 100%;
height: 100%;
left: 0;
top: 0;
background-color: white;
}
#sqliteviz-loading-text {
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #506784;
font-family: sans-serif;
font-size: 20px;
}
#sqliteviz-loading-wrapper svg {
display: block;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
#sqliteviz-loading-wrapper circle {
position: absolute;
left: 0; right: 0; top: 0; bottom: 0;
fill: none;
stroke-width: 5px;
stroke-linecap: round;
stroke: #119DFF;
}
#sqliteviz-loading-wrapper circle.bg {
stroke: #C8D4E3;
}
#sqliteviz-loading-wrapper circle.front {
stroke-dasharray: 402px;
animation: sqliteviz-loading 2s linear 0s infinite;
}
@keyframes sqliteviz-loading {
0% {
stroke-dasharray: 100px 402px;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 251px;
stroke-dashoffset: -251px;
}
100% {
stroke-dasharray: 100px 402px;
stroke-dashoffset: -502px;
}
}
</style>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<div id="app">
<div id="sqliteviz-loading-wrapper">
<div id="sqliteviz-loading-text">LOADING</div>
<svg height="170" width="170" viewBox="0 0 170 170">
<circle
class="bg"
cx="85"
cy="85"
r="80"
/>
<circle
class="front"
cx="85"
cy="85"
r="80"
/>
</svg>
</div>
</div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@@ -0,0 +1,30 @@
{
"background_color": "white",
"description": "Sqliteviz is a single-page application for fully client-side visualisation of SQLite databases or CSV.",
"display": "fullscreen",
"icons": [
{
"src": "favicon.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "Logo48x48.png",
"sizes": "48x48",
"type": "image/png"
},
{
"src": "Logo192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "Logo512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"name": "sqliteviz",
"short_name": "sqliteviz",
"start_url": "index.html"
}

View File

@@ -60,4 +60,7 @@ button,
body {
margin: 0;
}
.CodeMirror-hints {
z-index: 999 !important;
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -6,7 +6,6 @@
padding: 0 6px;
line-height: 19px;;
position: fixed;
z-index: 5;
height: 19px;
border-radius: var(--border-radius-medium);
white-space: nowrap;

View File

@@ -40,7 +40,7 @@
</template>
<script>
import ascii from '@/ascii'
import ascii from './ascii'
import DropDownChevron from '@/components/svg/dropDownChevron'
import ClearIcon from '@/components/svg/clear'

View File

@@ -10,7 +10,7 @@ export default {
getResult (source) {
const result = {}
if (source.meta.fields) {
result.columns = source.meta.fields
result.columns = source.meta.fields.map(col => col.trim())
result.values = source.data.map(row => {
const resultRow = []
result.columns.forEach(col => { resultRow.push(row[col]) })

View File

@@ -1,7 +1,7 @@
<template>
<div class="db-uploader-container">
<div class="db-uploader-container" :style="{ width }">
<change-db-icon v-if="type === 'small'" @click.native="browse"/>
<div v-if="['regular', 'illustrated'].includes(type)" class="drop-area-container">
<div v-if="type === 'illustrated'" class="drop-area-container">
<div
class="drop-area"
@dragover.prevent="state = 'dragover'"
@@ -26,7 +26,8 @@
ref="fileImg"
:class="{
'swing': state === 'dragover',
'fly': state === 'drop'
'fly': state === 'dropping',
'hidden': state === 'dropped'
}"
:src="require('@/assets/images/file.png')"
/>
@@ -127,17 +128,17 @@
</template>
<script>
import fu from '@/file.utils'
import csv from '@/csv'
import fIo from '@/lib/utils/fileIo'
import csv from './csv'
import CloseIcon from '@/components/svg/close'
import TextField from '@/components/TextField'
import DelimiterSelector from '@/components/DelimiterSelector'
import DelimiterSelector from './DelimiterSelector'
import CheckBox from '@/components/CheckBox'
import SqlTable from '@/components/SqlTable'
import Logs from '@/components/Logs'
import ChangeDbIcon from '@/components/svg/changeDb'
import time from '@/time'
import database from '@/database'
import time from '@/lib/utils/time'
import database from '@/lib/database'
export default {
name: 'DbUploader',
@@ -145,10 +146,15 @@ export default {
type: {
type: String,
required: false,
default: 'regular',
default: 'small',
validator: (value) => {
return ['regular', 'illustrated', 'small'].includes(value)
return ['illustrated', 'small'].includes(value)
}
},
width: {
type: String,
required: false,
default: 'unset'
}
},
components: {
@@ -182,6 +188,7 @@ export default {
this.animationPromise = new Promise((resolve) => {
this.$refs.fileImg.addEventListener('animationend', event => {
if (event.animationName.startsWith('fly')) {
this.state = 'dropped'
resolve()
}
})
@@ -217,8 +224,16 @@ export default {
this.$store.commit('saveSchema', this.schema)
if (this.importCsvCompleted) {
this.$modal.hide('parse')
const tabId = await this.$store.dispatch('addTab', { query: 'select * from csv_import' })
const stmt = [
'/*',
' * Your CSV file has been imported into csv_import table.',
' * You can run this SQL query to make all CSV records available for charting.',
' */',
'SELECT * FROM csv_import'
].join('\n')
const tabId = await this.$store.dispatch('addTab', { query: stmt })
this.$store.commit('setCurrentTabId', tabId)
this.importCsvCompleted = false
}
if (this.$route.path !== '/editor') {
this.$router.push('/editor')
@@ -324,8 +339,9 @@ export default {
}, 1000)
// Create db with csv table and get schema
const name = file.name.replace(/\.[^.]+$/, '')
start = new Date()
this.schema = await this.newDb.createDb(file.name, parseResult.data, progressCounterId)
this.schema = await this.newDb.importDb(name, parseResult.data, progressCounterId)
end = new Date()
// Inform about import success
@@ -364,8 +380,10 @@ export default {
},
async checkFile (file) {
this.state = 'drop'
if (file.type === 'text/csv') {
this.state = 'dropping'
if (fIo.isDatabase(file)) {
this.loadDb(file)
} else {
this.file = file
this.header = true
this.quoteChar = '"'
@@ -375,12 +393,10 @@ export default {
.then(() => {
this.$modal.show('parse')
})
} else {
this.loadDb(file)
}
},
browse () {
fu.getFileFromUser('.db,.sqlite,.sqlite3,.csv')
fIo.getFileFromUser('.db,.sqlite,.sqlite3,.csv')
.then(this.checkFile)
},
@@ -416,6 +432,7 @@ export default {
align-items: center;
justify-content: center;
height: 100%;
cursor: pointer;
}
#img-container {
@@ -487,13 +504,19 @@ export default {
#file-img.fly {
animation: fly ease-in-out 1s 1 normal;
transform-origin: center center;
top: 183px;
left: 225px;
transition: top 1s ease-in-out, left 1s ease-in-out;
}
@keyframes fly {
100% { transform: rotate(360deg) scale(0.5); }
100% {
transform: rotate(360deg) scale(0.5);
top: 183px;
left: 225px;
}
}
#file-img.hidden {
display: none;
}
/* Parse CSV dialog */
.chars {
display: flex;

View File

@@ -1,17 +1,18 @@
<template>
<svg :class="['svg-container', animationClass ]" height="20" width="20" viewBox="0 0 20 20">
<svg :class="animationClass" :height="size" :width="size" :viewBox="`0 0 ${size} ${size}`">
<circle
class="loader-svg bg"
cx="10"
cy="10"
r="8"
:style="{ strokeWidth }"
:cx="size / 2"
:cy="size / 2"
:r="radius"
/>
<circle
class="loader-svg front"
:style="{ strokeDasharray: circleProgress }"
cx="10"
cy="10"
r="8"
:style="{ strokeDasharray: circleProgress, strokeDashoffset: offset, strokeWidth }"
:cx="size / 2"
:cy="size / 2"
:r="radius"
/>
</svg>
</template>
@@ -19,15 +20,35 @@
<script>
export default {
name: 'LoadingIndicator',
props: ['progress'],
props: {
progress: {
type: Number,
required: false
},
size: {
type: Number,
required: false,
default: 20
}
},
computed: {
circleProgress () {
const dash = (50.24 * this.progress) / 100
const space = 50.24 - dash
return `${dash}, ${space}`
const circle = this.radius * 3.14 * 2
const dash = this.progress ? (circle * this.progress) / 100 : circle * 1 / 3
const space = circle - dash
return `${dash}px, ${space}px`
},
animationClass () {
return this.progress === undefined ? 'loading' : 'progress'
},
radius () {
return this.size / 2 - this.strokeWidth
},
offset () {
return this.radius * 3.14 / 2
},
strokeWidth () {
return this.size / 10
}
}
}
@@ -38,7 +59,6 @@ export default {
position: absolute;
left: 0; right: 0; top: 0; bottom: 0;
fill: none;
stroke-width: 2px;
stroke-linecap: round;
stroke: var(--color-accent);
}
@@ -48,27 +68,30 @@ export default {
}
.loading .loader-svg.front {
stroke-dasharray: 40.24;
will-change: transform;
animation: fill-animation-loading 1s cubic-bezier(1,1,1,1) 0s infinite;
transform-origin: center;
}
/*
We can't change anything in loading animation except transform, opacity and filter. Because in
our case the Main Thread can be busy and animation will be frozen (e. g. getting a result set
from the web-worker after query execution).
But transform, opacity and filter trigger changes only in the Composite Layer stage in rendering
waterfall. Hence they can be processed only with Compositor Thread while the Main Thread
processes something else.
https://www.viget.com/articles/animation-performance-101-browser-under-the-hood/
*/
@keyframes fill-animation-loading {
0% {
stroke-dasharray: 10 40.24;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 25.12;
stroke-dashoffset: 25.12;
transform: rotate(0deg);
}
100% {
stroke-dasharray: 10 40.24 ;
stroke-dashoffset: 50.24;
transform: rotate(360deg);
}
}
.progress .loader-svg.front {
stroke-dashoffset: 12.56;
transition: stroke-dasharray 0.2s;
}

View File

@@ -64,6 +64,7 @@ export default {
border: 1px solid var(--color-border-light);
box-sizing: border-box;
overflow-y: scroll;
color: var(--color-text-base);
}
.msg {
padding: 16px 7px;

View File

@@ -65,7 +65,7 @@
</template>
<script>
import splitter from '@/splitter'
import splitter from './splitter'
export default {
name: 'Splitpanes',

View File

@@ -41,6 +41,7 @@
<div class="table-footer-count">
{{ dataSet.values.length}} {{dataSet.values.length === 1 ? 'row' : 'rows'}} retrieved
<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" />
</div>
@@ -48,12 +49,12 @@
</template>
<script>
import Pager from '@/components/Pager'
import Pager from './Pager'
export default {
name: 'SqlTable',
components: { Pager },
props: ['dataSet', 'height', 'preview'],
props: ['dataSet', 'time', 'height', 'preview'],
data () {
return {
header: null,

View File

@@ -1,6 +1,6 @@
<template>
<div>
<div :class="['text-field-label', { error: errorMsg }, {'disabled': disabled}]">
<div v-if="label" :class="['text-field-label', { error: errorMsg }, {'disabled': disabled}]">
{{ label }}
<hint-icon class="hint" v-if="hint" :hint="hint" :max-width="maxHintWidth || '149px'"/>
</div>
@@ -11,7 +11,7 @@
:style="{ width: width }"
:value="value"
:disabled="disabled"
@input="$emit('input', $event.target.value)"
@input="$emit('input', $event.target.value)"
/>
<div v-show="errorMsg" class="text-field-error">{{ errorMsg }}</div>
</div>

View File

@@ -16,13 +16,13 @@
/>
</svg>
<span class="icon-tooltip" :style="tooltipStyle">
Change database
Load another database or CSV
</span>
</div>
</template>
<script>
import tooltipMixin from '@/mixins/tooltips'
import tooltipMixin from '@/tooltipMixin'
export default {
name: 'changeDbIcon',

View File

@@ -15,26 +15,28 @@
d="M10.5 1.5H4.5C3.675 1.5 3 2.175 3 3V15C3 15.825 3.675 16.5 4.5 16.5H13.5C14.325 16.5 15 15.825 15 15V6L10.5 1.5ZM13.5 15H4.5V3H9.75V6.75H13.5V15ZM12 8.25V13.575L10.425 12L8.325 14.1L6.225 12L8.325 9.9L6.675 8.25H12Z"
fill="#A2B1C6"
/>
</svg>
<span class="icon-tooltip" :style="tooltipStyle">
Export query to file
</span>
</svg>
<span class="icon-tooltip" :style="tooltipStyle">
{{ tooltip }}
</span>
</span>
</template>
<script>
import tooltipMixin from '@/mixins/tooltips'
import tooltipMixin from '@/tooltipMixin'
export default {
name: 'ExportIcon',
mixins: [tooltipMixin]
mixins: [tooltipMixin],
props: ['tooltip']
}
</script>
<style scoped>
.icon {
vertical-align: middle;
display: block;
margin: 0 12px;
cursor: pointer;
}
.icon:hover path {

View File

@@ -20,7 +20,7 @@
</template>
<script>
import tooltipMixin from '@/mixins/tooltips'
import tooltipMixin from '@/tooltipMixin'
export default {
name: 'HintIcon',

View File

@@ -1,5 +1,5 @@
import initSqlJs from 'sql.js/dist/sql-wasm.js'
import dbUtils from '@/db.utils'
import dbUtils from './_statements'
let SQL = null
const sqlModuleReady = initSqlJs().then(sqlModule => { SQL = sqlModule })

View File

@@ -1,5 +1,5 @@
import registerPromiseWorker from 'promise-worker/register'
import Sql from '@/sql'
import Sql from './_sql'
const sqlReady = Sql.build()
@@ -23,7 +23,7 @@ function processMsg (sql) {
function onError (error) {
return {
error
error: error.message
}
}

View File

@@ -1,8 +1,8 @@
import sqliteParser from 'sqlite-parser'
import fu from '@/file.utils'
import fu from '@/lib/utils/fileIo'
// We can import workers like so because of worker-loader:
// https://webpack.js.org/loaders/worker-loader/
import Worker from '@/db.worker.js'
import Worker from './_worker.js'
// Use promise-worker in order to turn worker into the promise based one:
// https://github.com/nolanlawson/promise-worker
@@ -50,7 +50,7 @@ class Database {
delete this.importProgresses[id]
}
async createDb (name, data, progressCounterId) {
async importDb (name, data, progressCounterId) {
const result = await this.pw.postMessage({
action: 'import',
columns: data.columns,
@@ -59,21 +59,22 @@ class Database {
})
if (result.error) {
throw result.error
throw new Error(result.error)
}
return await this.getSchema(name)
}
async loadDb (file) {
const fileContent = await fu.readAsArrayBuffer(file)
const fileContent = file ? await fu.readAsArrayBuffer(file) : null
const res = await this.pw.postMessage({ action: 'open', buffer: fileContent })
if (res.error) {
throw res.error
throw new Error(res.error)
}
return this.getSchema(file.name)
const dbName = file ? file.name.replace(/\.[^.]+$/, '') : 'database'
return this.getSchema(dbName)
}
async getSchema (name) {
@@ -85,12 +86,14 @@ class Database {
const result = await this.execute(getSchemaSql)
// Parse DDL statements to get column names and types
const parsedSchema = []
result.values.forEach(item => {
parsedSchema.push({
name: item[0],
columns: getColumns(item[1])
if (result && result.values) {
result.values.forEach(item => {
parsedSchema.push({
name: item[0],
columns: getColumns(item[1])
})
})
})
}
// Return db name and schema
return {
@@ -103,11 +106,20 @@ class Database {
const results = await this.pw.postMessage({ action: 'exec', sql: commands })
if (results.error) {
throw results.error
throw new Error(results.error)
}
// if it was more than one select - take only the last one
return results[results.length - 1]
}
async export (fileName) {
const data = await this.pw.postMessage({ action: 'export' })
if (data.error) {
throw new Error(data.error)
}
fu.exportToFile(data, fileName)
}
}
function getAst (sql) {
@@ -115,10 +127,10 @@ function getAst (sql) {
// It throws an error if tokenizer has an arguments:
// https://github.com/codeschool/sqlite-parser/issues/59
const fixedSql = sql
.replace(/(?<=tokenize=.+)"tokenchars=.+"/, '')
.replace(/(?<=tokenize=.+)"remove_diacritics=.+"/, '')
.replace(/(?<=tokenize=.+)"separators=.+"/, '')
.replace(/tokenize=.+(?=(,|\)))/, 'tokenize=unicode61')
.replace(/(tokenize=[^,]+)"tokenchars=.+?"/, '$1')
.replace(/(tokenize=[^,]+)"remove_diacritics=.+?"/, '$1')
.replace(/(tokenize=[^,]+)"separators=.+?"/, '$1')
.replace(/tokenize=.+?(,|\))/, 'tokenize=unicode61$1')
return sqliteParser(fixedSql)
}

View File

@@ -1,5 +1,5 @@
import { nanoid } from 'nanoid'
import fu from '@/file.utils'
import fu from '@/lib/utils/fileIo'
export default {
getStoredQueries () {

View File

@@ -1,4 +1,11 @@
export default {
isDatabase (file) {
const dbTypes = ['application/vnd.sqlite3', 'application/x-sqlite3']
return file.type
? dbTypes.includes(file.type)
: /\.(db|sqlite(3)?)+$/.test(file.name)
},
exportToFile (str, fileName, type = 'octet/stream') {
// Create downloader
const downloader = document.createElement('a')

7
src/lib/utils/time.js Normal file
View File

@@ -0,0 +1,7 @@
export default {
getPeriod (start, end) {
const diff = end.getTime() - start.getTime()
const seconds = diff / 1000
return seconds.toFixed(3) + 's'
}
}

View File

@@ -1,7 +1,7 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import App from '@/App.vue'
import router from '@/router'
import store from '@/store'
import { VuePlugin } from 'vuera'
import VModal from 'vue-js-modal'
@@ -12,6 +12,10 @@ import '@/assets/styles/dialogs.css'
import '@/assets/styles/tooltips.css'
import '@/assets/styles/messages.css'
if (!['localhost', '127.0.0.1'].includes(location.hostname)) {
import('./registerServiceWorker') // eslint-disable-line no-unused-expressions
}
Vue.use(VuePlugin)
Vue.use(VModal)

View File

@@ -0,0 +1,44 @@
let refresh = false
function invokeServiceWorkerUpdateFlow (registration) {
const agree = confirm('New version of the app is available. Refresh now?')
if (agree) {
if (registration.waiting) {
// let waiting Service Worker know it should became active
refresh = true
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
}
}
}
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
const registration = await navigator.serviceWorker.register('service-worker.js')
// ensure the case when the updatefound event was missed is also handled
// by re-invoking the prompt when there's a waiting Service Worker
if (registration.waiting) {
invokeServiceWorkerUpdateFlow(registration)
}
// detect Service Worker update available and wait for it to become installed
registration.addEventListener('updatefound', () => {
const newRegestration = registration.installing
if (newRegestration) {
// wait until the new Service worker is actually installed (ready to take over)
newRegestration.addEventListener('statechange', () => {
if (registration.waiting && navigator.serviceWorker.controller) {
invokeServiceWorkerUpdateFlow(registration)
}
})
}
})
// detect controller change and refresh the page
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refresh) {
window.location.reload()
refresh = false
}
})
})
}

View File

@@ -1,9 +1,9 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import Editor from '@/views/Editor'
import MyQueries from '@/views/MyQueries'
import Home from '@/views/Home'
import MainView from '@/views/MainView'
import Editor from '@/views/Main/Editor'
import MyQueries from '@/views/Main/MyQueries'
import Welcome from '@/views/Welcome'
import Main from '@/views/Main'
Vue.use(VueRouter)
@@ -11,12 +11,12 @@ const routes = [
{
path: '/',
name: 'Welcome',
component: Home
component: Welcome
},
{
path: '/',
name: 'MainView',
component: MainView,
name: 'Main',
component: Main,
children: [
{
path: '/editor',

30
src/store/actions.js Normal file
View File

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

View File

@@ -1,112 +1,11 @@
import Vue from 'vue'
import Vuex from 'vuex'
import { nanoid } from 'nanoid'
import state from '@/store/state'
import mutations from '@/store/mutations'
import actions from '@/store/actions'
Vue.use(Vuex)
export const state = {
schema: null,
dbFile: null,
dbName: null,
tabs: [],
currentTab: null,
currentTabId: null,
untitledLastIndex: 0,
predefinedQueries: [],
db: null
}
export const mutations = {
setDb (state, db) {
if (state.db) {
state.db.shutDown()
}
state.db = db
},
saveSchema (state, { dbName, schema }) {
state.dbName = dbName
state.schema = schema
},
updateTab (state, { index, name, id, query, chart, isUnsaved }) {
const tab = state.tabs[index]
const oldId = tab.id
if (id && state.currentTabId === oldId) {
state.currentTabId = id
}
if (id) { tab.id = id }
if (name) { tab.name = name }
if (query) { tab.query = query }
if (chart) { tab.chart = chart }
if (isUnsaved !== undefined) { tab.isUnsaved = isUnsaved }
if (!isUnsaved) {
// Saved query is not predefined
delete tab.isPredefined
}
Vue.set(state.tabs, index, tab)
},
deleteTab (state, index) {
// If closing tab is the current opened
if (state.tabs[index].id === state.currentTabId) {
if (index < state.tabs.length - 1) {
state.currentTabId = state.tabs[index + 1].id
} else if (index > 0) {
state.currentTabId = state.tabs[index - 1].id
} else {
state.currentTabId = null
state.currentTab = null
state.untitledLastIndex = 0
}
}
state.tabs.splice(index, 1)
},
setCurrentTabId (state, id) {
state.currentTabId = id
},
setCurrentTab (state, tab) {
state.currentTab = tab
},
updatePredefinedQueries (state, queries) {
if (Array.isArray(queries)) {
state.predefinedQueries = queries
} else {
state.predefinedQueries = [queries]
}
}
}
export const actions = {
async addTab ({ state }, data) {
const tab = data ? JSON.parse(JSON.stringify(data)) : {}
// If no data then create a new blank one...
// No data.id means to create new tab, but not blank,
// e.g. with 'select * from csv_import' query after csv import
if (!data || !data.id) {
tab.id = nanoid()
tab.name = null
tab.tempName = state.untitledLastIndex
? `Untitled ${state.untitledLastIndex}`
: 'Untitled'
tab.isUnsaved = true
} else {
tab.isUnsaved = false
}
// add new tab only if was not already opened
if (!state.tabs.some(openedTab => openedTab.id === tab.id)) {
state.tabs.push(tab)
if (!tab.name) {
state.untitledLastIndex += 1
}
}
return tab.id
}
}
export default new Vuex.Store({
state,
mutations,

63
src/store/mutations.js Normal file
View File

@@ -0,0 +1,63 @@
import Vue from 'vue'
export default {
setDb (state, db) {
if (state.db) {
state.db.shutDown()
}
state.db = db
},
saveSchema (state, { dbName, schema }) {
state.dbName = dbName
state.schema = schema
},
updateTab (state, { index, name, id, query, chart, isUnsaved }) {
const tab = state.tabs[index]
const oldId = tab.id
if (id && state.currentTabId === oldId) {
state.currentTabId = id
}
if (id) { tab.id = id }
if (name) { tab.name = name }
if (query) { tab.query = query }
if (chart) { tab.chart = chart }
if (isUnsaved !== undefined) { tab.isUnsaved = isUnsaved }
if (!isUnsaved) {
// Saved query is not predefined
delete tab.isPredefined
}
Vue.set(state.tabs, index, tab)
},
deleteTab (state, index) {
// If closing tab is the current opened
if (state.tabs[index].id === state.currentTabId) {
if (index < state.tabs.length - 1) {
state.currentTabId = state.tabs[index + 1].id
} else if (index > 0) {
state.currentTabId = state.tabs[index - 1].id
} else {
state.currentTabId = null
state.currentTab = null
state.untitledLastIndex = 0
}
}
state.tabs.splice(index, 1)
},
setCurrentTabId (state, id) {
state.currentTabId = id
},
setCurrentTab (state, tab) {
state.currentTab = tab
},
updatePredefinedQueries (state, queries) {
if (Array.isArray(queries)) {
state.predefinedQueries = queries
} else {
state.predefinedQueries = [queries]
}
}
}

11
src/store/state.js Normal file
View File

@@ -0,0 +1,11 @@
export default {
schema: null,
dbFile: null,
dbName: null,
tabs: [],
currentTab: null,
currentTabId: null,
untitledLastIndex: 0,
predefinedQueries: [],
db: null
}

View File

@@ -1,36 +0,0 @@
export default {
getPeriod (start, end) {
let diff = end.getTime() - start.getTime()
let result = ''
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
diff -= days * (1000 * 60 * 60 * 24)
if (days) {
result += days + ' d '
}
const hours = Math.floor(diff / (1000 * 60 * 60))
diff -= hours * (1000 * 60 * 60)
if (hours) {
result += hours + ' h '
}
const mins = Math.floor(diff / (1000 * 60))
diff -= mins * (1000 * 60)
if (mins) {
result += mins + ' m '
}
const seconds = Math.floor(diff / (1000))
diff -= seconds * (1000)
if (seconds) {
result += seconds + ' s '
}
if (diff) {
result += diff + ' ms '
}
return result.replace(/\s$/, '')
}
}

View File

@@ -1,78 +0,0 @@
<template>
<div>
<splitpanes
class="schema-tabs-splitter"
:before="{ size: 20, max: 30 }"
:after="{ size: 80, max: 100 }"
>
<template #left-pane>
<schema v-if="$store.state.schema"/>
<div v-else id="empty-schema-container">
<div class="warning">
Database is not loaded. Queries cant be run without database.
</div>
<db-uploader id="db-uploader"/>
</div>
</template>
<template #right-pane>
<tabs />
</template>
</splitpanes>
</div>
</template>
<script>
import Splitpanes from '@/components/Splitpanes'
import Schema from '@/components/Schema'
import Tabs from '@/components/Tabs'
import DbUploader from '@/components/DbUploader'
export default {
name: 'Editor',
components: {
Schema,
Splitpanes,
Tabs,
DbUploader
}
}
</script>
<style scoped>
.schema-tabs-splitter {
height: 100%;
background-color: var(--color-white);
}
#empty-schema-container {
display: flex;
flex-direction: column;
align-items: center;
min-width: 200px;
height: 100%;
}
#db-uploader {
flex-grow: 1;
padding: 24px;
width: 100%;
box-sizing: border-box;
}
.warning {
padding: 12px 24px;
width: 100%;
box-sizing: border-box;
}
>>> .db-uploader-container {
width: 100%;
}
>>>.drop-area {
padding: 0 15px;
}
>>>.drop-area .text {
max-width: 200px;
}
</style>

View File

@@ -5,10 +5,11 @@
</div>
<div id="db">
<div @click="schemaVisible = !schemaVisible" class="db-name">
<tree-chevron :expanded="schemaVisible"/>
<tree-chevron v-show="schema.length > 0" :expanded="schemaVisible"/>
{{ dbName }}
</div>
<db-uploader id="db-edit" type="small" />
<db-uploader id="db-edit" type="small" />
<export-icon tooltip="Export database" @click="exportToFile"/>
</div>
<div v-show="schemaVisible" class="schema">
<table-description
@@ -22,10 +23,11 @@
</template>
<script>
import TableDescription from '@/components/TableDescription'
import TableDescription from './TableDescription'
import TextField from '@/components/TextField'
import TreeChevron from '@/components/svg/treeChevron'
import DbUploader from '@/components/DbUploader'
import ExportIcon from '@/components/svg/export'
export default {
name: 'Schema',
@@ -33,7 +35,8 @@ export default {
TableDescription,
TextField,
TreeChevron,
DbUploader
DbUploader,
ExportIcon
},
data () {
return {
@@ -56,6 +59,11 @@ export default {
dbName () {
return this.$store.state.dbName
}
},
methods: {
exportToFile () {
this.$store.state.db.export(`${this.dbName}.sqlite`)
}
}
}
</script>
@@ -78,7 +86,7 @@ export default {
width: 100%;
height: 100px;
box-sizing: border-box;
background-image: linear-gradient(white 73%, transparent);;
background-image: linear-gradient(white 73%, rgba(255, 255, 255, 0));
z-index: 2;
}
.schema, .db-name {
@@ -95,6 +103,11 @@ export default {
.db-name {
cursor: pointer;
margin-right: 6px;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
}
.db-name:hover .chevron-icon path,

View File

@@ -28,7 +28,7 @@ import plotly from 'plotly.js/dist/plotly'
import 'react-chart-editor/lib/react-chart-editor.min.css'
import PlotlyEditor from 'react-chart-editor'
import chart from '@/chart'
import chartHelper from './chartHelper'
import dereference from 'react-chart-editor/lib/lib/dereference'
export default {
@@ -49,10 +49,10 @@ export default {
},
computed: {
dataSources () {
return chart.getDataSourcesFromSqlResult(this.sqlResult)
return chartHelper.getDataSourcesFromSqlResult(this.sqlResult)
},
dataSourceOptions () {
return chart.getOptionsFromDataSources(this.dataSources)
return chartHelper.getOptionsFromDataSources(this.dataSources)
}
},
watch: {
@@ -71,7 +71,7 @@ export default {
this.$emit('update')
},
getChartStateForSave () {
return chart.getChartStateForSave(this.state, this.dataSources)
return chartHelper.getChartStateForSave(this.state, this.dataSources)
}
}
}

View File

@@ -2,7 +2,6 @@ import CM from 'codemirror'
import 'codemirror/addon/hint/show-hint.js'
import 'codemirror/addon/hint/sql-hint.js'
import store from '@/store'
import { debounce } from 'debounce'
export function getHints (cm, options) {
const token = cm.getTokenAt(cm.getCursor()).string.toUpperCase()
@@ -25,21 +24,27 @@ const hintOptions = {
}
return tables
},
get defaultTable () {
const schema = store.state.schema
return schema && schema.length === 1 ? schema[0].name : null
},
completeSingle: false,
completeOnSingleClick: true,
alignWithWord: false
}
export default {
show: debounce(function (editor) {
// Don't show autocomplete after a space or semicolon or in string literals
const token = editor.getTokenAt(editor.getCursor())
const ch = token.string.slice(-1)
const tokenType = token.type
if (tokenType === 'string' || !ch || ch === ' ' || ch === ';') {
return
}
CM.showHint(editor, getHints, hintOptions)
}, 400)
export function showHintOnDemand (editor) {
CM.showHint(editor, getHints, hintOptions)
}
export default function showHint (editor) {
// Don't show autocomplete after a space or semicolon or in string literals
const token = editor.getTokenAt(editor.getCursor())
const ch = token.string.slice(-1)
const tokenType = token.type
if (tokenType === 'string' || !ch || ch === ' ' || ch === ';') {
return
}
CM.showHint(editor, getHints, hintOptions)
}

View File

@@ -5,7 +5,8 @@
</template>
<script>
import hint from '@/hint'
import showHint, { showHintOnDemand } from './hint'
import { debounce } from 'debounce'
import { codemirror } from 'vue-codemirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/mode/sql/sql.js'
@@ -28,7 +29,8 @@ export default {
lineNumbers: true,
line: true,
autofocus: true,
autoRefresh: true
autoRefresh: true,
extraKeys: { 'Ctrl-Space': showHintOnDemand }
}
}
},
@@ -38,7 +40,7 @@ export default {
}
},
methods: {
onChange: hint.show
onChange: debounce(showHint, 400)
}
}
</script>

View File

@@ -21,7 +21,8 @@
>
Run your query and get results here
</div>
<div v-show="isGettingResults" class="table-preview result-in-progress">
<div v-if="isGettingResults" class="table-preview result-in-progress">
<loading-indicator :size="30"/>
Fetching results...
</div>
<div
@@ -30,10 +31,8 @@
>
No rows retrieved according to your query
</div>
<div v-show="error" class="table-preview error">
{{ error }}
</div>
<sql-table v-if="result" :data-set="result" :height="tableViewHeight" />
<logs v-if="error" :messages="[error]"/>
<sql-table v-if="result" :data-set="result" :time="time" :height="tableViewHeight" />
</div>
<chart
:visible="view === 'chart'"
@@ -50,10 +49,13 @@
<script>
import SqlTable from '@/components/SqlTable'
import SqlEditor from '@/components/SqlEditor'
import Splitpanes from '@/components/Splitpanes'
import ViewSwitcher from '@/components/ViewSwitcher'
import Chart from '@/components/Chart'
import LoadingIndicator from '@/components/LoadingIndicator'
import SqlEditor from './SqlEditor'
import ViewSwitcher from './ViewSwitcher'
import Chart from './Chart'
import Logs from '@/components/Logs'
import time from '@/lib/utils/time'
export default {
name: 'Tab',
@@ -63,7 +65,9 @@ export default {
SqlTable,
Splitpanes,
ViewSwitcher,
Chart
Chart,
LoadingIndicator,
Logs
},
data () {
return {
@@ -73,7 +77,8 @@ export default {
tableViewHeight: 0,
isGettingResults: false,
error: null,
resizeObserver: null
resizeObserver: null,
time: 0
}
},
computed: {
@@ -108,10 +113,18 @@ export default {
this.isGettingResults = true
this.result = null
this.error = null
const state = this.$store.state
try {
this.result = await this.$store.state.db.execute(this.query + ';')
const start = new Date()
this.result = await state.db.execute(this.query + ';')
this.time = time.getPeriod(start, new Date())
const schema = await state.db.getSchema(state.dbName)
this.$store.commit('saveSchema', schema)
} catch (err) {
this.error = err
this.error = {
type: 'error',
message: err
}
}
this.isGettingResults = false
},
@@ -180,11 +193,32 @@ export default {
font-size: 13px;
}
.table-preview.error {
color: var(--color-text-error);
.result-in-progress {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
will-change: opacity;
/*
We need to show loader in 1 sec after starting query execution. We can't do that with
setTimeout because the main thread can be busy by getting a result set from the web worker.
But we can use CSS animation for opacity. Opacity triggers changes only in the Composite Layer
stage in rendering waterfall. Hence it can be processed only with Compositor Thread while
the Main Thread processes a result set.
https://www.viget.com/articles/animation-performance-101-browser-under-the-hood/
*/
animation: show-loader 1s linear 0s 1;
}
.table-preview.error::first-letter {
text-transform: capitalize;
@keyframes show-loader {
0% {
opacity: 0;
}
99% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>

View File

@@ -62,7 +62,7 @@
</template>
<script>
import Tab from '@/components/Tab'
import Tab from './Tab'
import CloseIcon from '@/components/svg/close'
export default {

View File

@@ -0,0 +1,68 @@
<template>
<div>
<splitpanes
class="schema-tabs-splitter"
:before="{ size: 20, max: 30 }"
:after="{ size: 80, max: 100 }"
>
<template #left-pane>
<schema/>
</template>
<template #right-pane>
<tabs />
</template>
</splitpanes>
</div>
</template>
<script>
import Splitpanes from '@/components/Splitpanes'
import Schema from './Schema'
import Tabs from './Tabs'
import database from '@/lib/database'
import store from '@/store'
export default {
name: 'Editor',
components: {
Schema,
Splitpanes,
Tabs
},
async beforeRouteEnter (to, from, next) {
if (!store.state.schema) {
const newDb = database.getNewDatabase()
const newSchema = await newDb.loadDb()
store.commit('setDb', newDb)
store.commit('saveSchema', newSchema)
const stmt = [
'/*',
' * Your database is empty. In order to start building charts',
' * you should create a table and insert data into it.',
' */',
'CREATE TABLE house',
'(',
' name TEXT,',
' points INTEGER',
');',
'INSERT INTO house VALUES',
"('Gryffindor', 100),",
"('Hufflepuff', 90),",
"('Ravenclaw', 95),",
"('Slytherin', 80);"
].join('\n')
const tabId = await store.dispatch('addTab', { query: stmt })
store.commit('setCurrentTabId', tabId)
}
next()
}
}
</script>
<style scoped>
.schema-tabs-splitter {
height: 100%;
background-color: var(--color-white);
}
</style>

View File

@@ -3,6 +3,7 @@
<div>
<router-link to="/editor">Editor</router-link>
<router-link to="/my-queries">My queries</router-link>
<a href="https://github.com/lana-k/sqliteviz/wiki" target="_blank">Help</a>
</div>
<div>
<button
@@ -62,7 +63,7 @@
<script>
import TextField from '@/components/TextField'
import CloseIcon from '@/components/svg/close'
import storedQueries from '@/storedQueries'
import storedQueries from '@/lib/storedQueries'
export default {
name: 'MainMenu',

View File

@@ -81,7 +81,10 @@
<div class="icons-container">
<rename-icon v-if="!query.isPredefined" @click="showRenameDialog(query.id)" />
<copy-icon @click="duplicateQuery(index)"/>
<export-icon @click="exportToFile([query], `${query.name}.json`)"/>
<export-icon
@click="exportToFile([query], `${query.name}.json`)"
tooltip="Export query to file"
/>
<delete-icon
v-if="!query.isPredefined"
@click="showDeleteDialog((new Set()).add(query.id))"
@@ -138,16 +141,16 @@
</template>
<script>
import RenameIcon from '@/components/svg/rename'
import CopyIcon from '@/components/svg/copy'
import RenameIcon from './svg/rename'
import CopyIcon from './svg/copy'
import ExportIcon from '@/components/svg/export'
import DeleteIcon from '@/components/svg/delete'
import DeleteIcon from './svg/delete'
import CloseIcon from '@/components/svg/close'
import TextField from '@/components/TextField'
import CheckBox from '@/components/CheckBox'
import tooltipMixin from '@/mixins/tooltips'
import storedQueries from '@/storedQueries'
import fu from '@/file.utils'
import tooltipMixin from '@/tooltipMixin'
import storedQueries from '@/lib/storedQueries'
import fu from '@/lib/utils/fileIo'
export default {
name: 'MyQueries',
@@ -520,7 +523,7 @@ tbody tr:hover td {
text-overflow: ellipsis;
}
tbody tr:hover .icons-container {
display: block;
display: flex;
}
.dialog input {
width: 100%;

View File

@@ -23,7 +23,7 @@
</template>
<script>
import tooltipMixin from '@/mixins/tooltips'
import tooltipMixin from '@/tooltipMixin'
export default {
name: 'CopyIcon',
@@ -33,7 +33,7 @@ export default {
<style scoped>
.icon {
vertical-align: middle;
display: block;
margin: 0 12px;
}
.icon:hover path {

View File

@@ -23,7 +23,7 @@
</template>
<script>
import tooltipMixin from '@/mixins/tooltips'
import tooltipMixin from '@/tooltipMixin'
export default {
name: 'DeleteIcon',
@@ -33,7 +33,7 @@ export default {
<style scoped>
.icon {
vertical-align: middle;
display: block;
margin: 0 12px;
}

View File

@@ -23,7 +23,7 @@
</template>
<script>
import tooltipMixin from '@/mixins/tooltips'
import tooltipMixin from '@/tooltipMixin'
export default {
name: 'RenameIcon',
@@ -33,7 +33,7 @@ export default {
<style scoped>
.icon {
vertical-align: middle;
display: block;
margin: 0 12px;
}

View File

@@ -8,11 +8,11 @@
</template>
<script>
import MainMenu from '@/components/MainMenu'
import MainMenu from './MainMenu'
import '@/assets/styles/scrollbars.css'
export default {
name: 'MainView',
name: 'Main',
components: { MainMenu }
}
</script>

View File

@@ -4,8 +4,8 @@
<div id="note">
Sqliteviz is fully client-side. Your database never leaves your computer.
</div>
<button id ="skip" class="secondary" @click="$router.push('/editor')">
Skip database loading
<button id="skip" class="secondary" @click="$router.push('/editor')">
Create empty database
</button>
</div>
</template>
@@ -14,7 +14,7 @@
import DbUploader from '@/components/DbUploader'
export default {
name: 'Home',
name: 'Welcome',
components: { DbUploader }
}
</script>

View File

@@ -2,32 +2,38 @@ import { expect } from 'chai'
import sinon from 'sinon'
import Vuex from 'vuex'
import { shallowMount, mount } from '@vue/test-utils'
import DbUploader from '@/components/DbUploader.vue'
import fu from '@/file.utils'
import database from '@/database'
import csv from '@/csv'
import DbUploader from '@/components/DbUploader'
import fu from '@/lib/utils/fileIo'
import database from '@/lib/database'
import csv from '@/components/DbUploader/csv'
describe('DbUploader.vue', () => {
let state = {}
let mutations = {}
let store = {}
let place
beforeEach(() => {
// mock store state and mutations
state = {}
mutations = {
saveSchema: sinon.stub()
saveSchema: sinon.stub(),
setDb: sinon.stub()
}
store = new Vuex.Store({ state, mutations })
place = document.createElement('div')
document.body.appendChild(place)
})
afterEach(() => {
sinon.restore()
place.remove()
})
it('loads db on click and redirects to /editor', async () => {
// mock getting a file from user
const file = {}
const file = { name: 'test.db' }
sinon.stub(fu, 'getFileFromUser').resolves(file)
// mock db loading
@@ -43,15 +49,22 @@ describe('DbUploader.vue', () => {
// mount the component
const wrapper = shallowMount(DbUploader, {
attachTo: place,
store,
mocks: { $router, $route }
mocks: { $router, $route },
propsData: {
type: 'illustrated'
}
})
await wrapper.find('.drop-area').trigger('click')
expect(db.loadDb.calledOnceWith(file)).to.equal(true)
await db.loadDb.returnValues[0]
await wrapper.vm.animationPromise
await wrapper.vm.$nextTick()
expect(mutations.saveSchema.calledOnceWith(state, schema)).to.equal(true)
expect($router.push.calledOnceWith('/editor')).to.equal(true)
wrapper.destroy()
})
it('loads db on drop and redirects to /editor', async () => {
@@ -68,12 +81,16 @@ describe('DbUploader.vue', () => {
// mount the component
const wrapper = shallowMount(DbUploader, {
attachTo: place,
store,
mocks: { $router, $route }
mocks: { $router, $route },
propsData: {
type: 'illustrated'
}
})
// mock a file dropped by a user
const file = {}
const file = { name: 'test.db' }
const dropData = { dataTransfer: new DataTransfer() }
Object.defineProperty(dropData.dataTransfer, 'files', {
value: [file],
@@ -83,13 +100,16 @@ describe('DbUploader.vue', () => {
await wrapper.find('.drop-area').trigger('drop', dropData)
expect(db.loadDb.calledOnceWith(file)).to.equal(true)
await db.loadDb.returnValues[0]
await wrapper.vm.animationPromise
await wrapper.vm.$nextTick()
expect(mutations.saveSchema.calledOnceWith(state, schema)).to.equal(true)
expect($router.push.calledOnceWith('/editor')).to.equal(true)
wrapper.destroy()
})
it("doesn't redirect if already on /editor", async () => {
// mock getting a file from user
const file = {}
const file = { name: 'test.db' }
sinon.stub(fu, 'getFileFromUser').resolves(file)
// mock db loading
@@ -105,13 +125,20 @@ describe('DbUploader.vue', () => {
// mount the component
const wrapper = shallowMount(DbUploader, {
attachTo: place,
store,
mocks: { $router, $route }
mocks: { $router, $route },
propsData: {
type: 'illustrated'
}
})
await wrapper.find('.drop-area').trigger('click')
await db.loadDb.returnValues[0]
await wrapper.vm.animationPromise
await wrapper.vm.$nextTick()
expect($router.push.called).to.equal(false)
wrapper.destroy()
})
})
@@ -121,6 +148,7 @@ describe('DbUploader.vue import CSV', () => {
let actions = {}
const newTabId = 1
let store = {}
let place
// mock router
const $router = { }
@@ -131,7 +159,7 @@ describe('DbUploader.vue import CSV', () => {
beforeEach(() => {
// mock getting a file from user
sinon.stub(fu, 'getFileFromUser').resolves({ type: 'text/csv' })
sinon.stub(fu, 'getFileFromUser').resolves({ type: 'text/csv', name: 'foo.csv' })
clock = sinon.useFakeTimers()
@@ -149,15 +177,24 @@ describe('DbUploader.vue import CSV', () => {
$router.push = sinon.stub()
place = document.createElement('div')
document.body.appendChild(place)
// mount the component
wrapper = mount(DbUploader, {
attachTo: place,
store,
mocks: { $router, $route }
mocks: { $router, $route },
propsData: {
type: 'illustrated'
}
})
})
afterEach(() => {
sinon.restore()
wrapper.destroy()
place.remove()
})
it('shows parse dialog if gets csv file', async () => {
@@ -183,6 +220,7 @@ describe('DbUploader.vue import CSV', () => {
await csv.parse.returnValues[0]
await wrapper.vm.animationPromise
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-modal="parse"]').exists()).to.equal(true)
expect(wrapper.findComponent({ name: 'delimiter-selector' }).vm.value).to.equal('|')
expect(wrapper.find('#quote-char input').element.value).to.equal('"')
@@ -218,6 +256,7 @@ describe('DbUploader.vue import CSV', () => {
await csv.parse.returnValues[0]
await wrapper.vm.animationPromise
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
parse.onCall(1).resolves({
delimiter: ',',
@@ -327,6 +366,7 @@ describe('DbUploader.vue import CSV', () => {
await csv.parse.returnValues[0]
await wrapper.vm.animationPromise
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
let resolveParsing
parse.onCall(1).returns(new Promise(resolve => {
@@ -394,6 +434,7 @@ describe('DbUploader.vue import CSV', () => {
await csv.parse.returnValues[0]
await wrapper.vm.animationPromise
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-import').trigger('click')
await csv.parse.returnValues[1]
@@ -451,6 +492,7 @@ describe('DbUploader.vue import CSV', () => {
await csv.parse.returnValues[0]
await wrapper.vm.animationPromise
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-import').trigger('click')
await csv.parse.returnValues[1]
@@ -510,6 +552,7 @@ describe('DbUploader.vue import CSV', () => {
await csv.parse.returnValues[0]
await wrapper.vm.animationPromise
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-import').trigger('click')
await csv.parse.returnValues[1]
@@ -562,8 +605,9 @@ describe('DbUploader.vue import CSV', () => {
let resolveImport = sinon.stub()
const newDb = {
createDb: sinon.stub().resolves(new Promise(resolve => { resolveImport = resolve })),
createProgressCounter: sinon.stub().returns(1)
importDb: sinon.stub().resolves(new Promise(resolve => { resolveImport = resolve })),
createProgressCounter: sinon.stub().returns(1),
deleteProgressCounter: sinon.stub()
}
sinon.stub(database, 'getNewDatabase').returns(newDb)
@@ -571,6 +615,7 @@ describe('DbUploader.vue import CSV', () => {
await csv.parse.returnValues[0]
await wrapper.vm.animationPromise
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-import').trigger('click')
await csv.parse.returnValues[1]
@@ -596,10 +641,11 @@ describe('DbUploader.vue import CSV', () => {
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(newDb.importDb.getCall(0).args[0]).to.equal('foo') // file name
// After resolving - loading indicator is not shown
await resolveImport()
await newDb.createDb.returnValues[0]
await newDb.importDb.returnValues[0]
expect(
wrapper.findComponent({ name: 'logs' }).findComponent({ name: 'LoadingIndicator' }).exists()
).to.equal(false)
@@ -618,7 +664,7 @@ describe('DbUploader.vue import CSV', () => {
hasErrors: false,
messages: []
})
// we need to separate calles because messages will mutate
parse.onCall(1).resolves({
delimiter: '|',
data: {
@@ -634,7 +680,7 @@ describe('DbUploader.vue import CSV', () => {
const schema = {}
const newDb = {
createDb: sinon.stub().resolves(schema),
importDb: sinon.stub().resolves(schema),
createProgressCounter: sinon.stub().returns(1),
deleteProgressCounter: sinon.stub()
}
@@ -644,6 +690,7 @@ describe('DbUploader.vue import CSV', () => {
await csv.parse.returnValues[0]
await wrapper.vm.animationPromise
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-import').trigger('click')
await csv.parse.returnValues[1]
@@ -678,7 +725,7 @@ describe('DbUploader.vue import CSV', () => {
hasErrors: false,
messages: []
})
// we need to separate calles because messages will mutate
parse.onCall(1).resolves({
delimiter: '|',
data: {
@@ -693,7 +740,7 @@ describe('DbUploader.vue import CSV', () => {
})
const newDb = {
createDb: sinon.stub().rejects(new Error('fail')),
importDb: sinon.stub().rejects(new Error('fail')),
createProgressCounter: sinon.stub().returns(1),
deleteProgressCounter: sinon.stub()
}
@@ -703,6 +750,7 @@ describe('DbUploader.vue import CSV', () => {
await csv.parse.returnValues[0]
await wrapper.vm.animationPromise
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-import').trigger('click')
await csv.parse.returnValues[1]
@@ -726,8 +774,7 @@ describe('DbUploader.vue import CSV', () => {
})
it('import final', async () => {
const parse = sinon.stub(csv, 'parse')
parse.onCall(0).resolves({
sinon.stub(csv, 'parse').resolves({
delimiter: '|',
data: {
columns: ['col1', 'col2'],
@@ -739,22 +786,9 @@ describe('DbUploader.vue import CSV', () => {
messages: []
})
parse.onCall(1).resolves({
delimiter: '|',
data: {
columns: ['col1', 'col2'],
values: [
[1, 'foo'],
[2, 'bar']
]
},
hasErrors: false,
messages: []
})
const schema = {}
const newDb = {
createDb: sinon.stub().resolves(schema),
importDb: sinon.stub().resolves(schema),
createProgressCounter: sinon.stub().returns(1),
deleteProgressCounter: sinon.stub()
}
@@ -764,6 +798,7 @@ describe('DbUploader.vue import CSV', () => {
await csv.parse.returnValues[0]
await wrapper.vm.animationPromise
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-import').trigger('click')
await csv.parse.returnValues[1]
@@ -781,8 +816,7 @@ describe('DbUploader.vue import CSV', () => {
})
it('import cancel', async () => {
const parse = sinon.stub(csv, 'parse')
parse.onCall(0).resolves({
sinon.stub(csv, 'parse').resolves({
delimiter: '|',
data: {
columns: ['col1', 'col2'],
@@ -794,24 +828,12 @@ describe('DbUploader.vue import CSV', () => {
messages: []
})
parse.onCall(1).resolves({
delimiter: '|',
data: {
columns: ['col1', 'col2'],
values: [
[1, 'foo'],
[2, 'bar']
]
},
hasErrors: false,
messages: []
})
const schema = {}
const newDb = {
createDb: sinon.stub().resolves(schema),
importDb: sinon.stub().resolves(schema),
createProgressCounter: sinon.stub().returns(1),
deleteProgressCounter: sinon.stub()
deleteProgressCounter: sinon.stub(),
shutDown: sinon.stub()
}
sinon.stub(database, 'getNewDatabase').returns(newDb)
@@ -819,6 +841,7 @@ describe('DbUploader.vue import CSV', () => {
await csv.parse.returnValues[0]
await wrapper.vm.animationPromise
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-import').trigger('click')
await csv.parse.returnValues[1]
@@ -831,6 +854,53 @@ describe('DbUploader.vue import CSV', () => {
expect(actions.addTab.called).to.equal(false)
expect(mutations.setCurrentTabId.called).to.equal(false)
expect($router.push.called).to.equal(false)
expect(newDb.shutDown.calledOnce).to.equal(true)
expect(wrapper.find('[data-modal="parse"]').exists()).to.equal(false)
})
it("doesn't open new tab when load db after importing CSV", async () => {
fu.getFileFromUser.onCall(0).resolves({ type: 'text/csv', name: 'foo.csv' })
fu.getFileFromUser.onCall(1).resolves({ type: 'application/x-sqlite3', name: 'bar.sqlite3' })
sinon.stub(csv, 'parse').resolves({
delimiter: '|',
data: {
columns: ['col1', 'col2'],
values: [
[1, 'foo']
]
},
hasErrors: false,
messages: []
})
const schema = {}
const newDb = {
importDb: sinon.stub().resolves(schema),
createProgressCounter: sinon.stub().returns(1),
deleteProgressCounter: sinon.stub(),
loadDb: sinon.stub().resolves()
}
sinon.stub(database, 'getNewDatabase').returns(newDb)
await wrapper.find('.drop-area').trigger('click')
await csv.parse.returnValues[0]
await wrapper.vm.animationPromise
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
await wrapper.find('#csv-import').trigger('click')
await csv.parse.returnValues[1]
await wrapper.vm.$nextTick()
await wrapper.find('#csv-finish').trigger('click')
expect(actions.addTab.calledOnce).to.equal(true)
await actions.addTab.returnValues[0]
expect(mutations.setCurrentTabId.calledOnceWith(state, newTabId)).to.equal(true)
await wrapper.find('.drop-area').trigger('click')
await newDb.loadDb.returnValues[0]
expect(actions.addTab.calledOnce).to.equal(true)
expect(mutations.setCurrentTabId.calledOnce).to.equal(true)
})
})

View File

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

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import sinon from 'sinon'
import csv from '@/csv'
import csv from '@/components/DbUploader/csv'
import Papa from 'papaparse'
describe('csv.js', () => {
@@ -15,7 +15,7 @@ describe('csv.js', () => {
{ id: 2, name: 'bar' }
],
meta: {
fields: ['id', 'name']
fields: ['id', 'name ']
}
}
expect(csv.getResult(source)).to.eql({

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { shallowMount } from '@vue/test-utils'
import LoadingIndicator from '@/components/LoadingIndicator.vue'
import LoadingIndicator from '@/components/LoadingIndicator'
describe('LoadingIndicator.vue', () => {
it('Calculates animation class', async () => {
@@ -19,6 +19,6 @@ describe('LoadingIndicator.vue', () => {
})
// The lendth of circle in the component is 50.24. If progress is 50% then resulting arc
// should be 25.12
expect(wrapper.find('.loader-svg.front').element.style.strokeDasharray).to.equal('25.12, 25.12')
expect(wrapper.find('.loader-svg.front').element.style.strokeDasharray).to.equal('25.12px, 25.12px')
})
})

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { shallowMount } from '@vue/test-utils'
import Logs from '@/components/Logs.vue'
import Logs from '@/components/Logs'
let place
describe('Logs.vue', () => {

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { shallowMount } from '@vue/test-utils'
import Splitpanes from '@/components/Splitpanes.vue'
import Splitpanes from '@/components/Splitpanes'
describe('Splitpanes.vue', () => {
it('renders correctly - vertical', () => {

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import sinon from 'sinon'
import splitter from '@/splitter'
import splitter from '@/components/Splitpanes/splitter'
describe('splitter.js', () => {
afterEach(() => {

View File

@@ -1,7 +1,7 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { mount } from '@vue/test-utils'
import Pager from '@/components/Pager.vue'
import Pager from '@/components/SqlTable/Pager'
describe('Pager.vue', () => {
afterEach(() => {

View File

@@ -2,14 +2,14 @@ import chai from 'chai'
import sinon from 'sinon'
import chaiAsPromised from 'chai-as-promised'
import initSqlJs from 'sql.js'
import Sql from '@/sql'
import Sql from '@/lib/database/_sql'
chai.use(chaiAsPromised)
const expect = chai.expect
chai.should()
const getSQL = initSqlJs()
describe('sql.js', () => {
describe('_sql.js', () => {
afterEach(() => {
sinon.restore()
})

View File

@@ -1,7 +1,7 @@
import { expect } from 'chai'
import dbUtils from '@/db.utils'
import dbUtils from '@/lib/database/_statements'
describe('db.utils.js', () => {
describe('_statements.js', () => {
it('generateChunks', () => {
const arr = ['1', '2', '3', '4', '5']
const size = 2

View File

@@ -2,7 +2,9 @@ import chai from 'chai'
import sinon from 'sinon'
import chaiAsPromised from 'chai-as-promised'
import initSqlJs from 'sql.js'
import database from '@/database.js'
import database from '@/lib/database'
import fu from '@/lib/utils/fileIo'
chai.use(chaiAsPromised)
const expect = chai.expect
chai.should()
@@ -32,8 +34,10 @@ describe('database.js', () => {
const data = tempDb.export()
const buffer = new Blob([data])
buffer.name = 'foo.sqlite'
const { schema } = await db.loadDb(buffer)
const { schema, dbName } = await db.loadDb(buffer)
expect(dbName).to.equal('foo')
expect(schema).to.have.lengthOf(1)
expect(schema[0].name).to.equal('test')
expect(schema[0].columns[0].name).to.equal('col1')
@@ -58,6 +62,7 @@ describe('database.js', () => {
const data = tempDb.export()
const buffer = new Blob([data])
buffer.name = 'foo.sqlite'
const { schema } = await db.loadDb(buffer)
expect(schema[0].name).to.equal('test_virtual')
@@ -74,6 +79,7 @@ describe('database.js', () => {
const data = tempDb.export()
const buffer = new Blob([data])
buffer.name = 'foo.sqlite'
sinon.stub(db.pw, 'postMessage').resolves({ error: new Error('foo') })
@@ -97,6 +103,7 @@ describe('database.js', () => {
const data = tempDb.export()
const buffer = new Blob([data])
buffer.name = 'foo.sqlite'
await db.loadDb(buffer)
const result = await db.execute('SELECT * from test limit 1; SELECT * from test;')
@@ -124,6 +131,7 @@ describe('database.js', () => {
const data = tempDb.export()
const buffer = new Blob([data])
buffer.name = 'foo.sqlite'
await db.loadDb(buffer)
await expect(db.execute('SELECT * from foo')).to.be.rejectedWith(/^no such table: foo$/)
})
@@ -138,7 +146,7 @@ describe('database.js', () => {
}
const progressHandler = sinon.spy()
const progressCounterId = db.createProgressCounter(progressHandler)
const { dbName, schema } = await db.createDb('foo', data, progressCounterId)
const { dbName, schema } = await db.importDb('foo', data, progressCounterId)
expect(dbName).to.equal('foo')
expect(schema).to.have.lengthOf(1)
expect(schema[0].name).to.equal('csv_import')
@@ -156,7 +164,7 @@ describe('database.js', () => {
expect(progressHandler.secondCall.calledWith(100)).to.equal(true)
})
it('createDb throws errors', async () => {
it('importDb throws errors', async () => {
const data = {
columns: ['id', 'name'],
values: [
@@ -166,7 +174,7 @@ describe('database.js', () => {
}
const progressHandler = sinon.stub()
const progressCounterId = db.createProgressCounter(progressHandler)
await expect(db.createDb('foo', data, progressCounterId))
await expect(db.importDb('foo', data, progressCounterId))
.to.be.rejectedWith('column index out of range')
})
@@ -204,4 +212,34 @@ describe('database.js', () => {
db.deleteProgressCounter(firstId)
expect(db.importProgresses[firstId]).to.equal(undefined)
})
it('exports db', async () => {
sinon.stub(fu, 'exportToFile').resolves()
// create db with table foo
const stmt = `
CREATE TABLE foo(id, name);
INSERT INTO foo VALUES (1, 'Harry Potter')
`
let result = await db.execute(stmt)
// export db to a file
await db.export('fooDb.sqlite')
expect(fu.exportToFile.called).to.equal(true)
// get data from export
const data = fu.exportToFile.getCall(0).args[0]
const file = new Blob([data])
file.name = 'fooDb.sqlite'
// loadDb from exported data
const anotherDb = database.getNewDatabase()
await anotherDb.loadDb(file)
// check that new db works and has the same table and data
result = await anotherDb.execute('SELECT * from foo')
expect(result.columns).to.eql(['id', 'name'])
expect(result.values).to.have.lengthOf(1)
expect(result.values[0]).to.eql([1, 'Harry Potter'])
})
})

View File

@@ -1,7 +1,7 @@
import { expect } from 'chai'
import sinon from 'sinon'
import storedQueries from '@/storedQueries.js'
import fu from '@/file.utils'
import storedQueries from '@/lib/storedQueries'
import fu from '@/lib/utils/fileIo'
describe('storedQueries.js', () => {
beforeEach(() => {

View File

@@ -1,8 +1,8 @@
import { expect } from 'chai'
import fu from '@/file.utils'
import fu from '@/lib/utils/fileIo'
import sinon from 'sinon'
describe('file.utils.js', () => {
describe('fileIo.js', () => {
afterEach(() => {
sinon.restore()
})
@@ -57,9 +57,9 @@ describe('file.utils.js', () => {
expect(URL.revokeObjectURL.calledOnceWith(url)).to.equal(true)
})
it('importFile', () => {
it('importFile', async () => {
const spyInput = document.createElement('input')
sinon.spy(spyInput, 'click')
sinon.stub(spyInput, 'click')
const blob = new Blob(['foo'])
Object.defineProperty(spyInput, 'files', {
@@ -71,14 +71,12 @@ describe('file.utils.js', () => {
setTimeout(() => { spyInput.dispatchEvent(new Event('change')) })
return fu.importFile()
.then((data) => {
expect(data).to.equal('foo')
expect(document.createElement.calledOnceWith('input')).to.equal(true)
expect(spyInput.type).to.equal('file')
expect(spyInput.accept).to.equal('.json')
expect(spyInput.click.calledOnce).to.equal(true)
})
const data = await fu.importFile()
expect(data).to.equal('foo')
expect(document.createElement.calledOnceWith('input')).to.equal(true)
expect(spyInput.type).to.equal('file')
expect(spyInput.accept).to.equal('.json')
expect(spyInput.click.calledOnce).to.equal(true)
})
it('readFile', () => {
@@ -107,4 +105,27 @@ describe('file.utils.js', () => {
const blob = new Blob(['foo'])
await expect(fu.readAsArrayBuffer(blob)).to.be.rejectedWith('Problem parsing input file.')
})
it('isDatabase', () => {
let file = { type: 'application/vnd.sqlite3' }
expect(fu.isDatabase(file)).to.equal(true)
file = { type: 'application/x-sqlite3' }
expect(fu.isDatabase(file)).to.equal(true)
file = { type: '', name: 'test.db' }
expect(fu.isDatabase(file)).to.equal(true)
file = { type: '', name: 'test.sqlite' }
expect(fu.isDatabase(file)).to.equal(true)
file = { type: '', name: 'test.sqlite3' }
expect(fu.isDatabase(file)).to.equal(true)
file = { type: '', name: 'test.csv' }
expect(fu.isDatabase(file)).to.equal(false)
file = { type: 'text', name: 'test.db' }
expect(fu.isDatabase(file)).to.equal(false)
})
})

View File

@@ -1,23 +1,23 @@
import { expect } from 'chai'
import time from '@/time'
import time from '@/lib/utils/time'
describe('time.js', () => {
it('getPeriod', () => {
// 1.01.2021 13:00:00 000
let start = new Date(2021, 0, 1, 13, 0, 0, 0)
// 3.01.2021 22:15:20 500
let end = new Date(2021, 0, 3, 22, 15, 20, 500)
// 1.01.2021 13:01:00 500
let end = new Date(2021, 0, 1, 13, 1, 0, 500)
expect(time.getPeriod(start, end)).to.equal('2 d 9 h 15 m 20 s 500 ms')
expect(time.getPeriod(start, end)).to.equal('60.500s')
// 1.01.2021 13:00:00 000
start = new Date(2021, 0, 1, 13, 0, 0, 0)
// 1.01.2021 22:00:20 000
end = new Date(2021, 0, 1, 22, 0, 20, 0)
// 1.01.2021 13:00:20 500
end = new Date(2021, 0, 1, 13, 0, 20, 500)
expect(time.getPeriod(start, end)).to.equal('9 h 20 s')
expect(time.getPeriod(start, end)).to.equal('20.500s')
// 1.01.2021 13:00:00 000
start = new Date(2021, 0, 1, 13, 0, 0, 0)
@@ -25,6 +25,6 @@ describe('time.js', () => {
// 1.01.2021 13:00:00 45
end = new Date(2021, 0, 1, 13, 0, 0, 45)
expect(time.getPeriod(start, end)).to.equal('45 ms')
expect(time.getPeriod(start, end)).to.equal('0.045s')
})
})

View File

@@ -0,0 +1,67 @@
import { expect } from 'chai'
import actions from '@/store/actions'
const { addTab } = actions
describe('actions', () => {
it('addTab adds new blank tab', async () => {
const state = {
tabs: [],
untitledLastIndex: 0
}
const id = await addTab({ state })
expect(state.tabs[0].id).to.eql(id)
expect(state.tabs[0].name).to.eql(null)
expect(state.tabs[0].tempName).to.eql('Untitled')
expect(state.tabs[0].isUnsaved).to.eql(true)
expect(state.untitledLastIndex).to.equal(1)
})
it('addTab adds tab from saved queries', async () => {
const state = {
tabs: [],
untitledLastIndex: 0
}
const tab = {
id: 1,
name: 'test',
tempName: null,
query: 'SELECT * from foo',
chart: {},
isUnsaved: false
}
await addTab({ state }, tab)
expect(state.tabs[0]).to.eql(tab)
expect(state.untitledLastIndex).to.equal(0)
})
it("addTab doesn't add anything when the query is already opened", async () => {
const tab1 = {
id: 1,
name: 'test',
tempName: null,
query: 'SELECT * from foo',
chart: {},
isUnsaved: false
}
const tab2 = {
id: 2,
name: 'bar',
tempName: null,
query: 'SELECT * from bar',
chart: {},
isUnsaved: false
}
const state = {
tabs: [tab1, tab2],
untitledLastIndex: 0
}
await addTab({ state }, tab1)
expect(state.tabs).to.have.lengthOf(2)
expect(state.untitledLastIndex).to.equal(0)
})
})

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { mutations, actions } from '@/store'
import mutations from '@/store/mutations'
const {
saveSchema,
updateTab,
@@ -11,8 +11,6 @@ const {
setDb
} = mutations
const { addTab } = actions
describe('mutations', () => {
it('setDb', () => {
const state = {
@@ -376,66 +374,3 @@ describe('mutations', () => {
expect(state.predefinedQueries).to.eql(queries)
})
})
describe('actions', () => {
it('addTab adds new blank tab', async () => {
const state = {
tabs: [],
untitledLastIndex: 0
}
const id = await addTab({ state })
expect(state.tabs[0].id).to.eql(id)
expect(state.tabs[0].name).to.eql(null)
expect(state.tabs[0].tempName).to.eql('Untitled')
expect(state.tabs[0].isUnsaved).to.eql(true)
expect(state.untitledLastIndex).to.equal(1)
})
it('addTab adds tab from saved queries', async () => {
const state = {
tabs: [],
untitledLastIndex: 0
}
const tab = {
id: 1,
name: 'test',
tempName: null,
query: 'SELECT * from foo',
chart: {},
isUnsaved: false
}
await addTab({ state }, tab)
expect(state.tabs[0]).to.eql(tab)
expect(state.untitledLastIndex).to.equal(0)
})
it("addTab doesn't add anything when the query is already opened", async () => {
const tab1 = {
id: 1,
name: 'test',
tempName: null,
query: 'SELECT * from foo',
chart: {},
isUnsaved: false
}
const tab2 = {
id: 2,
name: 'bar',
tempName: null,
query: 'SELECT * from bar',
chart: {},
isUnsaved: false
}
const state = {
tabs: [tab1, tab2],
untitledLastIndex: 0
}
await addTab({ state }, tab1)
expect(state.tabs).to.have.lengthOf(2)
expect(state.untitledLastIndex).to.equal(0)
})
})

View File

@@ -1,8 +1,8 @@
import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import tooltipMixin from '@/mixins/tooltips.js'
import tooltipMixin from '@/tooltipMixin'
describe('tooltips.js', () => {
describe('tooltipMixin.js', () => {
it('tooltip is hidden in initial', () => {
const component = {
template: '<div :style="tooltipStyle"></div>',

View File

@@ -2,8 +2,8 @@ import { expect } from 'chai'
import sinon from 'sinon'
import { mount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import Schema from '@/components/Schema.vue'
import TableDescription from '@/components/TableDescription.vue'
import Schema from '@/views/Main/Editor/Schema'
import TableDescription from '@/views/Main/Editor/Schema/TableDescription'
const localVue = createLocalVue()
localVue.use(Vuex)
@@ -98,4 +98,18 @@ describe('Schema.vue', () => {
expect(tables.at(1).vm.name).to.equal('bar')
expect(tables.at(2).vm.name).to.equal('foobar')
})
it('exports db', async () => {
const state = {
dbName: 'fooDB',
db: {
export: sinon.stub().resolves()
}
}
const store = new Vuex.Store({ state })
const wrapper = mount(Schema, { store, localVue })
await wrapper.findComponent({ name: 'export-icon' }).trigger('click')
expect(state.db.export.calledOnceWith('fooDB'))
})
})

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { shallowMount } from '@vue/test-utils'
import TableDescription from '@/components/TableDescription.vue'
import TableDescription from '@/views/Main/Editor/Schema/TableDescription'
describe('TableDescription.vue', () => {
it('Initially the columns are hidden and table name is rendered', () => {
@@ -19,6 +19,7 @@ describe('TableDescription.vue', () => {
it('Columns are visible and correct when click on table name', async () => {
const wrapper = shallowMount(TableDescription, {
stubs: ['router-link'],
propsData: {
name: 'Test table',
columns: [

View File

@@ -1,8 +1,8 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { mount, shallowMount } from '@vue/test-utils'
import Chart from '@/components/Chart.vue'
import chart from '@/chart.js'
import Chart from '@/views/Main/Editor/Tabs/Tab/Chart'
import chartHelper from '@/views/Main/Editor/Tabs/Tab/Chart/chartHelper'
import * as dereference from 'react-chart-editor/lib/lib/dereference'
describe('Chart.vue', () => {
@@ -14,7 +14,7 @@ describe('Chart.vue', () => {
// mount the component
const wrapper = shallowMount(Chart)
const vm = wrapper.vm
const stub = sinon.stub(chart, 'getChartStateForSave').returns('result')
const stub = sinon.stub(chartHelper, 'getChartStateForSave').returns('result')
const chartData = vm.getChartStateForSave()
expect(stub.calledOnceWith(vm.state, vm.dataSources)).to.equal(true)
expect(chartData).to.equal('result')

View File

@@ -1,9 +1,9 @@
import { expect } from 'chai'
import sinon from 'sinon'
import * as chart from '@/chart'
import * as chartHelper from '@/views/Main/Editor/Tabs/Tab/Chart/chartHelper'
import * as dereference from 'react-chart-editor/lib/lib/dereference'
describe('chart.js', () => {
describe('chartHelper.js', () => {
afterEach(() => {
sinon.restore()
})
@@ -17,7 +17,7 @@ describe('chart.js', () => {
]
}
const ds = chart.getDataSourcesFromSqlResult(sqlResult)
const ds = chartHelper.getDataSourcesFromSqlResult(sqlResult)
expect(ds).to.eql({
id: [1, 2],
name: ['foo', 'bar']
@@ -30,7 +30,7 @@ describe('chart.js', () => {
name: ['foo', 'bar']
}
const ds = chart.getOptionsFromDataSources(dataSources)
const ds = chartHelper.getOptionsFromDataSources(dataSources)
expect(ds).to.eql([
{ value: 'id', label: 'id' },
{ value: 'name', label: 'name' }
@@ -53,7 +53,7 @@ describe('chart.js', () => {
sinon.stub(dereference, 'default')
sinon.spy(JSON, 'parse')
const ds = chart.getChartStateForSave(state, dataSources)
const ds = chartHelper.getChartStateForSave(state, dataSources)
expect(dereference.default.calledOnce).to.equal(true)

View File

@@ -1,11 +1,12 @@
import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import SqlEditor from '@/components/SqlEditor.vue'
import SqlEditor from '@/views/Main/Editor/Tabs/Tab/SqlEditor'
describe('SqlEditor.vue', () => {
it('Emits input event when a query is changed', async () => {
const wrapper = mount(SqlEditor)
await wrapper.findComponent({ name: 'codemirror' }).vm.$emit('input', 'SELECT * FROM foo')
expect(wrapper.emitted('input')[0]).to.eql(['SELECT * FROM foo'])
// Take a pause to keep proper state in debounced '@/views/Main/Editor/Tabs/Tab/SqlEditor/hint'
})
})

View File

@@ -1,7 +1,7 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { state } from '@/store'
import hint, { getHints } from '@/hint'
import state from '@/store/state'
import showHint, { getHints } from '@/views/Main/Editor/Tabs/Tab/SqlEditor/hint'
import CM from 'codemirror'
describe('hint.js', () => {
@@ -40,15 +40,43 @@ describe('hint.js', () => {
getCursor: sinon.stub()
}
const clock = sinon.useFakeTimers()
hint.show(editor)
clock.tick(500)
showHint(editor)
expect(CM.showHint.called).to.equal(true)
expect(CM.showHint.firstCall.args[2].tables).to.eql({
foo: ['fooId', 'name'],
bar: ['barId']
})
expect(CM.showHint.firstCall.args[2].defaultTable).to.equal(null)
})
it('Add default table if there is only one table in schema', () => {
// mock store state
const schema = [
{
name: 'foo',
columns: [
{ name: 'fooId', type: 'INTEGER' },
{ name: 'name', type: 'NVARCHAR(20)' }
]
}
]
sinon.stub(state, 'schema').value(schema)
// mock showHint and editor
sinon.stub(CM, 'showHint')
const editor = {
getTokenAt () {
return {
string: 'SELECT',
type: 'keyword'
}
},
getCursor: sinon.stub()
}
showHint(editor)
expect(CM.showHint.firstCall.args[2].defaultTable).to.equal('foo')
})
it("Doesn't show hint when in string or space, or ';'", () => {
@@ -64,10 +92,7 @@ describe('hint.js', () => {
getCursor: sinon.stub()
}
const clock = sinon.useFakeTimers()
hint.show(editor)
clock.tick(500)
showHint(editor)
expect(CM.showHint.called).to.equal(false)
})
@@ -84,10 +109,7 @@ describe('hint.js', () => {
getCursor: sinon.stub()
}
const clock = sinon.useFakeTimers()
hint.show(editor)
clock.tick(500)
showHint(editor)
expect(CM.showHint.called).to.equal(false)
})
@@ -104,10 +126,7 @@ describe('hint.js', () => {
getCursor: sinon.stub()
}
const clock = sinon.useFakeTimers()
hint.show(editor)
clock.tick(500)
showHint(editor)
expect(CM.showHint.called).to.equal(false)
})
@@ -185,10 +204,7 @@ describe('hint.js', () => {
getCursor: sinon.stub()
}
const clock = sinon.useFakeTimers()
hint.show(editor)
clock.tick(500)
showHint(editor)
expect(CM.showHint.called).to.equal(true)
expect(CM.showHint.firstCall.args[2].tables).to.eql({})
})

View File

@@ -1,11 +1,15 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { mount } from '@vue/test-utils'
import { mutations } from '@/store'
import mutations from '@/store/mutations'
import Vuex from 'vuex'
import Tab from '@/components/Tab.vue'
import Tab from '@/views/Main/Editor/Tabs/Tab'
describe('Tab.vue', () => {
afterEach(() => {
sinon.restore()
})
it('Renders passed query', () => {
// mock store state
const state = {
@@ -142,7 +146,7 @@ describe('Tab.vue', () => {
expect(state.tabs[0].isUnsaved).to.equal(true)
})
it('Shows .result-in-progress message when executing query', (done) => {
it('Shows .result-in-progress message when executing query', async () => {
// mock store state
const state = {
currentTabId: 1,
@@ -167,11 +171,9 @@ describe('Tab.vue', () => {
})
wrapper.vm.execute()
wrapper.vm.$nextTick(() => {
expect(wrapper.find('.table-view .result-before').isVisible()).to.equal(false)
expect(wrapper.find('.table-view .result-in-progress').isVisible()).to.equal(true)
})
done()
await wrapper.vm.$nextTick()
expect(wrapper.find('.table-view .result-before').isVisible()).to.equal(false)
expect(wrapper.find('.table-view .result-in-progress').isVisible()).to.equal(true)
})
it('Shows error when executing query ends with error', async () => {
@@ -179,7 +181,7 @@ describe('Tab.vue', () => {
const state = {
currentTabId: 1,
db: {
execute () { return Promise.reject(new Error('There is no table foo')) }
execute: sinon.stub().rejects(new Error('There is no table foo'))
}
}
@@ -200,21 +202,12 @@ describe('Tab.vue', () => {
await wrapper.vm.execute()
expect(wrapper.find('.table-view .result-before').isVisible()).to.equal(false)
expect(wrapper.find('.table-view .result-in-progress').isVisible()).to.equal(false)
expect(wrapper.find('.table-preview.error').isVisible()).to.equal(true)
expect(wrapper.find('.table-preview.error').text()).to.include('There is no table foo')
expect(wrapper.find('.table-view .result-in-progress').exists()).to.equal(false)
expect(wrapper.findComponent({ name: 'logs' }).isVisible()).to.equal(true)
expect(wrapper.findComponent({ name: 'logs' }).text()).to.include('There is no table foo')
})
it('Passes result to sql-table component', async () => {
// mock store state
const state = {
currentTabId: 1,
db: {
execute () { return Promise.resolve(result) }
}
}
const store = new Vuex.Store({ state, mutations })
const result = {
columns: ['id', 'name'],
values: [
@@ -222,6 +215,16 @@ describe('Tab.vue', () => {
[2, 'bar']
]
}
// mock store state
const state = {
currentTabId: 1,
db: {
execute: sinon.stub().resolves(result),
getSchema: sinon.stub().resolves({ dbName: '', schema: [] })
}
}
const store = new Vuex.Store({ state, mutations })
// mount the component
const wrapper = mount(Tab, {
@@ -239,8 +242,64 @@ describe('Tab.vue', () => {
await wrapper.vm.execute()
expect(wrapper.find('.table-view .result-before').isVisible()).to.equal(false)
expect(wrapper.find('.table-view .result-in-progress').isVisible()).to.equal(false)
expect(wrapper.find('.table-preview.error').isVisible()).to.equal(false)
expect(wrapper.find('.table-view .result-in-progress').exists()).to.equal(false)
expect(wrapper.findComponent({ name: 'logs' }).exists()).to.equal(false)
expect(wrapper.findComponent({ name: 'SqlTable' }).vm.dataSet).to.eql(result)
})
it('Updates schema after query execution', async () => {
const result = {
columns: ['id', 'name'],
values: []
}
const newSchema = {
dbName: 'fooDb',
schema: [
{
name: 'foo',
columns: [
{ name: 'id', type: 'INTEGER' },
{ name: 'title', type: 'NVARCHAR(30)' }
]
},
{
name: 'bar',
columns: [
{ name: 'a', type: 'N/A' },
{ name: 'b', type: 'N/A' }
]
}
]
}
// mock store state
const state = {
currentTabId: 1,
dbName: 'fooDb',
db: {
execute: sinon.stub().resolves(result),
getSchema: sinon.stub().resolves(newSchema)
}
}
sinon.spy(mutations, 'saveSchema')
const store = new Vuex.Store({ state, mutations })
// mount the component
const wrapper = mount(Tab, {
store,
stubs: ['chart'],
propsData: {
id: 1,
initName: 'foo',
initQuery: 'SELECT * FROM foo; CREATE TABLE bar(a,b);',
initChart: [],
tabIndex: 0,
isPredefined: false
}
})
await wrapper.vm.execute()
expect(state.db.getSchema.calledOnceWith('fooDb')).to.equal(true)
expect(mutations.saveSchema.calledOnceWith(state, newSchema)).to.equal(true)
})
})

View File

@@ -1,11 +1,15 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { shallowMount, mount, createWrapper } from '@vue/test-utils'
import { mutations } from '@/store'
import mutations from '@/store/mutations'
import Vuex from 'vuex'
import Tabs from '@/components/Tabs.vue'
import Tabs from '@/views/Main/Editor/Tabs'
describe('Tabs.vue', () => {
afterEach(() => {
sinon.restore()
})
it('Renders start guide when there is no opened tabs', () => {
// mock store state
const state = {
@@ -14,7 +18,10 @@ describe('Tabs.vue', () => {
const store = new Vuex.Store({ state })
// mount the component
const wrapper = shallowMount(Tabs, { store })
const wrapper = shallowMount(Tabs, {
store,
stubs: ['router-link']
})
// check start-guide visibility
expect(wrapper.find('#start-guide').isVisible()).to.equal(true)
@@ -32,7 +39,10 @@ describe('Tabs.vue', () => {
const store = new Vuex.Store({ state })
// mount the component
const wrapper = shallowMount(Tabs, { store })
const wrapper = shallowMount(Tabs, {
store,
stubs: ['router-link']
})
// check start-guide visibility
expect(wrapper.find('#start-guide').isVisible()).to.equal(false)
@@ -64,7 +74,10 @@ describe('Tabs.vue', () => {
const store = new Vuex.Store({ state, mutations })
// mount the component
const wrapper = shallowMount(Tabs, { store })
const wrapper = shallowMount(Tabs, {
store,
stubs: ['router-link']
})
// click on the first tab
const firstTab = wrapper.findAll('.tab').at(0)
@@ -90,7 +103,10 @@ describe('Tabs.vue', () => {
const store = new Vuex.Store({ state, mutations })
// mount the component
const wrapper = mount(Tabs, { store, stubs: ['router-link'] })
const wrapper = mount(Tabs, {
store,
stubs: ['router-link']
})
// click on the close icon of the first tab
const firstTabCloseIcon = wrapper.findAll('.tab').at(0).find('.close-icon')
@@ -118,7 +134,10 @@ describe('Tabs.vue', () => {
const store = new Vuex.Store({ state, mutations })
// mount the component
const wrapper = mount(Tabs, { store, stubs: ['router-link'] })
const wrapper = mount(Tabs, {
store,
stubs: ['router-link']
})
// click on the close icon of the second tab
const secondTabCloseIcon = wrapper.findAll('.tab').at(1).find('.close-icon')
@@ -156,7 +175,10 @@ describe('Tabs.vue', () => {
const store = new Vuex.Store({ state, mutations })
// mount the component
const wrapper = mount(Tabs, { store, stubs: ['router-link'] })
const wrapper = mount(Tabs, {
store,
stubs: ['router-link']
})
// click on the close icon of the second tab
const secondTabCloseIcon = wrapper.findAll('.tab').at(1).find('.close-icon')
@@ -198,7 +220,10 @@ describe('Tabs.vue', () => {
const store = new Vuex.Store({ state, mutations })
// mount the component
const wrapper = mount(Tabs, { store, stubs: ['router-link'] })
const wrapper = mount(Tabs, {
store,
stubs: ['router-link']
})
// click on the close icon of the second tab
const secondTabCloseIcon = wrapper.findAll('.tab').at(1).find('.close-icon')
@@ -243,7 +268,10 @@ describe('Tabs.vue', () => {
const store = new Vuex.Store({ state, mutations })
// mount the component
const wrapper = shallowMount(Tabs, { store })
const wrapper = shallowMount(Tabs, {
store,
stubs: ['router-link']
})
const event = new Event('beforeunload')
sinon.spy(event, 'preventDefault')
@@ -264,7 +292,10 @@ describe('Tabs.vue', () => {
const store = new Vuex.Store({ state, mutations })
// mount the component
const wrapper = shallowMount(Tabs, { store })
const wrapper = shallowMount(Tabs, {
store,
stubs: ['router-link']
})
const event = new Event('beforeunload')
sinon.spy(event, 'preventDefault')

View File

@@ -2,8 +2,8 @@ import { expect } from 'chai'
import sinon from 'sinon'
import { mount, shallowMount, createWrapper } from '@vue/test-utils'
import Vuex from 'vuex'
import MainMenu from '@/components/MainMenu.vue'
import storedQueries from '@/storedQueries.js'
import MainMenu from '@/views/Main/MainMenu'
import storedQueries from '@/lib/storedQueries'
let wrapper = null

View File

@@ -2,10 +2,10 @@ import { expect } from 'chai'
import sinon from 'sinon'
import { mount, shallowMount } from '@vue/test-utils'
import Vuex from 'vuex'
import MyQueries from '@/views/MyQueries.vue'
import storedQueries from '@/storedQueries'
import { mutations } from '@/store'
import fu from '@/file.utils'
import MyQueries from '@/views/Main/MyQueries'
import storedQueries from '@/lib/storedQueries'
import mutations from '@/store/mutations'
import fu from '@/lib/utils/fileIo'
describe('MyQueries.vue', () => {
afterEach(() => {

View File

@@ -1,4 +1,5 @@
const CopyPlugin = require('copy-webpack-plugin')
const WorkboxPlugin = require('workbox-webpack-plugin')
module.exports = {
publicPath: '',
@@ -9,7 +10,13 @@ module.exports = {
// It is important that we do not change its name, and that it is in the same folder as the js
{ from: 'node_modules/sql.js/dist/sql-wasm.wasm', to: 'js/' },
{ from: 'LICENSE', to: './' }
])
]),
new WorkboxPlugin.GenerateSW({
exclude: [/\.map$/, 'LICENSE', 'queries.json'],
clientsClaim: true,
skipWaiting: false,
maximumFileSizeToCacheInBytes: 40000000
})
]
},
chainWebpack: config => {
@@ -24,11 +31,11 @@ module.exports = {
config.module
.rule('worker')
.test(/\.worker\.js$/)
.test(/worker\.js$/)
.use('worker-loader')
.loader('worker-loader')
.end()
config.module.rule('js').exclude.add(/\.worker\.js$/)
config.module.rule('js').exclude.add(/worker\.js$/)
}
}