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

Loading remote database and inquiries #109

This commit is contained in:
lana-k
2023-06-05 22:31:39 +02:00
parent 998e8d66f7
commit 0044d82b6f
15 changed files with 585 additions and 11 deletions

13
package-lock.json generated
View File

@@ -50,6 +50,7 @@
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^6.2.2",
"flush-promises": "^1.0.2",
"karma": "^3.1.4",
"karma-firefox-launcher": "^2.1.0",
"karma-webpack": "^4.0.2",
@@ -10090,6 +10091,12 @@
"resolved": "https://registry.npmjs.org/flip-pixels/-/flip-pixels-1.0.2.tgz",
"integrity": "sha512-oXbJGbjDnfJRWPC7Va38EFhd+A8JWE5/hCiKcK8qjCdbLj9DTpsq6MEudwpRTH+V4qq+Jw7d3pUgQdSr3x3mTA=="
},
"node_modules/flush-promises": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/flush-promises/-/flush-promises-1.0.2.tgz",
"integrity": "sha512-G0sYfLQERwKz4+4iOZYQEZVpOt9zQrlItIxQAAYAWpfby3gbHrx0osCHz5RLl/XoXevXk0xoN4hDFky/VV9TrA==",
"dev": true
},
"node_modules/flush-write-stream": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz",
@@ -33658,6 +33665,12 @@
"resolved": "https://registry.npmjs.org/flip-pixels/-/flip-pixels-1.0.2.tgz",
"integrity": "sha512-oXbJGbjDnfJRWPC7Va38EFhd+A8JWE5/hCiKcK8qjCdbLj9DTpsq6MEudwpRTH+V4qq+Jw7d3pUgQdSr3x3mTA=="
},
"flush-promises": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/flush-promises/-/flush-promises-1.0.2.tgz",
"integrity": "sha512-G0sYfLQERwKz4+4iOZYQEZVpOt9zQrlItIxQAAYAWpfby3gbHrx0osCHz5RLl/XoXevXk0xoN4hDFky/VV9TrA==",
"dev": true
},
"flush-write-stream": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz",

View File

@@ -51,6 +51,7 @@
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^6.2.2",
"flush-promises": "^1.0.2",
"karma": "^3.1.4",
"karma-firefox-launcher": "^2.1.0",
"karma-webpack": "^4.0.2",

View File

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

View File

@@ -77,7 +77,7 @@ class Database {
}
this.dbName = file ? fu.getFileName(file) : 'database'
this.refreshSchema()
await this.refreshSchema()
events.send('database.import', file ? file.size : 0, {
from: file ? 'sqlite' : 'none',

View File

@@ -22,6 +22,7 @@ export default class Tab {
table: 'bottom',
dataView: 'hidden'
}
this.maximize = inquiry.maximize
this.isSaved = !!inquiry.id
this.state = state

View File

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

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

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

View File

@@ -3,8 +3,9 @@
<splitpanes
class="query-results-splitter"
horizontal
:before="{ size: 50, max: 100 }"
:after="{ size: 50, max: 100 }"
:before="{ size: topPaneSize, max: 100 }"
:after="{ size: 100 - topPaneSize, max: 100 }"
:default="{ before: 50, after: 50 }"
>
<template #left-pane>
<div :id="'above-' + tab.id" class="above" />
@@ -70,6 +71,13 @@ export default {
Splitpanes,
Teleport
},
data () {
return {
topPaneSize: this.tab.maximize
? this.tab.layout[this.tab.maximize] === 'above' ? 100 : 0
: 50
}
},
computed: {
isActive () {
return this.tab.id === this.$store.state.currentTabId

View File

@@ -2,8 +2,9 @@
<div>
<splitpanes
class="schema-tabs-splitter"
:before="{ size: 20, max: 30 }"
:after="{ size: 80, max: 100 }"
:before="{ size: schemaWidth, max: 30 }"
:after="{ size: 100 - schemaWidth, max: 100 }"
:default="{ before: 20, after: 80 }"
>
<template #left-pane>
<schema/>
@@ -28,6 +29,11 @@ export default {
Splitpanes,
Tabs
},
data () {
return {
schemaWidth: this.$route.query.hide_schema === '1' ? 0 : 20
}
},
async beforeCreate () {
const schema = this.$store.state.db.schema
if (!schema || schema.length === 0) {

View File

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

View File

@@ -283,6 +283,7 @@ describe('mutations', () => {
it('setCurrentTabId', () => {
const state = {
tabs: [{ id: 1 }, { id: 2 }],
currentTabId: 1
}

View File

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

View File

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

View File

@@ -406,4 +406,119 @@ describe('Tab.vue', () => {
expect(wrapper.find('.above .sql-editor-panel').exists()).to.equal(true)
expect(wrapper.find('.bottomPane .run-result-panel').exists()).to.equal(true)
})
it('Maximize top panel if maximized panel is above', () => {
const state = {
currentTabId: 1
}
const store = new Vuex.Store({ state, mutations })
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo; CREATE TABLE bar(a,b);',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
maximize: 'sqlEditor',
isPredefined: false,
result: null,
isGettingResults: false,
error: null,
time: 0,
isSaved: true
}
const wrapper = mount(Tab, {
attachTo: place,
store,
stubs: ['chart'],
propsData: {
tab
}
})
expect(wrapper.find('.above').element.parentElement.style.height)
.to.equal('100%')
})
it('Maximize bottom panel if maximized panel is below', () => {
const state = {
currentTabId: 1
}
const store = new Vuex.Store({ state, mutations })
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo; CREATE TABLE bar(a,b);',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
maximize: 'table',
isPredefined: false,
result: null,
isGettingResults: false,
error: null,
time: 0,
isSaved: true
}
const wrapper = mount(Tab, {
attachTo: place,
store,
stubs: ['chart'],
propsData: {
tab
}
})
expect(wrapper.find('.bottomPane').element.parentElement.style.height)
.to.equal('100%')
})
it('Panel size is 50 is nothing to maximize', () => {
const state = {
currentTabId: 1
}
const store = new Vuex.Store({ state, mutations })
const tab = {
id: 1,
name: 'foo',
query: 'SELECT * FROM foo; CREATE TABLE bar(a,b);',
viewType: 'chart',
viewOptions: {},
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
},
isPredefined: false,
result: null,
isGettingResults: false,
error: null,
time: 0,
isSaved: true
}
const wrapper = mount(Tab, {
attachTo: place,
store,
stubs: ['chart'],
propsData: {
tab
}
})
expect(wrapper.find('.above').element.parentElement.style.height)
.to.equal('50%')
expect(wrapper.find('.bottomPane').element.parentElement.style.height)
.to.equal('50%')
})
})

View File

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