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

45 Commits
0.0.1 ... 0.4.0

Author SHA1 Message Date
lana-k
4c685b6d7c move to dist 2020-11-10 21:37:32 +01:00
lana-k
a37edc501b create an archive 2020-11-10 21:33:35 +01:00
lana-k
46a9d1613b minor changes 2020-11-10 20:12:06 +01:00
lana-k
b08265fab9 minor changes 2020-11-10 20:05:40 +01:00
lana-k
f1b5f5e3c7 rename github action 2020-11-10 18:58:12 +01:00
lana-k
e08fdbecbc fix Create release action 2020-11-10 18:53:42 +01:00
lana-k
a5ba1cb821 add release.yml 2020-11-06 16:58:29 +01:00
lana-k
7da1cb36fa Add tooltips #9 2020-11-06 12:20:44 +01:00
lana-k
57c8fe5bc8 Add table filter in schema sidebar #7 2020-11-05 17:29:38 +01:00
lana-k
e7d3da8869 fade out in schema 2020-11-05 13:48:02 +01:00
lana-k
5dc80751c4 add padding to schema 2020-11-04 22:31:38 +01:00
lana-k
3e52dcac2c move run button to the menu 2020-11-04 22:18:04 +01:00
lana-k
1037185a6a add predefined queries 2020-11-04 19:13:27 +01:00
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
lana-k
f16fab2d7a hide Export and Delete (group functions) 2020-10-15 20:29:42 +02:00
lana-k
769c146d95 add import queries feature 2020-10-15 20:27:35 +02:00
lana-k
39d958de86 add export to file feature 2020-10-13 21:46:15 +02:00
lana-k
fbccb3d9be add Delete query feature 2020-10-13 21:13:47 +02:00
lana-k
f25a4d5c07 duplicate feature 2020-10-13 20:05:18 +02:00
lana-k
805f2861aa finish rename feature 2020-10-13 19:57:07 +02:00
lana-k
9c6aae7c02 add draft of Rename Query dialog 2020-10-12 22:49:52 +02:00
lana-k
d7782733ed stop propagating click on icons 2020-10-12 22:49:28 +02:00
lana-k
343dea6ba8 add close icon 2020-10-12 22:49:09 +02:00
lana-k
6a178a6436 style for secondary button 2020-10-12 22:48:34 +02:00
lana-k
dbc2e3d0f3 add VModal 2020-10-12 22:48:10 +02:00
lana-k
24e8e3e520 Update main.yml 2020-10-12 13:51:45 +02:00
38 changed files with 2735 additions and 917 deletions

View File

@@ -1,18 +1,16 @@
name: Deploy to GitHub Pages
name: Deploy to GitHub Pages and create release
on:
workflow_dispatch:
push:
tags:
- '*'
branches:
- master
- '*'
jobs:
deploy:
name: Deploy to GitHub Pages
name: Deploy to GitHub Pages and create release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v1
with:
@@ -22,6 +20,17 @@ jobs:
npm install
npm run build
- name: Create archive
run: |
cd dist
zip -9 -r dist.zip . -x "js/*.map"
- name: Create release
uses: ncipollo/release-action@v1
with:
artifacts: "dist/dist.zip"
token: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@3.6.2
with:

