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

20 Commits
0.1.0 ... 0.3.0

Author SHA1 Message Date
lana-k
fec8fb5ac0 update version 2020-10-25 22:30:33 +01:00
lana-k
621a41844e add table messages 2020-10-25 22:28:48 +01:00
lana-k
0a3a94444e add a message about no data for chart 2020-10-25 21:31:18 +01:00
lana-k
37aa2d35d5 minor changes 2020-10-25 17:33:53 +01:00
lana-k
5f91180a8c small style fixes 2020-10-25 16:49:06 +01:00
lana-k
b8c5a2bfd7 codemirror wrapped as a vue component 2020-10-25 16:41:23 +01:00
lana-k
880c15762b chart is a separated component now 2020-10-24 18:21:48 +02:00
lana-k
df54c9086b save plotly settings 2020-10-24 16:59:44 +02:00
lana-k
fdd50b2f86 not greedy splitter 2020-10-22 22:47:15 +02:00
lana-k
b39a6bdb86 disable Run button without schema 2020-10-20 19:14:37 +02:00
lana-k
5a8b2584ff add dataBase lib 2020-10-20 19:05:38 +02:00
lana-k
aae47eff86 fix typo 2020-10-20 17:21:24 +02:00
lana-k
65db2556c0 add start guid 2020-10-20 17:20:29 +02:00
lana-k
d132127143 add skip db button 2020-10-20 15:37:41 +02:00
lana-k
3e5e4b29c1 add group delete feature 2020-10-20 14:53:08 +02:00
lana-k
71c70e0232 group export 2020-10-19 21:24:31 +02:00
lana-k
8f49c0509f add filter to my queries 2020-10-19 12:37:12 +02:00
lana-k
5e29a051b2 hover style for checkboxes 2020-10-17 14:10:17 +02:00
lana-k
8b76258260 add checkboxes 2020-10-17 14:07:43 +02:00
lana-k
518270e1f5 fix build 2020-10-15 20:48:54 +02:00
21 changed files with 1741 additions and 669 deletions

1202
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
{ {
"name": "sqliteviz", "name": "sqliteviz",
"version": "0.0.1", "version": "0.3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build", "build": "NODE_OPTIONS=--max_old_space_size=4096 vue-cli-service build",
"test:unit": "vue-cli-service test:unit", "test:unit": "vue-cli-service test:unit",
"lint": "vue-cli-service lint" "lint": "vue-cli-service lint"
}, },
@@ -12,9 +12,9 @@
"codemirror": "^5.57.0", "codemirror": "^5.57.0",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"nanoid": "^3.1.12", "nanoid": "^3.1.12",
"plotly.js": "^1.54.6", "plotly.js": "^1.57.1",
"react": "^16.13.1", "react": "^16.13.1",
"react-chart-editor": "^0.41.7", "react-chart-editor": "^0.42.0",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"sql.js": "^1.3.0", "sql.js": "^1.3.0",
"sqlite-parser": "^1.0.1", "sqlite-parser": "^1.0.1",

View File

@@ -0,0 +1,17 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="17" height="17" rx="2.5" fill="#119DFF" stroke="#0D76BF"/>
<g filter="url(#filter0_d)">
<path d="M15.75 5.25L6.75 14.25L2.625 10.125L3.6825 9.0675L6.75 12.1275L14.6925 4.1925L15.75 5.25Z" fill="white"/>
</g>
<defs>
<filter id="filter0_d" x="0.625" y="3.1925" width="17.125" height="14.0575" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.164706 0 0 0 0 0.247059 0 0 0 0 0.372549 0 0 0 0.7 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 981 B

View File

@@ -0,0 +1,17 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="17" height="17" rx="2.5" fill="#F3F6FA" stroke="#C8D4E3"/>
<g filter="url(#filter0_d)">
<path d="M15.75 5.24988L6.75 14.2499L2.625 10.1249L3.6825 9.06738L6.75 12.1274L14.6925 4.19238L15.75 5.24988Z" fill="#119DFF"/>
</g>
<defs>
<filter id="filter0_d" x="0.625" y="3.19238" width="17.125" height="14.0575" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.164706 0 0 0 0 0.247059 0 0 0 0 0.372549 0 0 0 0.45 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 996 B

View File

@@ -11,6 +11,7 @@
--color-blue-dark: #0D76BF; --color-blue-dark: #0D76BF;
--color-blue-dark-2: #2A3F5F; --color-blue-dark-2: #2A3F5F;
--color-red: #EF553B; --color-red: #EF553B;
--color-yellow: #FBEFCB;
@@ -18,6 +19,7 @@
--color-bg-light-2: var(--color-gray-light-2); --color-bg-light-2: var(--color-gray-light-2);
--color-bg-light-3: var(--color-gray-light-5); --color-bg-light-3: var(--color-gray-light-5);
--color-bg-dark: var(--color-gray-dark); --color-bg-dark: var(--color-gray-dark);
--color-bg-warning: var(--color-yellow);
--color-accent: var(--color-blue-medium); --color-accent: var(--color-blue-medium);
--color-accent-shade: var(--color-blue-dark); --color-accent-shade: var(--color-blue-dark);
--color-border-light: var(--color-gray-light-2); --color-border-light: var(--color-gray-light-2);

131
src/components/Chart.vue Normal file
View File

