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

26 Commits

Author SHA1 Message Date
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
47 changed files with 3519 additions and 1333 deletions

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,21 @@
# 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.
## Quickstart
The latest release of sqliteviz is deployed on GitHub Pages at [lana-k.github.io/sqliteviz][6].
### Choose a databese or CSV file
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 +31,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,

4056
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

44
registerServiceWorker.js Normal file
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
}
})
})
}

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

@@ -1,5 +1,5 @@
<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
@@ -139,6 +139,15 @@ import ChangeDbIcon from '@/components/svg/changeDb'
import time from '@/time'
import database from '@/database'
const csvMimeTypes = [
'text/csv',
'text/x-csv',
'application/x-csv',
'application/csv',
'text/x-comma-separated-values',
'text/comma-separated-values'
]
export default {
name: 'DbUploader',
props: {
@@ -149,6 +158,11 @@ export default {
validator: (value) => {
return ['regular', 'illustrated', 'small'].includes(value)
}
},
width: {
type: String,
required: false,
default: 'unset'
}
},
components: {
@@ -219,6 +233,7 @@ export default {
this.$modal.hide('parse')
const tabId = await this.$store.dispatch('addTab', { query: 'select * from csv_import' })
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.createDb(name, parseResult.data, progressCounterId)
end = new Date()
// Inform about import success
@@ -365,7 +381,7 @@ export default {
async checkFile (file) {
this.state = 'drop'
if (file.type === 'text/csv') {
if (csvMimeTypes.includes(file.type)) {
this.file = file
this.header = true
this.quoteChar = '"'

View File

@@ -1,5 +1,5 @@
<template>
<svg :class="['svg-container', animationClass ]" height="20" width="20" viewBox="0 0 20 20">
<svg :class="animationClass" height="20" width="20" viewBox="0 0 20 20">
<circle
class="loader-svg bg"
cx="10"
@@ -24,7 +24,7 @@ export default {
circleProgress () {
const dash = (50.24 * this.progress) / 100
const space = 50.24 - dash
return `${dash}, ${space}`
return `${dash}px, ${space}px`
},
animationClass () {
return this.progress === undefined ? 'loading' : 'progress'
@@ -48,22 +48,22 @@ export default {
}
.loading .loader-svg.front {
stroke-dasharray: 40.24;
stroke-dasharray: 40.24px;
animation: fill-animation-loading 1s cubic-bezier(1,1,1,1) 0s infinite;
}
@keyframes fill-animation-loading {
0% {
stroke-dasharray: 10 40.24;
stroke-dasharray: 10px 40.24px;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 25.12;
stroke-dashoffset: 25.12;
stroke-dasharray: 25.12px;
stroke-dashoffset: 25.12px;
}
100% {
stroke-dasharray: 10 40.24 ;
stroke-dashoffset: 50.24;
stroke-dasharray: 10px 40.24px;
stroke-dashoffset: 50.24px;
}
}

View File

@@ -8,7 +8,8 @@
<tree-chevron :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
@@ -26,6 +27,7 @@ import TableDescription from '@/components/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>
@@ -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

@@ -108,8 +108,11 @@ 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 + ';')
this.result = await state.db.execute(this.query + ';')
const schema = await state.db.getSchema(state.dbName)
this.$store.commit('saveSchema', schema)
} catch (err) {
this.error = err
}

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

@@ -33,7 +33,7 @@ export default {
<style scoped>
.icon {
vertical-align: middle;
display: block;
margin: 0 12px;
}
.icon:hover path {

View File

@@ -33,7 +33,7 @@ export default {
<style scoped>
.icon {
vertical-align: middle;
display: block;
margin: 0 12px;
}

View File

@@ -15,10 +15,10 @@
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>
@@ -27,14 +27,16 @@ import tooltipMixin from '@/mixins/tooltips'
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

@@ -33,7 +33,7 @@ export default {
<style scoped>
.icon {
vertical-align: middle;
display: block;
margin: 0 12px;
}

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

@@ -59,7 +59,7 @@ class Database {
})
if (result.error) {
throw result.error
throw new Error(result.error)
}
return await this.getSchema(name)
@@ -70,10 +70,10 @@ class Database {
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)
return this.getSchema(file.name.replace(/\.[^.]+$/, ''))
}
async getSchema (name) {
@@ -103,11 +103,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) {

View File

@@ -23,7 +23,7 @@ function processMsg (sql) {
function onError (error) {
return {
error
error: error.message
}
}

View File

@@ -25,6 +25,10 @@ const hintOptions = {
}
return tables
},
get defaultTable () {
const schema = store.state.schema
return schema.length === 1 ? schema[0].name : null
},
completeSingle: false,
completeOnSingleClick: true,
alignWithWord: false

View File

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

@@ -11,7 +11,7 @@
<div class="warning">
Database is not loaded. Queries cant be run without database.
</div>
<db-uploader id="db-uploader"/>
<db-uploader id="db-uploader" width="100%"/>
</div>
</template>
<template #right-pane>
@@ -64,10 +64,6 @@ export default {
box-sizing: border-box;
}
>>> .db-uploader-container {
width: 100%;
}
>>>.drop-area {
padding: 0 15px;
}

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

@@ -16,7 +16,8 @@ describe('DbUploader.vue', () => {
// mock store state and mutations
state = {}
mutations = {
saveSchema: sinon.stub()
saveSchema: sinon.stub(),
setDb: sinon.stub()
}
store = new Vuex.Store({ state, mutations })
})
@@ -131,7 +132,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()
@@ -563,7 +564,8 @@ 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)
createProgressCounter: sinon.stub().returns(1),
deleteProgressCounter: sinon.stub()
}
sinon.stub(database, 'getNewDatabase').returns(newDb)
@@ -596,6 +598,7 @@ 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.createDb.getCall(0).args[0]).to.equal('foo') // file name
// After resolving - loading indicator is not shown
await resolveImport()
@@ -811,7 +814,8 @@ describe('DbUploader.vue import CSV', () => {
const newDb = {
createDb: sinon.stub().resolves(schema),
createProgressCounter: sinon.stub().returns(1),
deleteProgressCounter: sinon.stub()
deleteProgressCounter: sinon.stub(),
shutDown: sinon.stub()
}
sinon.stub(database, 'getNewDatabase').returns(newDb)
@@ -831,6 +835,52 @@ 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 = {
createDb: 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.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

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

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

@@ -6,6 +6,10 @@ import Vuex from 'vuex'
import Tab from '@/components/Tab.vue'
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'))
}
}
@@ -206,15 +208,6 @@ describe('Tab.vue', () => {
})
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, {
@@ -243,4 +246,60 @@ describe('Tab.vue', () => {
expect(wrapper.find('.table-preview.error').isVisible()).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

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

@@ -6,6 +6,10 @@ import Vuex from 'vuex'
import Tabs from '@/components/Tabs.vue'
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

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

@@ -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 '@/database'
import fu from '@/file.utils'
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$/)
})
@@ -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

@@ -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', () => {

View File

@@ -49,6 +49,39 @@ describe('hint.js', () => {
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()
}
const clock = sinon.useFakeTimers()
hint.show(editor)
clock.tick(500)
expect(CM.showHint.firstCall.args[2].defaultTable).to.equal('foo')
})
it("Doesn't show hint when in string or space, or ';'", () => {

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 => {