1215
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,26 @@
{
"name": "sqliteviz",
"version": "0.0.1",
"version": "0.3.0",
"private": true,
"scripts": {
"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",
"lint": "vue-cli-service lint"
},
"dependencies": {
"codemirror": "^5.57.0",
"core-js": "^3.6.5",
"plotly.js": "^1.54.6",
"nanoid": "^3.1.12",
"plotly.js": "^1.57.1",
"react": "^16.13.1",
"react-chart-editor": "^0.41.7",
"react-chart-editor": "^0.42.0",
"react-dom": "^16.13.1",
"sql.js": "^1.3.0",
"sqlite-parser": "^1.0.1",
"vue": "^2.6.11",
"vue-codemirror": "^4.0.6",
"vue-js-modal": "^2.0.0-rc.6",
"vue-router": "^3.2.0",
"vuejs-paginate": "^2.1.0",
"vuera": "^0.2.7",

47
public/queries.json Normal file
View File

@@ -0,0 +1,47 @@
{
"query": "select * from invoices",
"chart": {
"data": [
{
"type": "scatter",
"mode": "lines",
"x": null,
"xsrc": "InvoiceId",
"meta": {
"columnNames": {
"x": "InvoiceId",
"y": "Total"
}
},
"y": null,
"ysrc": "Total"
}
],
"layout": {
"xaxis": {
"range": [
1,
412
],
"autorange": true,
"type": "linear"
},
"yaxis": {
"range": [
-0.39166666666666683,
27.241666666666667
],
"autorange": true,
"type": "linear"
},
"autosize": true,
"mapbox": {
"style": "open-street-map"
}
},
"frames": []
},
"name": "Invoices",
"id": "ieZfcITwDUTADwOmQlYyL",
"createdAt": "2020-11-03T14:17:49.524Z"
}

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

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 9H13V7H11V9ZM12 20C7.59 20 4 16.41 4 12C4 7.59 7.59 4 12 4C16.41 4 20 7.59 20 12C20 16.41 16.41 20 12 20ZM12 2C10.6868 2 9.38642 2.25866 8.17317 2.7612C6.95991 3.26375 5.85752 4.00035 4.92893 4.92893C3.05357 6.8043 2 9.34784 2 12C2 14.6522 3.05357 17.1957 4.92893 19.0711C5.85752 19.9997 6.95991 20.7362 8.17317 21.2388C9.38642 21.7413 10.6868 22 12 22C14.6522 22 17.1957 20.9464 19.0711 19.0711C20.9464 17.1957 22 14.6522 22 12C22 10.6868 21.7413 9.38642 21.2388 8.17317C20.7362 6.95991 19.9997 5.85752 19.0711 4.92893C18.1425 4.00035 17.0401 3.26375 15.8268 2.7612C14.6136 2.25866 13.3132 2 12 2V2ZM11 17H13V11H11V17Z" fill="#A2B1C6"/>
</svg>

After

Width:  |  Height:  |  Size: 754 B

View File

@@ -1,3 +1,12 @@
button {
box-sizing: border-box;
height: 36px;
padding: 0 12px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
button:focus {
outline: none;
}
@@ -5,16 +14,10 @@ button:focus {
button.primary {
background: var(--color-accent);
border: 1px solid var(--color-accent-shade);
box-sizing: border-box;
border-radius: var(--border-radius-big);
height: 36px;
padding: 0 12px;
min-width: 83px;
color: var(--color-text-light);
text-shadow: var(--shadow);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
button.primary:hover {
@@ -22,8 +25,6 @@ button.primary:hover {
border: 1px solid var(--color-accent-shade);
color: var(--color-text-light);
text-shadow: var(--shadow);
font-size: 14px;
font-weight: 600;
}
button.primary:disabled {
@@ -34,16 +35,24 @@ button.primary:disabled {
cursor: default;
}
button.secondary {
background: white;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-big);
min-width: 83px;
color: var(--color-text-base);
}
button.secondary:hover {
border: 1px solid var(--color-text-light-2);
color: var(--color-text-active);
}
button.toolbar {
background: transparent;
border: none;
box-sizing: border-box;
height: 36px;
padding: 0 12px;
color: var(--color-text-base);
font-size: 14px;
font-weight: 600;
cursor: pointer;
padding: 0;
}
button.toolbar:hover {

View File

@@ -0,0 +1,40 @@
.dialog {
border-radius: var(--border-radius-big);
box-shadow: 0px 2px 9px rgba(80, 103, 132, 0.8);
}
.dialog-header {
height: 46px;
line-height: 46px;
padding: 0 22px 0 12px;
color: var(--color-text-base);
font-size: 16px;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-body {
min-height: 60px;
background-color: var(--color-bg-light);
padding: 24px;
border-top: 1px solid var(--color-border-light);
color: var(--color-text-base);
font-size: 13px;
}
.dialog-buttons-container {
display: flex;
justify-content: flex-end;
background-color: var(--color-bg-light);
padding: 24px;
}
.dialog-buttons-container button {
margin-left: 16px;
}
.vm--overlay {
background-color: rgba(162, 177, 198, 0.5);
}

View File

@@ -1,18 +0,0 @@
input[type="text"] {
background: var(--color-white);
border: 1px solid var(--color-border);
color: var(--color-text-base);
border-radius: var(--border-radius-medium-2);
height: 36px;
padding: 0 8px;
font-size: 12px;
}
input[type="text"]::placeholder {
color: var(--color-text-light-2);
}
input[type="text"]:focus {
outline: none;
}

View File

@@ -0,0 +1,15 @@
.icon-tooltip {
visibility: hidden;
background-color: rgba(80, 103, 132, 0.85);
color: #fff;
text-align: center;
font-size: 11px;
padding: 0 6px;
line-height: 19px;;
position: fixed;
z-index: 5;
height: 19px;
border-radius: var(--border-radius-medium);
white-space: nowrap;
z-index: 999;
}

View File

@@ -10,6 +10,8 @@
--color-blue-medium: #119DFF;
--color-blue-dark: #0D76BF;
--color-blue-dark-2: #2A3F5F;
--color-red: #EF553B;
--color-yellow: #FBEFCB;
@@ -17,6 +19,7 @@
--color-bg-light-2: var(--color-gray-light-2);
--color-bg-light-3: var(--color-gray-light-5);
--color-bg-dark: var(--color-gray-dark);
--color-bg-warning: var(--color-yellow);
--color-accent: var(--color-blue-medium);
--color-accent-shade: var(--color-blue-dark);
--color-border-light: var(--color-gray-light-2);
@@ -25,6 +28,7 @@
--color-text-light-2: var(--color-gray-medium);
--color-text-base: var(--color-gray-dark);
--color-text-active: var(--color-blue-dark-2);
--color-text-error: var(--color-red);
--shadow: 0 1px 2px rgba(42, 63, 95, 0.7);

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

@@ -0,0 +1,131 @@
<template>
<div v-show="visible" 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
: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);
min-height: 242px;
}
>>> .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,67 @@
<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
}
},
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,23 +8,85 @@
<button
v-if="$store.state.tabs.length > 0"
class="primary"
:disabled="!$store.state.currentTab.isUnsaved"
@click="saveQuery"
:disabled="currentQuery && !$store.state.schema || !currentQuery.query"
@click="currentQuery.execute"
>
Run
</button>
<button
v-if="$store.state.tabs.length > 0"
class="primary"
:disabled="currentQuery && !currentQuery.isUnsaved"
@click="checkQueryBeforeSave"
>
Save
</button>
<button class="primary" @click="createNewQuery">Create</button>
</div>
<!--Save Query dialog -->
<modal name="save" classes="dialog" height="auto">
<div class="dialog-header">
Save query
<close-icon @click="$modal.hide('save')"/>
</div>
<div class="dialog-body">
<div v-show="isPredefined" id="save-note">
<img :src="require('@/assets/images/info.svg')">
Note: Predefined queries can't be edited.
That's why your modifications will be saved as a new query. Enter the name for it.
</div>
<text-field
label="Query name"
:error-msg="errorMsg"
v-model="name"
width="100%"
/>
</div>
<div class="dialog-buttons-container">
<button class="secondary" @click="$modal.hide('save')">Cancel</button>
<button class="primary" @click="saveQuery">Save</button>
</div>
</modal>
</nav>
</template>
<script>
import { nanoid } from 'nanoid'
import TextField from '@/components/TextField'
import CloseIcon from '@/components/svg/close'
export default {
name: 'MainMenu',
components: {
TextField,
CloseIcon
},
data () {
return {
name: '',
errorMsg: null
}
},
computed: {
currentQuery () {
return this.$store.state.currentTab
},
isPredefined () {
if (this.currentQuery) {
return this.currentQuery.isPredefined
} else {
return false
}
}
},
created () {
this.$root.$on('createNewQuery', this.createNewQuery)
},
methods: {
createNewQuery () {
const tab = {
id: Number(new Date()),
id: nanoid(),
name: null,
tempName: this.$store.state.untitledLastIndex
? `Untitled ${this.$store.state.untitledLastIndex}`
@@ -33,42 +95,74 @@ export default {
}
this.$store.commit('addTab', tab)
this.$store.commit('setCurrentTabId', tab.id)
this.$store.commit('updateUntitledLastIndex')
},
checkQueryBeforeSave () {
this.errorMsg = null
const isFromScratch = !this.currentQuery.initName
if (isFromScratch || this.isPredefined) {
this.$modal.show('save')
} else {
this.saveQuery()
}
},
saveQuery () {
const currentQuery = this.$store.state.currentTab
const isFromScratch = !this.$store.state.currentTab.initName
const isFromScratch = !this.currentQuery.initName
if ((isFromScratch || this.isPredefined) && !this.name) {
this.errorMsg = 'Query name can\'t be empty'
return
}
const dataSet = this.currentQuery.result
const tabView = this.currentQuery.view
// Prepare query
const value = {
id: currentQuery.id,
query: currentQuery.query
// TODO: save plotly settings
}
if (isFromScratch) {
value.name = prompt('query name')
// TODO: create dialog
this.$store.commit('updateTabName', { index: currentQuery.tabIndex, newName: value.name })
value.createdAt = new Date()
} else {
value.name = currentQuery.initName
id: this.isPredefined ? nanoid() : this.currentQuery.id,
query: this.currentQuery.query,
chart: this.currentQuery.getChartSatateForSave(),
name: (!this.isPredefined && this.currentQuery.initName) || this.name,
createdAt: new Date()
}
// Save query
let myQueries = JSON.parse(localStorage.getItem('myQueries'))
if (!myQueries) {
myQueries = [value]
} else if (isFromScratch) {
} else if (isFromScratch || this.isPredefined) {
myQueries.push(value)
} else {
const queryIndex = myQueries.findIndex(query => query.id === currentQuery.id)
const queryIndex = myQueries.findIndex(query => query.id === this.currentQuery.id)
value.createdAt = myQueries[queryIndex].createdAt
myQueries[queryIndex] = value
}
localStorage.setItem('myQueries', JSON.stringify(myQueries))
currentQuery.isUnsaved = false
// Update tab
this.$store.commit('updateTab', {
index: this.currentQuery.tabIndex,
name: value.name,
id: value.id,
query: value.query,
chart: value.chart,
isUnsaved: false
})
// Restore data:
// e.g. if we save predefined query the tab will be created again
// (because of new id) and
// it will be without sql result and has default view - table.
// That's why we need to restore data and view
this.$nextTick(() => {
this.currentQuery.result = dataSet
this.currentQuery.view = tabView
})
// Hide dialog
this.$modal.hide('save')
}
}
}
</script>
<style scoped>
nav {
height: 68px;
@@ -83,6 +177,7 @@ nav {
left: 0;
width: 100vw;
padding: 0 52px;
z-index: 999;
}
a {
font-size: 18px;
@@ -97,4 +192,13 @@ a.router-link-active {
button {
margin-left: 16px;
}
#save-note {
margin-bottom: 24px;
display: flex;
align-items: flex-start;
}
#save-note img {
margin: -3px 6px 0 0;
}
</style>

View File

@@ -1,10 +1,12 @@
<template>
<div>
<input type="text" placeholder="Search table"/>
<div id="schema-container">
<div id="schema-filter">
<text-field placeholder="Search table" width="100%" v-model="filter"/>
</div>
<div id="db">
<div @click="schemaVisible = !schemaVisible" class="db-name">
<svg
:style="{transform: schemaVisible ? 'rotate(90deg)' : 'rotate(0)'}"
:style="{ transform: schemaVisible ? 'rotate(90deg)' : 'rotate(0)' }"
class="chevron-icon"
width="9"
height="9"
@@ -22,25 +24,14 @@
<div class="db-edit">
<input type="file" id="actual-btn" ref="dbfile" hidden @change="changeDb"/>
<label for="actual-btn">
<svg
class="db-edit-icon"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M3 10.5V12.75C3 14.25 5.2875 15.54 8.25 15.75V13.5825L8.3475 13.5C5.34 13.32 3 12.045 3 10.5ZM9 9.75C5.685 9.75 3 8.4075 3 6.75V9C3 10.6575 5.685 12 9 12C9.2925 12 9.5775 12 9.87 12L12.75 9.09C11.55 9.54 10.2825 9.75 9 9.75ZM9 2.25C5.685 2.25 3 3.5925 3 5.25C3 6.9075 5.685 8.25 9 8.25C12.315 8.25 15 6.9075 15 5.25C15 3.5925 12.315 2.25 9 2.25ZM15.75 8.3475C15.6375 8.3475 15.5325 8.3925 15.4575 8.475L14.7075 9.225L16.245 10.725L16.995 9.975C17.1525 9.825 17.16 9.57 16.995 9.3975L16.065 8.475C15.99 8.3925 15.885 8.3475 15.78 8.3475H15.75ZM14.28 9.66L9.75 14.205V15.75H11.295L15.84 11.1975L14.28 9.66Z"
fill="#A2B1C6"/>
</svg>
<change-db-icon />
</label>
<span class="db-edit-tooltip">Change database</span>
</div>
</div>
<div v-show="schemaVisible" class="schema">
<div v-if="schemaVisible" class="schema">
<table-description
v-for="(table, index) in schema"
:key="index"
v-for="table in schema"
:key="table[0]"
:name="table[0]"
:sql="table[1]"
/>
@@ -50,19 +41,33 @@
<script>
import TableDescription from '@/components/TableDescription'
import TextField from '@/components/TextField'
import ChangeDbIcon from '@/components/svg/changeDb'
export default {
name: 'Schema',
components: { TableDescription },
components: {
TableDescription,
TextField,
ChangeDbIcon
},
data () {
return {
schemaVisible: true,
worker: this.$store.state.worker
filter: null
}
},
computed: {
schema () {
return this.$store.state.schema
if (!this.$store.state.schema) {
return []
}
return !this.filter
? this.$store.state.schema
: this.$store.state.schema.filter(
table => table[0].toUpperCase().indexOf(this.filter.toUpperCase()) !== -1
)
},
dbName () {
return this.$store.state.dbName
@@ -70,37 +75,32 @@ export default {
},
methods: {
changeDb () {
const dbName = this.$refs.dbfile.value.substr(this.$refs.dbfile.value.lastIndexOf('\\') + 1)
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)
this.$db.loadDb(this.$refs.dbfile.files[0])
}
}
}
</script>
<style scoped>
#schema-container {
position: relative;
padding-bottom: 24px;
}
.schema {
margin-left: 12px;
padding: 0 12px;
}
#schema-filter {
padding: 32px 12px;
position: sticky;
position: -webkit-sticky;
top: 0;
width: 100%;
height: 100px;
box-sizing: border-box;
background-image: linear-gradient(white 73%, transparent);;
z-index: 2;
}
.schema, .db-name {
color: var(--color-text-base);
@@ -110,6 +110,8 @@ export default {
#db {
display: flex;
align-items: center;
margin-top: -5px;
padding: 0 12px;
}
.db-name {
@@ -123,36 +125,4 @@ export default {
>>> .table-name:hover .chevron-icon path {
fill: #506784;
}
.db-edit {
position: relative;
}
.db-edit-icon {
display: block;
}
.db-edit-icon:hover path{
fill: var(--color-accent);
cursor: pointer;
}
.db-edit-tooltip {
visibility: hidden;
background-color: rgba(80, 103, 132, 0.75);
color: #fff;
text-align: center;
font-size: 11px;
padding: 0 6px;
line-height: 19px;;
position: absolute;
z-index: 5;
height: 19px;
left: 24px;
top: -12px;
border-radius: var(--border-radius-medium);
white-space: nowrap;
}
.db-edit:hover .db-edit-tooltip {
visibility: visible;
}
</style>

View File

@@ -1,8 +1,13 @@
<template>
<div
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
class="splitpanes__pane"
ref="left"
@@ -12,13 +17,21 @@
>
<slot name="left-pane" />
</div>
<splitter
<!-- Splitter start-->
<div
class="splitpanes__splitter"
@mousedown="onMouseDown"
@toggle="toggleFirstPane"
:expanded="paneBefore.size !== 0"
/>
@touchstart="onMouseDown"
>
<div class="toggle-btn" @click="toggleFirstPane">
<img
class="direction-icon"
:src="require('@/assets/images/chevron.svg')"
:style="directionIconStyle"
>
</div>
</div>
<!-- splitter end -->
<div
class="splitpanes__pane"
ref="right"
@@ -30,33 +43,58 @@
</template>
<script>
import Splitter from '@/components/splitter'
export default {
name: 'splitpanes',
components: { Splitter },
name: 'Splitpanes',
props: {
horizontal: { type: Boolean, default: false },
before: { type: Object },
after: { type: Object }
},
data: () => ({
container: null,
paneBefore: null,
paneAfter: null,
beforeMinimising: 20,
touch: {
mouseDown: false,
dragging: false
data () {
return {
container: null,
paneBefore: this.before,
paneAfter: this.after,
beforeMinimising: this.before.size,
touch: {
mouseDown: false,
dragging: false
},
movableSplitter: {
top: 0,
left: 0,
visibility: 'hidden'
}
}
}),
},
computed: {
styles () {
return [
{ [this.horizontal ? 'height' : 'width']: `${this.paneBefore.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).
event.preventDefault()
this.touch.dragging = true
this.calculatePanesSize(this.getCurrentMouseDrag(event))
this.$set(this.movableSplitter, 'visibility', 'visible')
this.moveSplitter(event)
}
},
onMouseUp () {
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)
// in order to prevent emitting `splitter-click` event if splitter was dragged.
setTimeout(() => {
@@ -109,8 +163,9 @@ export default {
// Get the cursor position relative to the splitpane container.
getCurrentMouseDrag (event) {
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 {
x: clientX - rect.left,
y: clientY - rect.top
@@ -126,27 +181,26 @@ export default {
return drag * 100 / containerSize
},
calculatePanesSize (drag) {
const dragPercentage = this.getCurrentDragPercentage(drag)
// If not pushing other panes, panes to resize are right before and right after splitter.
moveSplitter (event) {
const dragPercentage = this.getCurrentDragPercentage(this.getCurrentMouseDrag(event))
const paneBefore = this.paneBefore
const paneAfter = this.paneAfter
const paneBeforeMaxReached = paneBefore.max < 100 && (dragPercentage >= paneBefore.max)
const paneAfterMaxReached = paneAfter.max < 100 && (dragPercentage <= 100 - paneAfter.max)
const dir = this.horizontal ? 'top' : 'left'
// Prevent dragging beyond pane max.
if (paneBeforeMaxReached || paneAfterMaxReached) {
if (paneBeforeMaxReached) {
paneBefore.size = paneBefore.max
paneAfter.size = Math.max(100 - paneBefore.max, 0)
this.$set(this.movableSplitter, dir, paneBefore.max)
} else {
paneBefore.size = Math.max(100 - paneAfter.max, 0)
paneAfter.size = paneAfter.max
this.$set(this.movableSplitter, dir, Math.max(100 - paneAfter.max, 0))
}
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 () {
if (this.paneBefore.size > 0) {
@@ -160,10 +214,6 @@ export default {
},
mounted () {
this.container = this.$refs.container
},
created () {
this.paneBefore = this.before
this.paneAfter = this.after
}
}
</script>
@@ -172,6 +222,7 @@ export default {
.splitpanes {
display: flex;
height: 100%;
position: relative;
}
.splitpanes--vertical {flex-direction: row;}
@@ -183,4 +234,68 @@ export default {
height: 100%;
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>

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,12 +17,12 @@
class="table-container"
ref="table-container"
@scroll="onScrollTable"
:style="{height: `${height}px`}"
:style="{maxHeight: `${height}px`}"
>
<table ref="table">
<thead>
<tr>
<th v-for="(th,index) in data.columns" :key="index" ref="th">
<th v-for="(th,index) in dataSet.columns" :key="index" ref="th">
<div class="cell-data" :style="cellStyle">{{ th }}</div>
</th>
</tr>
@@ -39,7 +39,7 @@
</div>
<div class="table-footer">
<div class="table-footer-count">
{{ data.values.length}} {{data.values.length === 1 ? 'row' : 'rows'}} retrieved
{{ dataSet.values.length}} {{dataSet.values.length === 1 ? 'row' : 'rows'}} retrieved
</div>
<pager v-show="pageCount > 1" :page-count="pageCount" v-model="currentPage" />
</div>
@@ -52,17 +52,18 @@ import Pager from '@/components/Pager'
export default {
name: 'SqlTable',
components: { Pager },
props: ['data', 'height'],
props: ['dataSet', 'height'],
data () {
return {
header: null,
tableWidth: null,
currentPage: 1
currentPage: 1,
resizeObserver: null
}
},
computed: {
cellStyle () {
const eq = this.tableWidth / this.data.columns.length
const eq = this.tableWidth / this.dataSet.columns.length
return { maxWidth: `${Math.max(eq, 100)}px` }
},
@@ -70,11 +71,11 @@ export default {
return Math.max(Math.floor(this.height / 40), 20)
},
pageCount () {
return Math.ceil(this.data.values.length / this.pageSize)
return Math.ceil(this.dataSet.values.length / this.pageSize)
},
currentPageData () {
const start = (this.currentPage - 1) * this.pageSize
return this.data.values.slice(start, start + this.pageSize)
return this.dataSet.values.slice(start, start + this.pageSize)
}
},
methods: {
@@ -94,12 +95,16 @@ export default {
}
},
mounted () {
new ResizeObserver(this.calculateHeadersWidth).observe(this.$refs.table)
this.resizeObserver = new ResizeObserver(this.calculateHeadersWidth)
this.resizeObserver.observe(this.$refs.table)
this.calculateHeadersWidth()
},
beforeDestroy () {
this.resizeObserver.unobserve(this.$refs.table)
},
watch: {
currentPageData: 'calculateHeadersWidth',
data () {
dataSet () {
this.currentPage = 1
}
}

197
src/components/Tab.vue Normal file
View File

@@ -0,0 +1,197 @@
<template>
<div class="tab-content-container" v-show="isActive">
<splitpanes
class="query-results-splitter"
horizontal
:before="{ size: 50, max: 70 }"
:after="{ size: 50, max: 100 }"
>
<template #left-pane>
<div class="query-editor">
<sql-editor v-model="query" />
</div>
</template>
<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-set="result" :height="tableViewHeight" />
</div>
<chart
:visible="view === 'chart'"
:sql-result="result"
:init-chart="initChart"
ref="chart"
@update="isUnsaved = true"
/>
</div>
</template>
</splitpanes>
</div>
</template>
<script>
import SqlTable from '@/components/SqlTable'
import SqlEditor from '@/components/SqlEditor'
import Splitpanes from '@/components/Splitpanes'
import ViewSwitcher from '@/components/ViewSwitcher'
import Chart from '@/components/Chart'
export default {
name: 'Tab',
props: ['id', 'initName', 'initQuery', 'initChart', 'tabIndex', 'isPredefined'],
components: {
SqlEditor,
SqlTable,
Splitpanes,
ViewSwitcher,
Chart
},
data () {
return {
query: this.initQuery,
result: null,
view: 'table',
tableViewHeight: 0,
isUnsaved: !this.initName,
isGettingResults: false,
error: null,
resizeObserver: null
}
},
computed: {
isActive () {
return this.id === this.$store.state.currentTabId
}
},
created () {
this.$store.commit('setCurrentTab', this)
},
mounted () {
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.bottomPane)
this.calculateTableHeight()
},
beforeDestroy () {
this.resizeObserver.unobserve(this.$refs.bottomPane)
},
watch: {
isActive () {
if (this.isActive) {
this.$store.commit('setCurrentTab', this)
}
},
query () {
this.isUnsaved = true
},
isUnsaved () {
this.$store.commit('updateTabState', { index: this.tabIndex, newValue: this.isUnsaved })
}
},
methods: {
// Run a command in the database
execute () {
// this.$refs.output.textContent = 'Fetching results...' */
this.isGettingResults = true
this.result = null
this.error = null
this.$db.execute(this.query + ';')
.then(result => {
this.result = result
})
.catch(err => {
this.error = err
})
.finally(() => {
this.isGettingResults = false
})
},
handleResize () {
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 () {
const bottomPane = this.$refs.bottomPane
// 88 - view swittcher height
// 42 - table footer width
// 30 - desirable space after the table
// 5 - padding-bottom of rounded table container
// 40 - height of table header
const freeSpace = bottomPane.offsetHeight - 88 - 42 - 30 - 5 - 40
this.tableViewHeight = freeSpace - (freeSpace % 40)
},
getChartSatateForSave () {
return this.$refs.chart.getChartSatateForSave()
}
}
}
</script>
<style scoped>
.tab-content-container {
padding-top: 6px;
background-color: var(--color-bg-light);
border-top: 1px solid var(--color-border-light);
margin-top: -1px;
}
#bottomPane {
height: 100%;
}
.query-results-splitter {
height: calc(100vh - 110px);
background-color: var(--color-bg-light);
}
.query-editor {
padding: 52px;
display: flex;
flex-direction: column;
height: 100%;
max-height: 100%;
box-sizing: border-box;
min-height: 190px;
}
.table-view {
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>

View File

@@ -1,250 +0,0 @@
<template>
<div class="tab-content-container" v-show="isActive">
<splitpanes
class="query-results-splitter"
horizontal
:before="{ size: 50, max: 50 }"
:after="{ size: 50, max: 100 }"
>
<div slot="left-pane" class="query-editor">
<div class="codemirror-container">
<codemirror v-model="query" :options="cmOptions" @changes="onCmChange" ref="codemirror" />
</div>
<div class="run-btn-container">
<button class="primary run-btn" @click="execEditorContents">Run</button>
</div>
</div>
<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>
</div>
</template>
<script>
import SqlTable from '@/components/SqlTable'
import Splitpanes from '@/components/splitpanes'
import ViewSwitcher from '@/components/ViewSwitcher'
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 {
name: 'TabContent',
props: ['id', 'initName', 'initQuery', 'initPlotly', 'tabIndex'],
components: {
codemirror,
SqlTable,
Splitpanes,
ViewSwitcher,
PlotlyEditor
},
data () {
return {
plotly: plotly,
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,
view: 'table',
tableViewHeight: 0,
worker: this.$store.state.worker,
isUnsaved: !this.name
}
},
computed: {
isActive () {
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 () {
this.$store.commit('setCurrentTab', this)
},
mounted () {
new ResizeObserver(this.calculateTableHeight).observe(this.$refs.bottomPane)
this.calculateTableHeight()
},
watch: {
isActive () {
if (this.isActive) {
this.$store.commit('setCurrentTab', this)
}
},
query () {
this.isUnsaved = true
},
isUnsaved () {
this.$store.commit('updateTabState', { index: this.tabIndex, newValue: this.isUnsaved })
}
},
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
execute (commands) {
this.worker.onmessage = (event) => {
// if it was more than one select - take only the first one
this.result = event.data.results[0]
if (!this.result) {
console.log(event.data.error)
// return
}
// this.$refs.output.innerHTML = ''
}
this.worker.postMessage({ action: 'exec', sql: commands })
// this.$refs.output.textContent = 'Fetching results...'
},
execEditorContents () {
this.execute(this.query + ';')
},
calculateTableHeight () {
const bottomPane = this.$refs.bottomPane
// 88 - view swittcher height
// 42 - table footer width
// 30 - desirable space after the table
// 5 - padding-bottom of rounded table container
// 40 - height of table header
const freeSpace = bottomPane.offsetHeight - 88 - 42 - 30 - 5 - 40
this.tableViewHeight = freeSpace - (freeSpace % 40)
}
}
}
</script>
<style scoped>
.tab-content-container {
padding-top: 6px;
background-color: var(--color-bg-light);
border-top: 1px solid var(--color-border-light);
margin-top: -1px;
}
#bottomPane {
height: 100%;
}
.query-results-splitter {
height: calc(100vh - 110px);
background-color: var(--color-bg-light);
}
.run-btn {
margin-top: 24px;
}
.query-editor {
padding: 52px 52px 24px;
display: flex;
flex-direction: column;
height: 100%;
max-height: 100%;
box-sizing: border-box;
min-height: 150px;
}
.codemirror-container {
flex-grow: 1;
min-height: 0;
}
.run-btn-container {
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 {
margin: 0 52px;
}
</style>

View File

@@ -1,9 +1,9 @@
<template>
<div>
<div id="tabs__header">
<div id="tabs-container">
<div id="tabs__header" v-if="tabs.length > 0">
<div
v-for="(tab, index) in tabs"
:key="tab.id"
:key="index"
@click="selectTab(tab.id)"
:class="[{'tab__selected': (tab.id === selectedIndex)}, 'tab']"
>
@@ -29,22 +29,30 @@
</div>
</div>
</div>
<tab-content
<tab
v-for="(tab, index) in tabs"
:key="tab.id"
:id="tab.id"
:init-name="tab.name"
:init-query="tab.query"
:init-chart="tab.chart"
:is-predefined="tab.isPredefined"
: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>
</template>
<script>
import TabContent from '@/components/TabContent'
import Tab from '@/components/Tab'
export default {
components: {
TabContent
Tab
},
data () {
return {
@@ -70,6 +78,10 @@ export default {
</script>
<style>
#tabs-container {
position: relative;
height: 100%;
}
#tabs__header {
display: flex;
margin: 0;
@@ -129,4 +141,19 @@ export default {
fill: var(--color-text-base);
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;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<div>
<div :class="['text-field-label', { error: errorMsg }]">{{ label }}</div>
<input
type="text"
:placeholder="placeholder"
:class="{ error: errorMsg }"
:style="{ width: width }"
:value="value"
@input="$emit('input', $event.target.value)"
/>
<div class="text-field-error">{{ errorMsg }}</div>
</div>
</template>
<script>
export default {
name: 'textField',
props: ['placeholder', 'label', 'errorMsg', 'value', 'width']
}
</script>
<style scoped>
input {
background: var(--color-white);
border: 1px solid var(--color-border);
color: var(--color-text-base);
border-radius: var(--border-radius-medium-2);
height: 36px;
padding: 0 8px;
font-size: 13px;
box-sizing: border-box;
}
input::placeholder {
color: var(--color-text-light-2);
}
input:focus {
outline: none;
}
input.error {
border-color: var(--color-text-error);
}
.text-field-label {
font-size: 12px;
color: var(--color-text-base);
padding-left: 8px;
margin-bottom: 2px;
}
.text-field-label.error {
color: var(--color-text-error);
}
.text-field-error {
color: var(--color-text-error);
font-size: 12px;
padding-left: 8px;
margin-top: 2px;
}
</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>

View File

@@ -0,0 +1,41 @@
<template>
<div>
<svg
class="db-edit-icon"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@mouseover="showTooltip"
@mouseout="$set(tooltipStyle, 'visibility', 'hidden')"
>
<path
d="M3 10.5V12.75C3 14.25 5.2875 15.54 8.25 15.75V13.5825L8.3475 13.5C5.34 13.32 3 12.045 3 10.5ZM9 9.75C5.685 9.75 3 8.4075 3 6.75V9C3 10.6575 5.685 12 9 12C9.2925 12 9.5775 12 9.87 12L12.75 9.09C11.55 9.54 10.2825 9.75 9 9.75ZM9 2.25C5.685 2.25 3 3.5925 3 5.25C3 6.9075 5.685 8.25 9 8.25C12.315 8.25 15 6.9075 15 5.25C15 3.5925 12.315 2.25 9 2.25ZM15.75 8.3475C15.6375 8.3475 15.5325 8.3925 15.4575 8.475L14.7075 9.225L16.245 10.725L16.995 9.975C17.1525 9.825 17.16 9.57 16.995 9.3975L16.065 8.475C15.99 8.3925 15.885 8.3475 15.78 8.3475H15.75ZM14.28 9.66L9.75 14.205V15.75H11.295L15.84 11.1975L14.28 9.66Z"
fill="#A2B1C6"
/>
</svg>
<span class="icon-tooltip" :style="tooltipStyle">
Change database
</span>
</div>
</template>
<script>
import tooltipMixin from '@/mixins/tooltips'
export default {
name: 'changeDbIcon',
mixins: [tooltipMixin]
}
</script>
<style scoped>
.db-edit-icon {
display: block;
cursor: pointer;
}
.db-edit-icon:hover path {
fill: var(--color-accent);
}
</style>

View File

@@ -0,0 +1,20 @@
<template>
<svg @click.stop="$emit('click')" class="icon" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 1.41L12.59 0L7 5.59L1.41 0L0 1.41L5.59 7L0 12.59L1.41 14L7 8.41L12.59 14L14 12.59L8.41 7L14 1.41Z" fill="#A2B1C6"/>
</svg>
</template>
<script>
export default {
name: 'CloseIcon'
}
</script>
<style scoped>
.icon {
cursor: pointer;
}
.icon:hover path {
fill: var(--color-text-active);
}
</style>

View File

@@ -1,12 +1,33 @@
<template>
<svg class="icon" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.25 15.75H6V5.25H14.25V15.75ZM14.25 3.75H6C5.60218 3.75 5.22064 3.90804 4.93934 4.18934C4.65804 4.47064 4.5 4.85218 4.5 5.25V15.75C4.5 16.1478 4.65804 16.5294 4.93934 16.8107C5.22064 17.092 5.60218 17.25 6 17.25H14.25C14.6478 17.25 15.0294 17.092 15.3107 16.8107C15.592 16.5294 15.75 16.1478 15.75 15.75V5.25C15.75 4.85218 15.592 4.47064 15.3107 4.18934C15.0294 3.90804 14.6478 3.75 14.25 3.75ZM12 0.75H3C2.60218 0.75 2.22064 0.908035 1.93934 1.18934C1.65804 1.47064 1.5 1.85218 1.5 2.25V12.75H3V2.25H12V0.75Z" fill="#A2B1C6"/>
</svg>
<span>
<svg
class="icon"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@click.stop="$emit('click')"
@mouseover="showTooltip"
@mouseout="$set(tooltipStyle, 'visibility', 'hidden')"
>
<path
d="M14.25 15.75H6V5.25H14.25V15.75ZM14.25 3.75H6C5.60218 3.75 5.22064 3.90804 4.93934 4.18934C4.65804 4.47064 4.5 4.85218 4.5 5.25V15.75C4.5 16.1478 4.65804 16.5294 4.93934 16.8107C5.22064 17.092 5.60218 17.25 6 17.25H14.25C14.6478 17.25 15.0294 17.092 15.3107 16.8107C15.592 16.5294 15.75 16.1478 15.75 15.75V5.25C15.75 4.85218 15.592 4.47064 15.3107 4.18934C15.0294 3.90804 14.6478 3.75 14.25 3.75ZM12 0.75H3C2.60218 0.75 2.22064 0.908035 1.93934 1.18934C1.65804 1.47064 1.5 1.85218 1.5 2.25V12.75H3V2.25H12V0.75Z"
fill="#A2B1C6"
/>
</svg>
<span class="icon-tooltip" :style="tooltipStyle">
Duplicate query
</span>
</span>
</template>
<script>
import tooltipMixin from '@/mixins/tooltips'
export default {
name: 'CopyIcon'
name: 'CopyIcon',
mixins: [tooltipMixin]
}
</script>

View File

@@ -1,19 +1,40 @@
<template>
<svg class="icon" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.75 2.25V3H3V4.5H3.75V14.25C3.75 14.6478 3.90804 15.0294 4.18934 15.3107C4.47064 15.592 4.85218 15.75 5.25 15.75H12.75C13.1478 15.75 13.5294 15.592 13.8107 15.3107C14.092 15.0294 14.25 14.6478 14.25 14.25V4.5H15V3H11.25V2.25H6.75ZM5.25 4.5H12.75V14.25H5.25V4.5ZM6.75 6V12.75H8.25V6H6.75ZM9.75 6V12.75H11.25V6H9.75Z" fill="#A2B1C6"/>
</svg>
<span>
<svg
class="icon"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@click.stop="$emit('click')"
@mouseover="showTooltip"
@mouseout="$set(tooltipStyle, 'visibility', 'hidden')"
>
<path
d="M6.75 2.25V3H3V4.5H3.75V14.25C3.75 14.6478 3.90804 15.0294 4.18934 15.3107C4.47064 15.592 4.85218 15.75 5.25 15.75H12.75C13.1478 15.75 13.5294 15.592 13.8107 15.3107C14.092 15.0294 14.25 14.6478 14.25 14.25V4.5H15V3H11.25V2.25H6.75ZM5.25 4.5H12.75V14.25H5.25V4.5ZM6.75 6V12.75H8.25V6H6.75ZM9.75 6V12.75H11.25V6H9.75Z"
fill="#A2B1C6"
/>
</svg>
<span class="icon-tooltip" :style="tooltipStyle">
Delete query
</span>
</span>
</template>
<script>
import tooltipMixin from '@/mixins/tooltips'
export default {
name: 'DeleteIcon'
name: 'DeleteIcon',
mixins: [tooltipMixin]
}
</script>
<style scoped>
.icon {
vertical-align: middle;
margin: 0 0 0 12px;
margin: 0 12px;
}
.icon:hover path {

View File

@@ -1,12 +1,33 @@
<template>
<svg class="icon" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path 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>
<svg
class="icon"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@click.stop="$emit('click')"
@mouseover="showTooltip"
@mouseout="$set(tooltipStyle, 'visibility', 'hidden')"
>
<path
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>
</span>
</template>
<script>
import tooltipMixin from '@/mixins/tooltips'
export default {
name: 'ExportIcon'
name: 'ExportIcon',
mixins: [tooltipMixin]
}
</script>

View File

@@ -1,19 +1,40 @@
<template>
<svg class="icon" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.545 6.75L11.25 7.455L4.44 14.25H3.75V13.56L10.545 6.75ZM13.245 2.25C13.0575 2.25 12.8625 2.325 12.72 2.4675L11.3475 3.84L14.16 6.6525L15.5325 5.28C15.825 4.9875 15.825 4.5 15.5325 4.2225L13.7775 2.4675C13.6275 2.3175 13.44 2.25 13.245 2.25ZM10.545 4.6425L2.25 12.9375V15.75H5.0625L13.3575 7.455L10.545 4.6425Z" fill="#A2B1C6"/>
</svg>
<span>
<svg
class="icon"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@click.stop="$emit('click')"
@mouseover="showTooltip"
@mouseout="$set(tooltipStyle, 'visibility', 'hidden')"
>
<path
d="M10.545 6.75L11.25 7.455L4.44 14.25H3.75V13.56L10.545 6.75ZM13.245 2.25C13.0575 2.25 12.8625 2.325 12.72 2.4675L11.3475 3.84L14.16 6.6525L15.5325 5.28C15.825 4.9875 15.825 4.5 15.5325 4.2225L13.7775 2.4675C13.6275 2.3175 13.44 2.25 13.245 2.25ZM10.545 4.6425L2.25 12.9375V15.75H5.0625L13.3575 7.455L10.545 4.6425Z"
fill="#A2B1C6"
/>
</svg>
<span class="icon-tooltip" :style="tooltipStyle">
Rename query
</span>
</span>
</template>
<script>
import tooltipMixin from '@/mixins/tooltips'
export default {
name: 'RenameIcon'
name: 'RenameIcon',
mixins: [tooltipMixin]
}
</script>
<style scoped>
.icon {
vertical-align: middle;
margin: 0 12px 0 6px;
margin: 0 12px;
}
.icon:hover path {

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

@@ -3,15 +3,20 @@ import App from './App.vue'
import router from './router'
import store from './store'
import { VuePlugin } from 'vuera'
import VModal from 'vue-js-modal'
import db from '@/dataBase'
import '@/assets/styles/variables.css'
import '@/assets/styles/buttons.css'
import '@/assets/styles/textFields.css'
import '@/assets/styles/tables.css'
import '@/assets/styles/dialogs.css'
import '@/assets/styles/tooltips.css'
Vue.use(VuePlugin)
Vue.use(VModal)
Vue.config.productionTip = false
Vue.prototype.$db = db
new Vue({
router,

17
src/mixins/tooltips.js Normal file
View File

@@ -0,0 +1,17 @@
export default {
data () {
return {
tooltipStyle: {}
}
},
methods: {
showTooltip (e) {
this.tooltipStyle = {
visibility: 'visible',
position: 'fixed',
top: e.clientY - 12 + 'px',
left: e.clientX + 12 + 'px'
}
}
}
}

View File

@@ -8,11 +8,11 @@ export default new Vuex.Store({
schema: null,
dbFile: null,
dbName: null,
worker: new Worker('js/worker.sql-wasm.js'),
tabs: [],
currentTab: null,
currentTabId: null,
untitledLastIndex: 0
untitledLastIndex: 0,
predefinedQueries: []
},
mutations: {
saveSchema (state, schema) {
@@ -26,14 +26,29 @@ export default new Vuex.Store({
},
addTab (state, tab) {
state.tabs.push(tab)
if (!tab.name) {
state.untitledLastIndex += 1
}
},
updateTabName (state, { index, newName }) {
updateTab (state, { index, name, id, query, chart, isUnsaved }) {
const tab = state.tabs[index]
tab.name = newName
const oldId = tab.id
if (state.currentTabId === oldId) {
state.currentTabId = id
}
tab.id = id
if (name) { tab.name = name }
if (query) { tab.query = query }
if (chart) { tab.chart = chart }
if (isUnsaved !== undefined) { tab.isUnsaved = isUnsaved }
delete tab.isPredefined
Vue.set(state.tabs, index, tab)
},
updateTabState (state, { index, newValue }) {
console.log(index, newValue)
const tab = state.tabs[index]
tab.isUnsaved = newValue
Vue.set(state.tabs, index, tab)
@@ -46,6 +61,7 @@ export default new Vuex.Store({
state.currentTabId = state.tabs[index - 1].id
} else {
state.currentTabId = null
state.currentTab = null
state.untitledLastIndex = 0
}
state.tabs.splice(index, 1)
@@ -56,12 +72,14 @@ export default new Vuex.Store({
setCurrentTab (state, tab) {
state.currentTab = tab
},
updateUntitledLastIndex (state) {
state.untitledLastIndex += 1
updatePredefinedQueries (state, queries) {
if (Array.isArray(queries)) {
state.predefinedQueries = queries
} else {
state.predefinedQueries = [queries]
}
}
},
actions: {
},
modules: {
}
})

View File

@@ -16,47 +16,22 @@
</div>
</label>
<div id="error" class="error"></div>
<button id ="skip" class="secondary" @click="$router.push('/editor')">
Skip database connection for now
</button>
</div>
</template>
<script>
export default {
name: 'DbUpload',
data () {
return {
worker: this.$store.state.worker
}
},
methods: {
loadDb () {
const dbName = this.$refs.file.value.substr(this.$refs.file.value.lastIndexOf('\\') + 1)
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)
this.$db.loadDb(this.$refs.file.files[0])
},
dragover (event) {
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')) {
event.currentTarget.classList.remove('bg-gray-100')
event.currentTarget.classList.add('bg-green-300')
@@ -98,17 +73,21 @@ label {
border-radius: var(--border-radius-big);
}
#drop-area {
width: 231px;
height: 153px;
width: 628px;
height: 490px;
background-color: var(--color-bg-light-3);
border-radius: var(--border-radius-big);
color: var(--color-text-base);
font-size: 13px;
padding: 44px 15px;
padding: 200px 144px;
text-align: center;
box-sizing: border-box;
}
input {
display: none;
}
#skip {
position: absolute;
bottom: 50px;
}
</style>

View File

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

View File

@@ -13,7 +13,35 @@ import '@/assets/styles/scrollbars.css'
export default {
name: 'MainView',
components: { MainMenu }
components: { MainMenu },
created () {
this.readPredefinedQueries()
.then(queries => {
this.$store.commit('updatePredefinedQueries', queries)
})
.catch(console.error)
},
methods: {
readPredefinedQueries () {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', './queries.json')
xhr.onload = () => {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(xhr.statusText)
}
}
}
xhr.onerror = () => {
reject(xhr.statusText)
}
xhr.send()
})
}
}
}
</script>
<style scoped>

View File

@@ -3,19 +3,43 @@
<div id="my-queries-content">
<div id="my-queries-toolbar">
<div id="toolbar-buttons">
<button class="toolbar">Import</button>
<button class="toolbar">Export</button>
<button class="toolbar">Delete</button>
<input
ref="importFile"
type="file"
accept=".json"
id="import-file"
@change="importQueries"
/>
<button class="toolbar">
<label for="import-file">
Import
</label>
</button>
<button
class="toolbar"
v-show="selectedQueriesCount > 0"
@click="exportQuery(selectedQueriesIds)"
>
Export
</button>
<button
class="toolbar"
v-show="selectedNotPredefinedCount > 0"
@click="showDeleteDialog(selectedQueriesIds)"
>
Delete
</button>
</div>
<div id="toolbar-search">
<input type="text" placeholder="Search query by name"/>
<text-field placeholder="Search query by name" width="300px" v-model="filter"/>
</div>
</div>
<div class="rounded-bg">
<div class="header-container">
<div>
<div class="fixed-header" ref="name-th">
Name
<check-box ref="mainCheckBox" theme="light" @click="toggleSelectAll"/>
<div class="name-th">Name</div>
</div>
<div class="fixed-header">
Created at
@@ -28,18 +52,41 @@
>
<table ref="table">
<tbody>
<tr v-for="(query, index) in queries" :key="query.id" @click="openQuery(index)">
<tr
v-for="(query, index) in showedQueries"
:key="query.id"
:class="{ 'predefined': query.isPredefined }"
@click="openQuery(index)"
>
<td ref="name-td">
{{ query.name }}
<div class="cell-data">
<check-box
ref="rowCheckBox"
:init="selectAll || selectedQueriesIds.has(query.id)"
@click="toggleRow($event, query.id)"
/>
<div class="name">{{ query.name }}</div>
<div
class="badge"
@mouseover="showTooltip"
@mouseout="$set(tooltipStyle, 'visibility', 'hidden')"
>
Predefined
<span class="icon-tooltip" :style="tooltipStyle">
Predefined queries come from the server.
These queries cant be deleted or renamed.
</span>
</div>
</div>
</td>
<td>
<div class="second-column">
<div class="date-container">{{ query.createdAt | date }}</div>
<div class="icons-container">
<rename-icon />
<copy-icon />
<export-icon />
<delete-icon />
<rename-icon v-if="!query.isPredefined" @click="showRenameDialog(query.id)" />
<copy-icon @click="duplicateQuery(index)"/>
<export-icon @click="exportQuery(index)"/>
<delete-icon v-if="!query.isPredefined" @click="showDeleteDialog(query.id)"/>
</div>
</div>
</td>
@@ -49,14 +96,72 @@
</div>
</div>
</div>
<!--Rename Query dialog -->
<modal name="rename" classes="dialog" height="auto">
<div class="dialog-header">
Rename query
<close-icon @click="$modal.hide('rename')"/>
</div>
<div class="dialog-body">
<text-field
label="New query name"
:error-msg="errorMsg"
v-model="newName"
width="100%"
/>
</div>
<div class="dialog-buttons-container">
<button class="secondary" @click="$modal.hide('rename')">Cancel</button>
<button class="primary" @click="renameQuery">Rename</button>
</div>
</modal>
<!--Delete Query dialog -->
<modal name="delete" classes="dialog" height="auto">
<div class="dialog-header">
Delete {{ deleteGroup ? 'queries' : 'query' }}
<close-icon @click="$modal.hide('delete')"/>
</div>
<div
v-if="
deleteGroup || (
currentQueryIndex !== null
&& currentQueryIndex >= 0
&& currentQueryIndex < queries.length
)
"
class="dialog-body"
>
Are you sure you want to delete
{{ deleteGroup
? `${selectedNotPredefinedCount} ${selectedNotPredefinedCount > 1 ? 'queries' : 'query'}`
: `"${queries[currentQueryIndex].name}"`
}}?
<div v-show="selectedQueriesCount > selectedNotPredefinedCount" id="note">
<img :src="require('@/assets/images/info.svg')">
Note: Predefined queries you've selected won't be deleted
</div>
</div>
<div class="dialog-buttons-container">
<button class="secondary" @click="$modal.hide('delete')">Cancel</button>
<button class="primary" @click="deleteQuery">Delete</button>
</div>
</modal>
<a ref="downloader" />
</div>
</template>
<script>
import RenameIcon from '@/components/svg/rename.vue'
import CopyIcon from '@/components/svg/copy.vue'
import ExportIcon from '@/components/svg/export.vue'
import DeleteIcon from '@/components/svg/delete.vue'
import RenameIcon from '@/components/svg/rename'
import CopyIcon from '@/components/svg/copy'
import ExportIcon from '@/components/svg/export'
import DeleteIcon from '@/components/svg/delete'
import CloseIcon from '@/components/svg/close'
import TextField from '@/components/TextField'
import CheckBox from '@/components/CheckBox'
import tooltipMixin from '@/mixins/tooltips'
import { nanoid } from 'nanoid'
export default {
name: 'MyQueries',
@@ -64,20 +169,64 @@ export default {
RenameIcon,
CopyIcon,
ExportIcon,
DeleteIcon
DeleteIcon,
CloseIcon,
TextField,
CheckBox
},
mixins: [tooltipMixin],
data () {
return {
queries: []
queries: [],
filter: null,
newName: null,
currentQueryId: null,
errorMsg: null,
selectedQueriesIds: new Set(),
selectedQueriesCount: 0,
selectedNotPredefinedCount: 0,
selectAll: false,
deleteGroup: false,
resizeObserver: null
}
},
computed: {
predefinedQueries () {
return this.$store.state.predefinedQueries.map(query => {
query.isPredefined = true
return query
})
},
predefinedQueriesIds () {
return new Set(this.predefinedQueries.map(query => query.id))
},
showedQueries () {
let showedQueries = this.allQueries
if (this.filter) {
showedQueries = showedQueries.filter(
query => query.name.toUpperCase().indexOf(this.filter.toUpperCase()) >= 0
)
}
return showedQueries
},
allQueries () {
return this.predefinedQueries.concat(this.queries)
},
currentQueryIndex () {
return this.queries.findIndex(query => query.id === this.currentQueryId)
}
},
created () {
this.queries = JSON.parse(localStorage.getItem('myQueries'))
this.queries = JSON.parse(localStorage.getItem('myQueries')) || []
},
mounted () {
new ResizeObserver(this.calcNameWidth).observe(this.$refs.table)
this.resizeObserver = new ResizeObserver(this.calcNameWidth)
this.resizeObserver.observe(this.$refs.table)
this.calcNameWidth()
},
beforeDestroy () {
this.resizeObserver.unobserve(this.$refs.table)
},
filters: {
date (value) {
if (!value) {
@@ -98,11 +247,181 @@ export default {
this.$refs['name-th'].style = `width: ${this.$refs['name-td'][0].offsetWidth}px`
},
openQuery (index) {
const tab = this.queries[index]
const tab = JSON.parse(JSON.stringify(this.showedQueries[index]))
tab.isUnsaved = false
this.$store.commit('addTab', tab)
this.$store.commit('setCurrentTabId', tab.id)
this.$router.push('/editor')
},
showRenameDialog (id) {
this.errorMsg = null
this.currentQueryId = id
this.newName = this.queries[this.currentQueryIndex].name
this.$modal.show('rename')
},
renameQuery () {
if (!this.newName) {
this.errorMsg = 'Query name can\'t be empty'
return
}
const currentQuery = this.queries[this.currentQueryIndex]
currentQuery.name = this.newName
this.$set(this.queries, this.currentQueryIndex, currentQuery)
// update queries in local storage
this.saveQueriesInLocalStorage()
// update tab, if renamed query is opened
const tabIndex = this.findTabIndex(currentQuery.id)
if (tabIndex >= 0) {
this.$store.commit('updateTab', {
index: tabIndex,
name: this.newName,
id: currentQuery.id
})
}
// hide dialog
this.$modal.hide('rename')
},
duplicateQuery (index) {
const newQuery = JSON.parse(JSON.stringify(this.showedQueries[index]))
newQuery.name = newQuery.name + ' Copy'
newQuery.id = nanoid()
newQuery.createdAt = new Date()
delete newQuery.isPredefined
if (this.selectAll) {
this.selectedQueriesIds.add(newQuery.id)
this.selectedQueriesCount = this.selectedQueriesIds.size
}
this.queries.push(newQuery)
this.saveQueriesInLocalStorage()
},
showDeleteDialog (id) {
this.deleteGroup = typeof id !== 'string'
if (!this.deleteGroup) {
this.currentQueryId = id
}
this.$modal.show('delete')
},
deleteQuery () {
this.$modal.hide('delete')
if (!this.deleteGroup) {
this.queries.splice(this.currentQueryIndex, 1)
const tabIndex = this.findTabIndex(this.currentQueryId)
if (tabIndex >= 0) {
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) {
return this.$store.state.tabs.findIndex(tab => tab.id === id)
},
exportQuery (index) {
let data
let name
// single operation
if (typeof index === 'number') {
data = JSON.parse(JSON.stringify(this.showedQueries[index]))
name = data.name
delete data.isPredefined
} else {
// group operation
data = this.selectAll
? JSON.parse(JSON.stringify(this.allQueries))
: this.allQueries.filter(query => this.selectedQueriesIds.has(query.id))
name = 'My sqliteviz queries'
data.forEach(query => delete query.isPredefined)
}
// export data to file
const downloader = this.$refs.downloader
const json = JSON.stringify(data, null, 4)
const blob = new Blob([json], { type: 'octet/stream' })
const url = window.URL.createObjectURL(blob)
downloader.href = url
downloader.download = `${name}.json`
downloader.click()
window.URL.revokeObjectURL(url)
},
importQueries () {
const file = this.$refs.importFile.files[0]
const reader = new FileReader()
reader.onload = () => {
let importedQueries = JSON.parse(event.target.result)
if (!Array.isArray(importedQueries)) {
importedQueries = [importedQueries]
}
importedQueries.forEach(query => {
const allQueriesIds = this.allQueries.map(query => query.id)
if (new Set(allQueriesIds).has(query.id)) {
query.id = nanoid()
}
})
if (this.selectAll) {
importedQueries.forEach(query => {
this.selectedQueriesIds.add(query.id)
})
this.selectedQueriesCount = this.selectedQueriesIds.size
}
this.queries = this.queries.concat(importedQueries)
this.saveQueriesInLocalStorage()
this.$refs.importFile.value = null
}
reader.readAsText(file)
},
saveQueriesInLocalStorage () {
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.allQueries.map(query => query.id))
: new Set()
this.selectedQueriesCount = this.selectedQueriesIds.size
this.selectedNotPredefinedCount = checked ? this.queries.length : 0
},
toggleRow (checked, id) {
const isPredefined = this.predefinedQueriesIds.has(id)
if (checked) {
this.selectedQueriesIds.add(id)
if (!isPredefined) {
this.selectedNotPredefinedCount += 1
}
} else {
if (this.selectedQueriesIds.size === this.allQueries.length) {
this.$refs.mainCheckBox.checked = false
this.selectAll = false
}
this.selectedQueriesIds.delete(id)
if (!isPredefined) {
this.selectedNotPredefinedCount -= 1
}
}
this.selectedQueriesCount = this.selectedQueriesIds.size
}
}
}
@@ -116,32 +435,55 @@ export default {
#my-queries-toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 18px;
margin: 0 auto 8px;
max-width: 1500px;
width: 100%;
}
.rounded-bg,
#my-queries-toolbar {
.rounded-bg {
margin: 0 auto;
max-width: 1500px;
width: 100%;
}
.fixed-header:first-child {
display: flex;
align-items: center;
padding-left: 12px;
}
.fixed-header:first-child .name-th {
margin-left: 24px;
}
table {
margin-top: 0;
}
tbody tr td {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
padding: 0 24px;
line-height: 40px;
}
tbody tr td:first-child {
width: 70%;
max-width: 0;
padding: 0 12px;
}
tbody tr td:last-child {
width: 30%;
max-width: 0;
padding: 0 24px;
}
tbody .cell-data {
display: flex;
align-items: center;
max-width: 100%;
width: 100%;
}
tbody .cell-data div.name {
overflow: hidden;
text-overflow: ellipsis;
margin-left: 24px;
}
tbody tr:hover td {
@@ -162,6 +504,7 @@ tbody tr:hover td {
.icons-container {
display: none;
margin-right: -12px;
}
.date-container {
flex-shrink: 1;
@@ -172,4 +515,51 @@ tbody tr:hover td {
tbody tr:hover .icons-container {
display: block;
}
.dialog input {
width: 100%;
}
a, #import-file {
display: none;
}
button.toolbar {
margin-right: 16px;
}
button label {
display: block;
line-height: 36px;
}
button label:hover {
cursor: pointer;
}
.badge {
display: none;
background-color: var(--color-gray-light-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-small);
padding: 2px 6px;
font-size: 11px;
line-height: normal;
margin-left: 12px;
}
tbody tr.predefined:hover .badge {
display: block;
}
#note {
margin-top: 24px;
}
#note img {
vertical-align: middle;
}
.icon-tooltip {
display: block;
width: 149px;
white-space: normal;
height: auto;
line-height: normal;
padding: 6px;
}
</style>