@@ -0,0 +1,131 @@
<template>
<div class="chart-container">
<div class="chart-worning" v-show="!sqlResult && visible">
There is no data to build a chart. Run your sql query and make sure the result is not empty.
</div>
<PlotlyEditor
v-show="visible"
:data="state.data"
:layout="state.layout"
:frames="state.frames"
:config="{ editable: true, displaylogo: false }"
:dataSources="dataSources"
:dataSourceOptions="dataSourceOptions"
:plotly="plotly"
@onUpdate="update"
:useResizeHandler="true"
:debug="true"
:advancedTraceTypeSelector="true"
class="chart"
:style="{ height: !sqlResult ? 'calc(100% - 40px)' : '100%' }"
/>
</div>
</template>
<script>
import plotly from 'plotly.js/dist/plotly'
import 'react-chart-editor/lib/react-chart-editor.min.css'
import PlotlyEditor from 'react-chart-editor'
import dereference from 'react-chart-editor/lib/lib/dereference'
export default {
name: 'Chart',
props: ['sqlResult', 'initChart', 'visible'],
components: {
PlotlyEditor
},
data () {
return {
plotly: plotly,
state: this.initChart || {
data: [],
layout: {},
frames: []
}
}
},
computed: {
dataSources () {
if (!this.sqlResult) {
return {}
}
const dataSorces = {}
const matrix = this.sqlResult.values
const [row] = matrix
const transposedMatrix = row.map((value, column) => matrix.map(row => row[column]))
this.sqlResult.columns.forEach((column, index) => {
dataSorces[column] = transposedMatrix[index]
})
return dataSorces
},
dataSourceOptions () {
return Object.keys(this.dataSources).map(name => ({
value: name,
label: name
}))
}
},
watch: {
dataSources () {
// we need to update state.data in order to update the graph
// https://github.com/plotly/react-chart-editor/issues/948
dereference(this.state.data, this.dataSources)
}
},
methods: {
update (data, layout, frames) {
this.state = { data, layout, frames }
this.$emit('update')
},
getChartSatateForSave () {
// we don't need to save the data, only settings
// so we modify state.data using dereference
const stateCopy = JSON.parse(JSON.stringify(this.state))
const emptySources = {}
for (const key in this.dataSources) {
emptySources[key] = []
}
dereference(stateCopy.data, emptySources)
return stateCopy
}
}
}
</script>
<style scoped>
.chart-container {
height: calc(100% - 89px);
}
.chart-worning {
background-color: var(--color-bg-warning);
height: 40px;
line-height: 40px;
color: var(--color-text-base);
font-size: 13px;
padding: 0 24px;
}
.chart {
border-top: 1px solid var(--color-border);
}
>>> .editor_controls .sidebar__item:before {
width: 0;
}
>>> .sidebar {
width: 120px;
min-width: 120px;
max-width: 120px;
}
>>> .editor_controls__wrapper>.panel,
>>> .editor_controls .panel__empty {
width: 315px;
}
>>> .editor_controls .sidebar__group__title {
padding-left: 10px;
}
>>> .editor_controls .sidebar__item {
padding-left: 32px;
}
</style>

View File

@@ -0,0 +1,72 @@
<template>
<div class="checkbox-container" @click.stop="onClick">
<div v-show="!checked" class="unchecked" />
<img
v-show="checked && theme === 'accent'"
:src="require('@/assets/images/checkbox_checked.svg')"
/>
<img
v-show="checked && theme === 'light'"
:src="require('@/assets/images/checkbox_checked_light.svg')"
/>
</div>
</template>
<script>
export default {
name: 'checkBox',
props: {
theme: {
type: String,
required: false,
default: 'accent',
validator: (value) => {
return ['accent', 'light'].includes(value)
}
},
init: {
type: Boolean,
required: false,
default: false
}
},
data () {
return {
checked: this.init
}
},
watch: {
checked () {
this.$emit('change', this.checked)
}
},
methods: {
onClick () {
this.checked = !this.checked
this.$emit('click', this.checked)
}
}
}
</script>
<style scoped>
.checkbox-container {
display: inline-block;
cursor: pointer;
}
.unchecked {
width: 18px;
height: 18px;
background-color: white;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-medium);
box-sizing: border-box;
}
.unchecked:hover {
background-color: var(--color-bg-light);
}
img {
display: block;
}
</style>

View File

@@ -8,7 +8,7 @@
<button <button
v-if="$store.state.tabs.length > 0" v-if="$store.state.tabs.length > 0"
class="primary" class="primary"
:disabled="!$store.state.currentTab.isUnsaved" :disabled="$store.state.currentTab && !$store.state.currentTab.isUnsaved"
@click="saveQuery" @click="saveQuery"
> >
Save Save
@@ -23,6 +23,9 @@ import { nanoid } from 'nanoid'
export default { export default {
name: 'MainMenu', name: 'MainMenu',
created () {
this.$root.$on('createNewQuery', this.createNewQuery)
},
methods: { methods: {
createNewQuery () { createNewQuery () {
const tab = { const tab = {
@@ -42,8 +45,8 @@ export default {
const isFromScratch = !this.$store.state.currentTab.initName const isFromScratch = !this.$store.state.currentTab.initName
const value = { const value = {
id: currentQuery.id, id: currentQuery.id,
query: currentQuery.query query: currentQuery.query,
// TODO: save plotly settings chart: currentQuery.getChartSatateForSave()
} }
if (isFromScratch) { if (isFromScratch) {

View File

@@ -57,8 +57,7 @@ export default {
components: { TableDescription, TextField }, components: { TableDescription, TextField },
data () { data () {
return { return {
schemaVisible: true, schemaVisible: true
worker: this.$store.state.worker
} }
}, },
computed: { computed: {
@@ -71,29 +70,7 @@ export default {
}, },
methods: { methods: {
changeDb () { changeDb () {
const dbName = this.$refs.dbfile.value.substr(this.$refs.dbfile.value.lastIndexOf('\\') + 1) this.$db.loadDb(this.$refs.dbfile.files[0])
this.$store.commit('saveDbName', dbName)
const f = this.$refs.dbfile.files[0]
const r = new FileReader()
r.onload = () => {
this.worker.onmessage = () => {
const getSchemaSql = `
SELECT name, sql
FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%';`
this.worker.onmessage = event => {
this.$store.commit('saveSchema', event.data.results[0].values)
}
this.worker.postMessage({ action: 'exec', sql: getSchemaSql })
}
this.$store.commit('saveDbFile', r.result)
try {
this.worker.postMessage({ action: 'open', buffer: r.result }, [r.result])
} catch (exception) {
this.worker.postMessage({ action: 'open', buffer: r.result })
}
}
r.readAsArrayBuffer(f)
} }
} }
} }

View File

@@ -1,8 +1,13 @@
<template> <template>
<div <div
ref="container" ref="container"
:class="['splitpanes', `splitpanes--${horizontal ? 'horizontal' : 'vertical'}`, { 'splitpanes--dragging': touch.dragging }]" :class="[
'splitpanes',
`splitpanes--${horizontal ? 'horizontal' : 'vertical'}`,
{ 'splitpanes--dragging': touch.dragging }
]"
> >
<div class="movable-splitter" ref="movableSplitter" :style="movableSplitterStyle" />
<div <div
class="splitpanes__pane" class="splitpanes__pane"
ref="left" ref="left"
@@ -12,13 +17,21 @@
> >
<slot name="left-pane" /> <slot name="left-pane" />
</div> </div>
<!-- Splitter start-->
<splitter <div
class="splitpanes__splitter"
@mousedown="onMouseDown" @mousedown="onMouseDown"
@toggle="toggleFirstPane" @touchstart="onMouseDown"
:expanded="paneBefore.size !== 0" >
/> <div class="toggle-btn" @click="toggleFirstPane">
<img
class="direction-icon"
:src="require('@/assets/images/chevron.svg')"
:style="directionIconStyle"
>
</div>
</div>
<!-- splitter end -->
<div <div
class="splitpanes__pane" class="splitpanes__pane"
ref="right" ref="right"
@@ -30,33 +43,58 @@
</template> </template>
<script> <script>
import Splitter from '@/components/splitter'
export default { export default {
name: 'splitpanes', name: 'Splitpanes',
components: { Splitter },
props: { props: {
horizontal: { type: Boolean, default: false }, horizontal: { type: Boolean, default: false },
before: { type: Object }, before: { type: Object },
after: { type: Object } after: { type: Object }
}, },
data: () => ({ data () {
container: null, return {
paneBefore: null, container: null,
paneAfter: null, paneBefore: this.before,
beforeMinimising: 20, paneAfter: this.after,
touch: { beforeMinimising: this.before.size,
mouseDown: false, touch: {
dragging: false mouseDown: false,
dragging: false
},
movableSplitter: {
top: 0,
left: 0,
visibility: 'hidden'
}
} }
}), },
computed: { computed: {
styles () { styles () {
return [ return [
{ [this.horizontal ? 'height' : 'width']: `${this.paneBefore.size}%` }, { [this.horizontal ? 'height' : 'width']: `${this.paneBefore.size}%` },
{ [this.horizontal ? 'height' : 'width']: `${this.paneAfter.size}%` } { [this.horizontal ? 'height' : 'width']: `${this.paneAfter.size}%` }
] ]
},
movableSplitterStyle () {
const style = { ...this.movableSplitter }
style.top += '%'
style.left += '%'
return style
},
expanded () {
return this.paneBefore.size !== 0
},
directionIconStyle () {
const translation = 'translate(-50%, -50%)'
if (this.horizontal) {
return {
transform: `${translation} ${this.expanded ? 'rotate(-90deg)' : 'rotate(90deg)'}`
}
} else {
return {
transform: `${translation} ${this.expanded ? 'rotate(180deg)' : ''}`
}
}
} }
}, },
@@ -92,12 +130,28 @@ export default {
// Prevent scrolling while touch dragging (only works with an active event, eg. passive: false). // Prevent scrolling while touch dragging (only works with an active event, eg. passive: false).
event.preventDefault() event.preventDefault()
this.touch.dragging = true this.touch.dragging = true
this.calculatePanesSize(this.getCurrentMouseDrag(event)) this.$set(this.movableSplitter, 'visibility', 'visible')
this.moveSplitter(event)
} }
}, },
onMouseUp () { onMouseUp () {
this.touch.mouseDown = false this.touch.mouseDown = false
if (this.touch.dragging) {
const dragPercentage = this.horizontal
? this.movableSplitter.top
: this.movableSplitter.left
this.paneBefore.size = dragPercentage
this.paneAfter.size = 100 - dragPercentage
this.movableSplitter = {
top: 0,
left: 0,
visibility: 'hidden'
}
}
// Keep dragging flag until click event is finished (click happens immediately after mouseup) // Keep dragging flag until click event is finished (click happens immediately after mouseup)
// in order to prevent emitting `splitter-click` event if splitter was dragged. // in order to prevent emitting `splitter-click` event if splitter was dragged.
setTimeout(() => { setTimeout(() => {
@@ -109,8 +163,9 @@ export default {
// Get the cursor position relative to the splitpane container. // Get the cursor position relative to the splitpane container.
getCurrentMouseDrag (event) { getCurrentMouseDrag (event) {
const rect = this.container.getBoundingClientRect() const rect = this.container.getBoundingClientRect()
const { clientX, clientY } = ('ontouchstart' in window && event.touches) ? event.touches[0] : event const { clientX, clientY } = ('ontouchstart' in window && event.touches)
? event.touches[0]
: event
return { return {
x: clientX - rect.left, x: clientX - rect.left,
y: clientY - rect.top y: clientY - rect.top
@@ -126,27 +181,26 @@ export default {
return drag * 100 / containerSize return drag * 100 / containerSize
}, },
calculatePanesSize (drag) { moveSplitter (event) {
const dragPercentage = this.getCurrentDragPercentage(drag) const dragPercentage = this.getCurrentDragPercentage(this.getCurrentMouseDrag(event))
// If not pushing other panes, panes to resize are right before and right after splitter.
const paneBefore = this.paneBefore const paneBefore = this.paneBefore
const paneAfter = this.paneAfter const paneAfter = this.paneAfter
const paneBeforeMaxReached = paneBefore.max < 100 && (dragPercentage >= paneBefore.max) const paneBeforeMaxReached = paneBefore.max < 100 && (dragPercentage >= paneBefore.max)
const paneAfterMaxReached = paneAfter.max < 100 && (dragPercentage <= 100 - paneAfter.max) const paneAfterMaxReached = paneAfter.max < 100 && (dragPercentage <= 100 - paneAfter.max)
const dir = this.horizontal ? 'top' : 'left'
// Prevent dragging beyond pane max. // Prevent dragging beyond pane max.
if (paneBeforeMaxReached || paneAfterMaxReached) { if (paneBeforeMaxReached || paneAfterMaxReached) {
if (paneBeforeMaxReached) { if (paneBeforeMaxReached) {
paneBefore.size = paneBefore.max this.$set(this.movableSplitter, dir, paneBefore.max)
paneAfter.size = Math.max(100 - paneBefore.max, 0)
} else { } else {
paneBefore.size = Math.max(100 - paneAfter.max, 0) this.$set(this.movableSplitter, dir, Math.max(100 - paneAfter.max, 0))
paneAfter.size = paneAfter.max
} }
return } else {
this.$set(this.movableSplitter, dir, Math.min(Math.max(dragPercentage, 0), paneBefore.max))
} }
paneBefore.size = Math.min(Math.max(dragPercentage, 0), paneBefore.max)
paneAfter.size = Math.min(Math.max(100 - dragPercentage, 0), paneAfter.max)
}, },
toggleFirstPane () { toggleFirstPane () {
if (this.paneBefore.size > 0) { if (this.paneBefore.size > 0) {
@@ -160,10 +214,6 @@ export default {
}, },
mounted () { mounted () {
this.container = this.$refs.container this.container = this.$refs.container
},
created () {
this.paneBefore = this.before
this.paneAfter = this.after
} }
} }
</script> </script>
@@ -172,6 +222,7 @@ export default {
.splitpanes { .splitpanes {
display: flex; display: flex;
height: 100%; height: 100%;
position: relative;
} }
.splitpanes--vertical {flex-direction: row;} .splitpanes--vertical {flex-direction: row;}
@@ -183,4 +234,68 @@ export default {
height: 100%; height: 100%;
overflow: auto; overflow: auto;
} }
/* Splitter */
.splitpanes--vertical > .splitpanes__splitter,
.splitpanes--vertical.splitpanes--dragging {
cursor: col-resize;
}
.splitpanes--horizontal > .splitpanes__splitter,
.splitpanes--horizontal.splitpanes--dragging {
cursor: row-resize;
}
.splitpanes__splitter {
touch-action: none;
background-color: var(--color-bg-light-2);
box-sizing: border-box;
position: relative;
flex-shrink: 0;
z-index: 1;
}
.movable-splitter {
position: absolute;
background-color:rgba(162, 177, 198, 0.5);
}
.splitpanes--vertical > .splitpanes__splitter,
.splitpanes--vertical .movable-splitter {
width: 3px;
z-index: 5;
height: 100%
}
.splitpanes--horizontal > .splitpanes__splitter,
.splitpanes--horizontal .movable-splitter {
height: 3px;
width: 100%;
}
.splitpanes__splitter .toggle-btn {
background-color: var(--color-bg-light-2);
border-radius: var(--border-radius-small);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.splitpanes__splitter .toggle-btn:hover {
cursor: pointer;
}
.splitpanes--vertical .toggle-btn {
height: 68px;
width: 15px;
}
.splitpanes--horizontal .toggle-btn {
width: 68px;
height: 15px;
}
.splitpanes__splitter .toggle-btn .direction-icon {
position: absolute;
top: 50%;
left: 50%;
}
</style> </style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="codemirror-container">
<codemirror v-model="query" :options="cmOptions" @changes="onCmChange" />
</div>
</template>
<script>
import CM from 'codemirror'
import { codemirror } from 'vue-codemirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/mode/sql/sql.js'
import 'codemirror/theme/neo.css'
import 'codemirror/addon/hint/show-hint.js'
import 'codemirror/addon/hint/show-hint.css'
import 'codemirror/addon/hint/sql-hint.js'
export default {
name: 'SqlEditor',
props: ['value'],
components: {
codemirror
},
data () {
return {
query: this.value,
cmOptions: {
// codemirror options
tabSize: 4,
mode: 'text/x-mysql',
theme: 'neo',
lineNumbers: true,
line: true
},
result: null
}
},
watch: {
query () {
this.$emit('input', this.query)
}
},
methods: {
onCmChange (editor) {
// Don't show autocomplete after a space or semicolon
const ch = editor.getTokenAt(editor.getCursor()).string.slice(-1)
if (!ch || ch === ' ' || ch === ';') {
return
}
const hintOptions = {
// tables: this.state.tables,
completeSingle: false,
completeOnSingleClick: true
}
// editor.hint.sql is defined when importing codemirror/addon/hint/sql-hint
// (this is mentioned in codemirror addon documentation)
// Reference the hint function imported here when including other hint addons
// or supply your own
CM.showHint(editor, CM.hint.sql, hintOptions)
}
}
}
</script>
<style scoped>
.codemirror-container {
flex-grow: 1;
min-height: 0;
}
>>> .vue-codemirror {
height: 100%;
max-height: 100%;
}
>>> .CodeMirror {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-big);
height: 100%;
max-height: 100%;
}
</style>

View File

@@ -17,7 +17,7 @@
class="table-container" class="table-container"
ref="table-container" ref="table-container"
@scroll="onScrollTable" @scroll="onScrollTable"
:style="{height: `${height}px`}" :style="{maxHeight: `${height}px`}"
> >
<table ref="table"> <table ref="table">
<thead> <thead>

View File

@@ -6,122 +6,89 @@
:before="{ size: 50, max: 50 }" :before="{ size: 50, max: 50 }"
:after="{ size: 50, max: 100 }" :after="{ size: 50, max: 100 }"
> >
<div slot="left-pane" class="query-editor"> <template #left-pane>
<div class="codemirror-container"> <div class="query-editor">
<codemirror v-model="query" :options="cmOptions" @changes="onCmChange" ref="codemirror" /> <sql-editor v-model="query" />
<div class="run-btn-container">
<button
class="primary run-btn"
@click="execute"
:disabled="!$store.state.schema || !query"
>
Run
</button>
</div>
</div> </div>
<div class="run-btn-container"> </template>
<button class="primary run-btn" @click="execEditorContents">Run</button> <template #right-pane>
<div id="bottomPane" ref="bottomPane">
<view-switcher :view.sync="view" />
<div v-show="view === 'table'" class="table-view">
<div v-show="result === null && !isGettingResults && !error" class="table-preview">
Run your query and get results here
</div>
<div v-show="isGettingResults" class="table-preview">
Fetching results...
</div>
<div v-show="result === undefined && !isGettingResults && !error" class="table-preview">
No rows retrieved according to your query
</div>
<div v-show="error" class="table-preview error">
{{ error }}
</div>
<sql-table v-if="result" :data="result" :height="tableViewHeight" />
</div>
<chart
:visible="view === 'chart'"
:sql-result="result"
:init-chart="initChart"
ref="chart"
@update="isUnsaved = true"
/>
</div> </div>
</div> </template>
<div slot="right-pane" id="bottomPane" ref="bottomPane">
<view-switcher :view.sync="view" />
<div v-show="view === 'table'" class="table-view">
<!-- <div id="error" class="error"></div>
<pre ref="output" id="output">Results will be displayed here</pre> -->
<sql-table v-if="result" :data="result" :height="tableViewHeight" />
</div>
<PlotlyEditor
v-show="view === 'chart'"
:data="state.data"
:layout="state.layout"
:frames="state.frames"
:config="{ editable: true }"
:dataSources="dataSources"
:dataSourceOptions="dataSourceOptions"
:plotly="plotly"
@onUpdate="update"
:useResizeHandler="true"
:debug="true"
:advancedTraceTypeSelector="true"
/>
</div>
</splitpanes> </splitpanes>
</div> </div>
</template> </template>
<script> <script>
import SqlTable from '@/components/SqlTable' import SqlTable from '@/components/SqlTable'
import Splitpanes from '@/components/splitpanes' import SqlEditor from '@/components/SqlEditor'
import Splitpanes from '@/components/Splitpanes'
import ViewSwitcher from '@/components/ViewSwitcher' import ViewSwitcher from '@/components/ViewSwitcher'
import Chart from '@/components/Chart'
import plotly from 'plotly.js/dist/plotly'
import 'react-chart-editor/lib/react-chart-editor.min.css'
import CM from 'codemirror'
import { codemirror } from 'vue-codemirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/mode/sql/sql.js'
import 'codemirror/theme/neo.css'
import 'codemirror/addon/hint/show-hint.js'
import 'codemirror/addon/hint/show-hint.css'
import 'codemirror/addon/hint/sql-hint.js'
import PlotlyEditor from 'react-chart-editor'
export default { export default {
name: 'TabContent', name: 'TabContent',
props: ['id', 'initName', 'initQuery', 'initPlotly', 'tabIndex'], props: ['id', 'initName', 'initQuery', 'initChart', 'tabIndex'],
components: { components: {
codemirror, SqlEditor,
SqlTable, SqlTable,
Splitpanes, Splitpanes,
ViewSwitcher, ViewSwitcher,
PlotlyEditor Chart
}, },
data () { data () {
return { return {
plotly: plotly, query: this.initQuery,
state: {
data: [],
layout: {},
frames: []
},
query: 'select * from albums',
cmOptions: {
// codemirror options
tabSize: 4,
mode: 'text/x-mysql',
theme: 'neo',
lineNumbers: true,
line: true
},
result: null, result: null,
view: 'table', view: 'table',
tableViewHeight: 0, tableViewHeight: 0,
worker: this.$store.state.worker, isUnsaved: !this.initName,
isUnsaved: !this.name isGettingResults: false,
error: null
} }
}, },
computed: { computed: {
isActive () { isActive () {
return this.id === this.$store.state.currentTabId return this.id === this.$store.state.currentTabId
},
dataSources () {
if (!this.result) {
return {}
}
const dataSorces = {}
const matrix = this.result.values
const [row] = matrix
const transposedMatrix = row.map((value, column) => matrix.map(row => row[column]))
this.result.columns.forEach((column, index) => {
dataSorces[column] = transposedMatrix[index]
})
return dataSorces
},
dataSourceOptions () {
return Object.keys(this.dataSources).map(name => ({
value: name,
label: name
}))
} }
}, },
created () { created () {
this.$store.commit('setCurrentTab', this) this.$store.commit('setCurrentTab', this)
}, },
mounted () { mounted () {
new ResizeObserver(this.calculateTableHeight).observe(this.$refs.bottomPane) new ResizeObserver(this.handleResize).observe(this.$refs.bottomPane)
this.calculateTableHeight() this.calculateTableHeight()
}, },
watch: { watch: {
@@ -138,47 +105,32 @@ export default {
} }
}, },
methods: { methods: {
update (data, layout, frames) {
this.state = { data, layout, frames }
this.isUnsaved = true
console.log(this.state)
},
onCmChange (editor) {
// Don't show autocomplete after a space or semicolon
const ch = editor.getTokenAt(editor.getCursor()).string.slice(-1)
if (!ch || ch === ' ' || ch === ';') {
return
}
const hintOptions = {
// tables: this.state.tables,
completeSingle: false,
completeOnSingleClick: true
}
// editor.hint.sql is defined when importing codemirror/addon/hint/sql-hint
// (this is mentioned in codemirror addon documentation)
// Reference the hint function imported here when including other hint addons
// or supply your own
CM.showHint(editor, CM.hint.sql, hintOptions)
},
// Run a command in the database // Run a command in the database
execute (commands) { execute () {
this.worker.onmessage = (event) => { // this.$refs.output.textContent = 'Fetching results...' */
// if it was more than one select - take only the first one this.isGettingResults = true
this.result = event.data.results[0] this.result = null
if (!this.result) { this.error = null
console.log(event.data.error) this.$db.execute(this.query + ';')
// return .then(result => {
} this.result = result
})
// this.$refs.output.innerHTML = '' .catch(err => {
} this.error = err
this.worker.postMessage({ action: 'exec', sql: commands }) })
// this.$refs.output.textContent = 'Fetching results...' .finally(() => {
this.isGettingResults = false
})
}, },
execEditorContents () { handleResize () {
this.execute(this.query + ';') if (this.view === 'chart') {
// hack react-chart editor: hidden and show in order to make the graph resize
this.view = 'not chart'
this.$nextTick(() => {
this.view = 'chart'
})
}
this.calculateTableHeight()
}, },
calculateTableHeight () { calculateTableHeight () {
const bottomPane = this.$refs.bottomPane const bottomPane = this.$refs.bottomPane
@@ -189,6 +141,9 @@ export default {
// 40 - height of table header // 40 - height of table header
const freeSpace = bottomPane.offsetHeight - 88 - 42 - 30 - 5 - 40 const freeSpace = bottomPane.offsetHeight - 88 - 42 - 30 - 5 - 40
this.tableViewHeight = freeSpace - (freeSpace % 40) this.tableViewHeight = freeSpace - (freeSpace % 40)
},
getChartSatateForSave () {
return this.$refs.chart.getChartSatateForSave()
} }
} }
} }
@@ -222,29 +177,33 @@ export default {
height: 100%; height: 100%;
max-height: 100%; max-height: 100%;
box-sizing: border-box; box-sizing: border-box;
min-height: 150px; min-height: 190px;
}
.codemirror-container {
flex-grow: 1;
min-height: 0;
} }
.run-btn-container { .run-btn-container {
text-align: right; text-align: right;
} }
>>> .vue-codemirror {
height: 100%;
max-height: 100%;
}
>>> .CodeMirror {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-big);
height: 100%;
max-height: 100%;
}
.table-view { .table-view {
margin: 0 52px; margin: 0 52px;
height: calc(100% - 88px);
position: relative;
}
.table-preview {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--color-text-base);
font-size: 13px;
}
.table-preview.error {
color: var(--color-text-error);
}
.table-preview.error::first-letter {
text-transform: capitalize;
} }
</style> </style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div> <div id="tabs-container">
<div id="tabs__header"> <div id="tabs__header" v-if="tabs.length > 0">
<div <div
v-for="(tab, index) in tabs" v-for="(tab, index) in tabs"
:key="tab.id" :key="tab.id"
@@ -34,8 +34,15 @@
:key="tab.id" :key="tab.id"
:id="tab.id" :id="tab.id"
:init-name="tab.name" :init-name="tab.name"
:init-query="tab.query"
:init-chart="tab.chart"
:tab-index="index" :tab-index="index"
/> />
<div v-if="tabs.length === 0" id="start-guide">
<span class="link" @click="$root.$emit('createNewQuery')">Create</span>
a new query from scratch or open the one from
<router-link class="link" to="/my-queries">My queries</router-link>
</div>
</div> </div>
</template> </template>
@@ -70,6 +77,10 @@ export default {
</script> </script>
<style> <style>
#tabs-container {
position: relative;
height: 100%;
}
#tabs__header { #tabs__header {
display: flex; display: flex;
margin: 0; margin: 0;
@@ -129,4 +140,18 @@ export default {
fill: var(--color-text-base); fill: var(--color-text-base);
cursor: pointer; cursor: pointer;
} }
#start-guide {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--color-text-base);
font-size: 14px;
text-align: center;
}
.link {
color: var(--color-accent);
text-decoration: none;
cursor: pointer;
}
</style> </style>

View File

@@ -1,85 +0,0 @@
<template>
<div
class="splitpanes__splitter"
@mousedown="$emit('mousedown')"
@touchstart="$emit('mousedown')"
>
<div class="toggle-btn" @click="$emit('toggle')">
<img
class="direction-icon"
:src="require('@/assets/images/chevron.svg')"
:style="directionIconStyle"
>
</div>
</div>
</template>
<script>
export default {
name: 'splitter',
props: ['expanded'],
computed: {
directionIconStyle () {
const translation = 'translate(-50%, -50%)'
if (this.$parent.horizontal) {
return {
transform: `${translation} ${this.expanded ? 'rotate(-90deg)' : 'rotate(90deg)'}`
}
} else {
return {
transform: `${translation} ${this.expanded ? 'rotate(180deg)' : ''}`
}
}
}
}
}
</script>
<style>
.splitpanes--vertical > .splitpanes__splitter {min-width: 1px;cursor: col-resize;}
.splitpanes--horizontal > .splitpanes__splitter {min-height: 1px; cursor: row-resize;}
.splitpanes__splitter {
touch-action: none;
background-color: var(--color-bg-light-2);
box-sizing: border-box;
position: relative;
flex-shrink: 0;
z-index: 1;
}
.splitpanes--vertical > .splitpanes__splitter {
width: 3px;
z-index: 3;
}
.splitpanes--horizontal > .splitpanes__splitter {
height: 3px;
width: 100%;
}
.splitpanes__splitter .toggle-btn {
background-color: var(--color-bg-light-2);
border-radius: var(--border-radius-small);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.splitpanes__splitter .toggle-btn:hover {
cursor: pointer;
}
.splitpanes--vertical .toggle-btn {
height: 68px;
width: 15px;
}
.splitpanes--horizontal .toggle-btn {
width: 68px;
height: 15px;
}
.splitpanes__splitter .toggle-btn .direction-icon {
position: absolute;
top: 50%;
left: 50%;
}
</style>

46
src/dataBase.js Normal file
View File

@@ -0,0 +1,46 @@
import store from '@/store'
import router from '@/router'
const worker = new Worker('js/worker.sql-wasm.js')
export default {
loadDb (file) {
const dbName = file.name
store.commit('saveDbName', dbName)
const f = file
const r = new FileReader()
r.onload = () => {
worker.onmessage = () => {
const getSchemaSql = `
SELECT name, sql
FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%';`
worker.onmessage = event => {
store.commit('saveSchema', event.data.results[0].values)
if (router.currentRoute.path !== '/editor') {
router.push('/editor')
}
}
worker.postMessage({ action: 'exec', sql: getSchemaSql })
}
store.commit('saveDbFile', r.result)
try {
worker.postMessage({ action: 'open', buffer: r.result }, [r.result])
} catch (exception) {
worker.postMessage({ action: 'open', buffer: r.result })
}
}
r.readAsArrayBuffer(f)
},
execute (commands) {
return new Promise((resolve, reject) => {
worker.onmessage = (event) => {
if (event.data.error) {
reject(event.data.error)
}
// if it was more than one select - take only the first one
resolve(event.data.results[0])
}
worker.postMessage({ action: 'exec', sql: commands })
})
}
}

View File

@@ -4,6 +4,7 @@ import router from './router'
import store from './store' import store from './store'
import { VuePlugin } from 'vuera' import { VuePlugin } from 'vuera'
import VModal from 'vue-js-modal' import VModal from 'vue-js-modal'
import db from '@/dataBase'
import '@/assets/styles/variables.css' import '@/assets/styles/variables.css'
import '@/assets/styles/buttons.css' import '@/assets/styles/buttons.css'
@@ -14,6 +15,7 @@ Vue.use(VuePlugin)
Vue.use(VModal) Vue.use(VModal)
Vue.config.productionTip = false Vue.config.productionTip = false
Vue.prototype.$db = db
new Vue({ new Vue({
router, router,

View File

@@ -8,7 +8,6 @@ export default new Vuex.Store({
schema: null, schema: null,
dbFile: null, dbFile: null,
dbName: null, dbName: null,
worker: new Worker('js/worker.sql-wasm.js'),
tabs: [], tabs: [],
currentTab: null, currentTab: null,
currentTabId: null, currentTabId: null,

View File

@@ -16,47 +16,22 @@
</div> </div>
</label> </label>
<div id="error" class="error"></div> <div id="error" class="error"></div>
<button id ="skip" class="secondary" @click="$router.push('/editor')">
Skip database connection for now
</button>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
name: 'DbUpload', name: 'DbUpload',
data () {
return {
worker: this.$store.state.worker
}
},
methods: { methods: {
loadDb () { loadDb () {
const dbName = this.$refs.file.value.substr(this.$refs.file.value.lastIndexOf('\\') + 1) this.$db.loadDb(this.$refs.file.files[0])
this.$store.commit('saveDbName', dbName)
const f = this.$refs.file.files[0]
const r = new FileReader()
r.onload = () => {
this.worker.onmessage = () => {
const getSchemaSql = `
SELECT name, sql
FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%';`
this.worker.onmessage = event => {
this.$store.commit('saveSchema', event.data.results[0].values)
this.$router.push('/editor')
}
this.worker.postMessage({ action: 'exec', sql: getSchemaSql })
}
this.$store.commit('saveDbFile', r.result)
try {
this.worker.postMessage({ action: 'open', buffer: r.result }, [r.result])
} catch (exception) {
this.worker.postMessage({ action: 'open', buffer: r.result })
}
}
r.readAsArrayBuffer(f)
}, },
dragover (event) { dragover (event) {
event.preventDefault() event.preventDefault()
// TODO: Add some visual fluff to show the user can drop its files // TODO: Add some visual stuff to show the user can drop its files
if (!event.currentTarget.classList.contains('bg-green-300')) { if (!event.currentTarget.classList.contains('bg-green-300')) {
event.currentTarget.classList.remove('bg-gray-100') event.currentTarget.classList.remove('bg-gray-100')
event.currentTarget.classList.add('bg-green-300') event.currentTarget.classList.add('bg-green-300')
@@ -98,17 +73,21 @@ label {
border-radius: var(--border-radius-big); border-radius: var(--border-radius-big);
} }
#drop-area { #drop-area {
width: 231px; width: 628px;
height: 153px; height: 490px;
background-color: var(--color-bg-light-3); background-color: var(--color-bg-light-3);
border-radius: var(--border-radius-big); border-radius: var(--border-radius-big);
color: var(--color-text-base); color: var(--color-text-base);
font-size: 13px; font-size: 13px;
padding: 44px 15px; padding: 200px 144px;
text-align: center; text-align: center;
box-sizing: border-box; box-sizing: border-box;
} }
input { input {
display: none; display: none;
} }
#skip {
position: absolute;
bottom: 50px;
}
</style> </style>

View File

@@ -5,18 +5,18 @@
:before="{ size: 20, max: 30 }" :before="{ size: 20, max: 30 }"
:after="{ size: 80, max: 100 }" :after="{ size: 80, max: 100 }"
> >
<div slot="left-pane"> <template #left-pane>
<schema /> <schema />
</div> </template>
<div slot="right-pane"> <template #right-pane>
<tabs /> <tabs />
</div> </template>
</splitpanes> </splitpanes>
</div> </div>
</template> </template>
<script> <script>
import Splitpanes from '@/components/splitpanes' import Splitpanes from '@/components/Splitpanes'
import Schema from '@/components/Schema' import Schema from '@/components/Schema'
import Tabs from '@/components/Tabs' import Tabs from '@/components/Tabs'

View File

@@ -15,18 +15,31 @@
Import Import
</label> </label>
</button> </button>
<button class="toolbar" v-show="selectedQueries.length > 0">Export</button> <button
<button class="toolbar" v-show="selectedQueries.length > 0">Delete</button> class="toolbar"
v-show="selectedQueriesCount > 0"
@click="exportQuery(selectedQueriesIds)"
>
Export
</button>
<button
class="toolbar"
v-show="selectedQueriesCount > 0"
@click="showDeleteDialog(selectedQueriesIds)"
>
Delete
</button>
</div> </div>
<div id="toolbar-search"> <div id="toolbar-search">
<text-field placeholder="Search query by name" width="300px"/> <text-field placeholder="Search query by name" width="300px" v-model="filter"/>
</div> </div>
</div> </div>
<div class="rounded-bg"> <div class="rounded-bg">
<div class="header-container"> <div class="header-container">
<div> <div>
<div class="fixed-header" ref="name-th"> <div class="fixed-header" ref="name-th">
Name <check-box ref="mainCheckBox" theme="light" @click="toggleSelectAll"/>
<div class="name-th">Name</div>
</div> </div>
<div class="fixed-header"> <div class="fixed-header">
Created at Created at
@@ -39,18 +52,25 @@
> >
<table ref="table"> <table ref="table">
<tbody> <tbody>
<tr v-for="(query, index) in queries" :key="query.id" @click="openQuery(index)"> <tr v-for="(query, index) in showedQueries" :key="query.id" @click="openQuery(index)">
<td ref="name-td"> <td ref="name-td">
{{ query.name }} <div class="cell-data">
<check-box
ref="rowCheckBox"
:init="selectAll || selectedQueriesIds.has(query.id)"
@change="toggleRow($event, query.id)"
/>
<div class="name">{{ query.name }}</div>
</div>
</td> </td>
<td> <td>
<div class="second-column"> <div class="second-column">
<div class="date-container">{{ query.createdAt | date }}</div> <div class="date-container">{{ query.createdAt | date }}</div>
<div class="icons-container"> <div class="icons-container">
<rename-icon @click="showRenameDialog(index)" /> <rename-icon @click="showRenameDialog(query.id)" />
<copy-icon @click="duplicateQuery(index)"/> <copy-icon @click="duplicateQuery(index)"/>
<export-icon @click="exportQuery(index)"/> <export-icon @click="exportQuery(index)"/>
<delete-icon @click="showDeleteDialog(index)"/> <delete-icon @click="showDeleteDialog(query.id)"/>
</div> </div>
</div> </div>
</td> </td>
@@ -84,19 +104,24 @@
<!--Delete Query dialog --> <!--Delete Query dialog -->
<modal name="delete" classes="dialog" height="auto"> <modal name="delete" classes="dialog" height="auto">
<div class="dialog-header"> <div class="dialog-header">
Delete query Delete {{ deleteGroup ? 'queries' : 'query' }}
<close-icon @click="$modal.hide('delete')"/> <close-icon @click="$modal.hide('delete')"/>
</div> </div>
<div <div
v-if=" v-if="
currentQueryIndex !== null deleteGroup || (
&& currentQueryIndex >= 0 currentQueryIndex !== null
&& currentQueryIndex < queries.length && currentQueryIndex >= 0
&& currentQueryIndex < queries.length
)
" "
class="dialog-body" class="dialog-body"
> >
Are you sure you want to delete Are you sure you want to delete
"{{ queries[currentQueryIndex].name }}"? {{ deleteGroup
? `${selectedQueriesCount} ${selectedQueriesCount > 1 ? 'queries' : 'query'}`
: `"${queries[currentQueryIndex].name}"`
}}?
</div> </div>
<div class="dialog-buttons-container"> <div class="dialog-buttons-container">
<button class="secondary" @click="$modal.hide('delete')">Cancel</button> <button class="secondary" @click="$modal.hide('delete')">Cancel</button>
@@ -114,6 +139,7 @@ import ExportIcon from '@/components/svg/export'
import DeleteIcon from '@/components/svg/delete' import DeleteIcon from '@/components/svg/delete'
import CloseIcon from '@/components/svg/close' import CloseIcon from '@/components/svg/close'
import TextField from '@/components/TextField' import TextField from '@/components/TextField'
import CheckBox from '@/components/CheckBox'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
export default { export default {
@@ -124,15 +150,32 @@ export default {
ExportIcon, ExportIcon,
DeleteIcon, DeleteIcon,
CloseIcon, CloseIcon,
TextField TextField,
CheckBox
}, },
data () { data () {
return { return {
queries: [], queries: [],
filter: null,
newName: null, newName: null,
currentQueryIndex: null, currentQueryId: null,
errorMsg: null, errorMsg: null,
selectedQueries: [] selectedQueriesIds: new Set(),
selectedQueriesCount: 0,
selectAll: false,
deleteGroup: false
}
},
computed: {
showedQueries () {
if (!this.filter) {
return this.queries
} else {
return this.queries.filter(query => query.name.toUpperCase().indexOf(this.filter.toUpperCase()) >= 0)
}
},
currentQueryIndex () {
return this.queries.findIndex(query => query.id === this.currentQueryId)
} }
}, },
created () { created () {
@@ -162,15 +205,15 @@ export default {
this.$refs['name-th'].style = `width: ${this.$refs['name-td'][0].offsetWidth}px` this.$refs['name-th'].style = `width: ${this.$refs['name-td'][0].offsetWidth}px`
}, },
openQuery (index) { openQuery (index) {
const tab = this.queries[index] const tab = this.showedQueries[index]
tab.isUnsaved = false tab.isUnsaved = false
this.$store.commit('addTab', tab) this.$store.commit('addTab', tab)
this.$store.commit('setCurrentTabId', tab.id) this.$store.commit('setCurrentTabId', tab.id)
this.$router.push('/editor') this.$router.push('/editor')
}, },
showRenameDialog (index) { showRenameDialog (id) {
this.errorMsg = null this.errorMsg = null
this.currentQueryIndex = index this.currentQueryId = id
this.newName = this.queries[this.currentQueryIndex].name this.newName = this.queries[this.currentQueryIndex].name
this.$modal.show('rename') this.$modal.show('rename')
}, },
@@ -190,41 +233,82 @@ export default {
} }
}, },
duplicateQuery (index) { duplicateQuery (index) {
const newQuery = JSON.parse(JSON.stringify(this.queries[index])) const newQuery = JSON.parse(JSON.stringify(this.showedQueries[index]))
newQuery.name = newQuery.name + ' Copy' newQuery.name = newQuery.name + ' Copy'
newQuery.id = nanoid() newQuery.id = nanoid()
newQuery.createdAt = new Date() newQuery.createdAt = new Date()
this.queries.push(newQuery) this.queries.push(newQuery)
if (this.selectAll) {
this.selectedQueriesIds.add(newQuery.id)
this.selectedQueriesCount = this.selectedQueriesIds.size
}
this.saveQueriesInLocalStorage() this.saveQueriesInLocalStorage()
}, },
showDeleteDialog (index) { showDeleteDialog (id) {
this.currentQueryIndex = index this.deleteGroup = typeof id !== 'string'
if (!this.deleteGroup) {
this.currentQueryId = id
}
this.$modal.show('delete') this.$modal.show('delete')
}, },
deleteQuery () { deleteQuery () {
this.$modal.hide('delete') this.$modal.hide('delete')
const id = this.queries[this.currentQueryIndex].id if (!this.deleteGroup) {
this.queries.splice(this.currentQueryIndex, 1) this.queries.splice(this.currentQueryIndex, 1)
this.saveQueriesInLocalStorage() const tabIndex = this.findTabIndex(this.currentQueryId)
const tabIndex = this.findTabIndex(id) if (tabIndex >= 0) {
if (tabIndex >= 0) { this.$store.commit('deleteTab', tabIndex)
this.$store.commit('deleteTab', tabIndex) }
if (this.selectedQueriesIds.has(this.currentQueryId)) {
this.selectedQueriesIds.delete(this.currentQueryId)
}
} else {
this.queries = this.selectAll
? []
: this.queries.filter(query => !this.selectedQueriesIds.has(query.id))
const tabs = this.$store.state.tabs
for (let i = tabs.length - 1; i >= 0; i--) {
if (this.selectedQueriesIds.has(tabs[i].id)) {
this.$store.commit('deleteTab', i)
}
}
this.selectedQueriesIds.clear()
} }
this.selectedQueriesCount = this.selectedQueriesIds.size
this.saveQueriesInLocalStorage()
}, },
findTabIndex (id) { findTabIndex (id) {
return this.$store.state.tabs.findIndex(tab => tab.id === id) return this.$store.state.tabs.findIndex(tab => tab.id === id)
}, },
exportQuery (index) { exportQuery (index) {
this.currentQueryIndex = index let data
let name
// single operation
if (typeof index === 'number') {
console.log('single')
data = JSON.parse(JSON.stringify(this.showedQueries[index]))
name = data.name
delete data.id
delete data.createdAt
} else {
// group operation
data = this.selectAll
? JSON.parse(JSON.stringify(this.queries))
: this.queries.filter(query => this.selectedQueriesIds.has(query.id))
name = 'My sqliteviz queries'
data.forEach(query => {
delete query.id
delete query.createdAt
})
}
// export data to file
const downloader = this.$refs.downloader const downloader = this.$refs.downloader
const currentQuery = JSON.parse(JSON.stringify(this.queries[this.currentQueryIndex])) const json = JSON.stringify(data, null, 4)
delete currentQuery.id
delete currentQuery.createdAt
const json = JSON.stringify(currentQuery)
const blob = new Blob([json], { type: 'octet/stream' }) const blob = new Blob([json], { type: 'octet/stream' })
const url = window.URL.createObjectURL(blob) const url = window.URL.createObjectURL(blob)
downloader.href = url downloader.href = url
downloader.download = `${currentQuery.name}.json` downloader.download = `${name}.json`
downloader.click() downloader.click()
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(url)
}, },
@@ -241,6 +325,10 @@ export default {
importedQueries.forEach(query => { importedQueries.forEach(query => {
query.id = nanoid() query.id = nanoid()
query.createdAt = new Date() query.createdAt = new Date()
if (this.selectAll) {
this.selectedQueriesIds.add(query.id)
this.selectedQueriesCount = this.selectedQueriesIds.size
}
}) })
this.queries = this.queries.concat(importedQueries) this.queries = this.queries.concat(importedQueries)
@@ -251,6 +339,24 @@ export default {
}, },
saveQueriesInLocalStorage () { saveQueriesInLocalStorage () {
localStorage.setItem('myQueries', JSON.stringify(this.queries)) localStorage.setItem('myQueries', JSON.stringify(this.queries))
},
toggleSelectAll (checked) {
this.selectAll = checked
this.$refs.rowCheckBox.forEach(item => { item.checked = checked })
this.selectedQueriesIds = checked ? new Set(this.queries.map(query => query.id)) : new Set()
this.selectedQueriesCount = this.selectedQueriesIds.size
},
toggleRow (checked, id) {
if (checked) {
this.selectedQueriesIds.add(id)
} else {
if (this.selectedQueriesIds.size === this.queries.length) {
this.$refs.mainCheckBox.checked = false
this.selectAll = false
}
this.selectedQueriesIds.delete(id)
}
this.selectedQueriesCount = this.selectedQueriesIds.size
} }
} }
} }
@@ -275,24 +381,43 @@ export default {
max-width: 1500px; max-width: 1500px;
width: 100%; width: 100%;
} }
.fixed-header:first-child {
display: flex;
align-items: center;
padding-left: 12px;
}
.fixed-header:first-child .name-th {
margin-left: 24px;
}
table { table {
margin-top: 0; margin-top: 0;
} }
tbody tr td { tbody tr td {
overflow: hidden;
min-width: 0; min-width: 0;
text-overflow: ellipsis;
padding: 0 24px;
line-height: 40px; line-height: 40px;
} }
tbody tr td:first-child { tbody tr td:first-child {
width: 70%; width: 70%;
max-width: 0; max-width: 0;
padding: 0 12px;
} }
tbody tr td:last-child { tbody tr td:last-child {
width: 30%; width: 30%;
max-width: 0; max-width: 0;
padding: 0 24px;
}
tbody .cell-data {
display: flex;
align-items: center;
max-width: 100%;
}
tbody .cell-data div.name {
overflow: hidden;
text-overflow: ellipsis;
margin-left: 24px;
} }
tbody tr:hover td { tbody tr:hover td {