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

Pivot implementation and redesign (#69)

- Pivot support implementation 
- Rename queries into inquiries
- Rename editor into workspace
- Change result set format
- New JSON format for inquiries
- Redesign panels
This commit is contained in:
lana-k
2021-08-04 22:20:51 +02:00
committed by GitHub
parent 8d0bc6affe
commit 5017b55944
105 changed files with 4659 additions and 2021 deletions

View File

@@ -47,13 +47,13 @@ export default {
let result = await state.db.execute('select sqlite_version()')
this.info.push({
name: 'SQLite version',
info: result.values[0]
info: result['sqlite_version()']
})
result = await state.db.execute('PRAGMA compile_options')
this.info.push({
name: 'SQLite compile options',
info: result.values.map(row => row[0])
info: result.compile_options
})
}
}

View File

@@ -1,98 +0,0 @@
<template>
<div v-show="visible" class="chart-container">
<div class="warning chart-warning" 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"
@onRender="go"
:useResizeHandler="true"
:debug="true"
:advancedTraceTypeSelector="true"
class="chart"
ref="plotlyEditor"
: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 chartHelper from './chartHelper'
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 () {
return chartHelper.getDataSourcesFromSqlResult(this.sqlResult)
},
dataSourceOptions () {
return chartHelper.getOptionsFromDataSources(this.dataSources)
}
},
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: {
go (data, layout, frames) {
// TODO: check changes and enable Save button if needed
},
update (data, layout, frames) {
this.state = { data, layout, frames }
this.$emit('update')
},
getChartStateForSave () {
return chartHelper.getChartStateForSave(this.state, this.dataSources)
}
}
}
</script>
<style scoped>
.chart-container {
height: calc(100% - 89px);
}
.chart-warning {
height: 40px;
line-height: 40px;
}
.chart {
border-top: 1px solid var(--color-border);
min-height: 242px;
}
>>> .editor_controls .sidebar__item:before {
width: 0;
}
</style>

View File

@@ -1,67 +0,0 @@
<template>
<div class="view-switcher">
<div
:class="['table-mode', {'active-mode': view === 'table'}]"
@click="$emit('update:view','table')"
>
Table
</div>
<div
:class="['chart-mode', {'active-mode': view === 'chart'}]"
@click="$emit('update:view','chart')"
>
Chart
</div>
</div>
</template>
<script>
export default {
name: 'ViewSwitcher',
props: ['view']
}
</script>
<style scoped>
.view-switcher {
height: 28px;
display: flex;
padding: 30px;
justify-content: center;
}
.view-switcher div {
height: 100%;
width: 136px;
box-sizing: border-box;
line-height: 28px;
font-size: 12px;
cursor: pointer;
background: var(--color-white);
border: 1px solid var(--color-border);
color: var(--color-text-base);
text-align: center;
font-weight: 400;
}
.view-switcher div:hover {
background-color: var(--color-bg-light);
color: var(--color-text-active);
}
.view-switcher div.active-mode {
background: var(--color-accent);
border: 1px solid var(--color-accent-shade);
color: var(--color-text-light);
text-shadow: var(--shadow);
z-index: 1;
font-weight: 600;
}
.view-switcher div.active-mode:hover {
background: var(--color-accent-shade);
}
.table-mode {
border-radius: var(--border-radius-medium) 0 0 var(--border-radius-medium);
}
.chart-mode {
margin-left: -1px;
border-radius: 0 var(--border-radius-medium) var(--border-radius-medium) 0;
}
</style>

View File

@@ -1,225 +0,0 @@
<template>
<div class="tab-content-container" v-show="isActive">
<splitpanes
class="query-results-splitter"
horizontal
:before="{ size: 50, max: 100 }"
:after="{ size: 50, max: 100 }"
>
<template #left-pane>
<div class="query-editor">
<sql-editor ref="sqlEditor" 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 result-before"
>
Run your query and get results here
</div>
<div v-if="isGettingResults" class="table-preview result-in-progress">
<loading-indicator :size="30"/>
Fetching results...
</div>
<div
v-show="result === undefined && !isGettingResults && !error"
class="table-preview result-empty"
>
No rows retrieved according to your query
</div>
<logs v-if="error" :messages="[error]"/>
<sql-table v-if="result" :data-set="result" :time="time" :height="tableViewHeight" />
</div>
<chart
:visible="view === 'chart'"
:sql-result="result"
:init-chart="initChart"
ref="chart"
@update="$store.commit('updateTab', { index: tabIndex, isUnsaved: true })"
/>
</div>
</template>
</splitpanes>
</div>
</template>
<script>
import SqlTable from '@/components/SqlTable'
import Splitpanes from '@/components/Splitpanes'
import LoadingIndicator from '@/components/LoadingIndicator'
import SqlEditor from './SqlEditor'
import ViewSwitcher from './ViewSwitcher'
import Chart from './Chart'
import Logs from '@/components/Logs'
import time from '@/lib/utils/time'
export default {
name: 'Tab',
props: ['id', 'initName', 'initQuery', 'initChart', 'tabIndex', 'isPredefined'],
components: {
SqlEditor,
SqlTable,
Splitpanes,
ViewSwitcher,
Chart,
LoadingIndicator,
Logs
},
data () {
return {
query: this.initQuery,
result: null,
view: 'table',
tableViewHeight: 0,
isGettingResults: false,
error: null,
resizeObserver: null,
time: 0
}
},
computed: {
isActive () {
return this.id === this.$store.state.currentTabId
}
},
mounted () {
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.bottomPane)
this.calculateTableHeight()
},
beforeDestroy () {
this.resizeObserver.unobserve(this.$refs.bottomPane)
},
watch: {
isActive: {
immediate: true,
async handler () {
if (this.isActive) {
this.$store.commit('setCurrentTab', this)
await this.$nextTick()
this.$refs.sqlEditor.focus()
}
}
},
query () {
this.$store.commit('updateTab', { index: this.tabIndex, isUnsaved: true })
}
},
methods: {
// Run a command in the database
async execute () {
this.isGettingResults = true
this.result = null
this.error = null
const state = this.$store.state
try {
const start = new Date()
this.result = await state.db.execute(this.query + ';')
this.time = time.getPeriod(start, new Date())
} catch (err) {
this.error = {
type: 'error',
message: err
}
}
state.db.refreshSchema()
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
// 34 - table footer width
// 12 - desirable space after the table
// 5 - padding-bottom of rounded table container
// 35 - height of table header
const freeSpace = bottomPane.offsetHeight - 88 - 34 - 12 - 5 - 35
this.tableViewHeight = freeSpace - (freeSpace % 35)
}
}
}
</script>
<style scoped>
.tab-content-container {
background-color: var(--color-white);
border-top: 1px solid var(--color-border-light);
margin-top: -1px;
}
#bottomPane {
height: 100%;
background-color: var(--color-bg-light);
}
.query-results-splitter {
height: calc(100vh - 104px);
background-color: var(--color-bg-light);
}
.query-editor {
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;
}
.result-in-progress {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
will-change: opacity;
/*
We need to show loader in 1 sec after starting query execution. We can't do that with
setTimeout because the main thread can be busy by getting a result set from the web worker.
But we can use CSS animation for opacity. Opacity triggers changes only in the Composite Layer
stage in rendering waterfall. Hence it can be processed only with Compositor Thread while
the Main Thread processes a result set.
https://www.viget.com/articles/animation-performance-101-browser-under-the-hood/
*/
animation: show-loader 1s linear 0s 1;
}
@keyframes show-loader {
0% {
opacity: 0;
}
99% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>

View File

@@ -1,22 +1,22 @@
<template>
<div>
<div id="start-guide" v-if="showedQueries.length === 0">
You don't have saved queries so far.
<span class="link" @click="$root.$emit('createNewQuery')">Create</span>
<div id="start-guide" v-if="showedInquiries.length === 0">
You don't have saved inquiries so far.
<span class="link" @click="$root.$emit('createNewInquiry')">Create</span>
the one from scratch or
<span @click="importQueries" class="link">import</span> from a file.
<span @click="importInquiries" class="link">import</span> from a file.
</div>
<div id="my-queries-content" ref="my-queries-content" v-show="showedQueries.length > 0">
<div id="my-queries-toolbar">
<div id="my-inquiries-content" ref="my-inquiries-content" v-show="showedInquiries.length > 0">
<div id="my-inquiries-toolbar">
<div id="toolbar-buttons">
<button id="toolbar-btns-import" class="toolbar" @click="importQueries">
<button id="toolbar-btns-import" class="toolbar" @click="importInquiries">
Import
</button>
<button
id="toolbar-btns-export"
class="toolbar"
v-show="selectedQueriesCount > 0"
@click="exportSelectedQueries()"
v-show="selectedInquiriesCount > 0"
@click="exportSelectedInquiries()"
>
Export
</button>
@@ -24,13 +24,13 @@
id="toolbar-btns-delete"
class="toolbar"
v-show="selectedNotPredefinedCount > 0"
@click="showDeleteDialog(selectedQueriesIds)"
@click="showDeleteDialog(selectedInquiriesIds)"
>
Delete
</button>
</div>
<div id="toolbar-search">
<text-field placeholder="Search query by name" width="300px" v-model="filter"/>
<text-field placeholder="Search inquiry by name" width="300px" v-model="filter"/>
</div>
</div>
<div class="rounded-bg">
@@ -46,48 +46,49 @@
</div>
</div>
<div class="table-container" :style="{ 'max-height': `${maxTableHeight}px` }">
<table ref="table">
<table ref="table" class="sqliteviz-table">
<tbody>
<tr
v-for="(query, index) in showedQueries"
:key="query.id"
@click="openQuery(index)"
v-for="(inquiry, index) in showedInquiries"
:key="inquiry.id"
@click="openInquiry(index)"
>
<td ref="name-td">
<div class="cell-data">
<check-box
ref="rowCheckBox"
:init="selectAll || selectedQueriesIds.has(query.id)"
@click="toggleRow($event, query.id)"
:init="selectAll || selectedInquiriesIds.has(inquiry.id)"
@click="toggleRow($event, inquiry.id)"
/>
<div class="name">{{ query.name }}</div>
<div class="name">{{ inquiry.name }}</div>
<div
v-if="query.isPredefined"
v-if="inquiry.isPredefined"
class="badge"
@mouseover="showTooltip"
@mouseout="hideTooltip"
@mouseenter="showTooltip"
@mouseleave="hideTooltip"
>
Predefined
<span class="icon-tooltip" :style="tooltipStyle">
Predefined queries come from the server.
These queries cant be deleted or renamed.
<span class="icon-tooltip" :style="tooltipStyle" ref="tooltip">
Predefined inquiries come from the server.
These inquiries cant be deleted or renamed.
</span>
</div>
</div>
</td>
<td>
<div class="second-column">
<div class="date-container">{{ query.createdAt | date }}</div>
<div class="date-container">{{ inquiry.createdAt | date }}</div>
<div class="icons-container">
<rename-icon v-if="!query.isPredefined" @click="showRenameDialog(query.id)" />
<copy-icon @click="duplicateQuery(index)"/>
<rename-icon v-if="!inquiry.isPredefined" @click="showRenameDialog(inquiry.id)" />
<copy-icon @click="duplicateInquiry(index)"/>
<export-icon
@click="exportToFile([query], `${query.name}.json`)"
tooltip="Export query to file"
@click="exportToFile([inquiry], `${inquiry.name}.json`)"
tooltip="Export inquiry to file"
tooltip-position="top-left"
/>
<delete-icon
v-if="!query.isPredefined"
@click="showDeleteDialog((new Set()).add(query.id))"
v-if="!inquiry.isPredefined"
@click="showDeleteDialog((new Set()).add(inquiry.id))"
/>
</div>
</div>
@@ -99,15 +100,15 @@
</div>
</div>
<!--Rename Query dialog -->
<!--Rename Inquiry dialog -->
<modal name="rename" classes="dialog" height="auto">
<div class="dialog-header">
Rename query
Rename inquiry
<close-icon @click="$modal.hide('rename')"/>
</div>
<div class="dialog-body">
<text-field
label="New query name"
label="New inquiry name"
:error-msg="errorMsg"
v-model="newName"
width="100%"
@@ -115,26 +116,26 @@
</div>
<div class="dialog-buttons-container">
<button class="secondary" @click="$modal.hide('rename')">Cancel</button>
<button class="primary" @click="renameQuery">Rename</button>
<button class="primary" @click="renameInquiry">Rename</button>
</div>
</modal>
<!--Delete Query dialog -->
<!--Delete Inquiry dialog -->
<modal name="delete" classes="dialog" height="auto">
<div class="dialog-header">
Delete {{ deleteGroup ? 'queries' : 'query' }}
Delete {{ deleteGroup ? 'inquiries' : 'inquiry' }}
<close-icon @click="$modal.hide('delete')"/>
</div>
<div class="dialog-body">
{{ deleteDialogMsg }}
<div v-show="selectedQueriesCount > selectedNotPredefinedCount" id="note">
<div v-show="selectedInquiriesCount > selectedNotPredefinedCount" id="note">
<img :src="require('@/assets/images/info.svg')">
Note: Predefined queries you've selected won't be deleted
Note: Predefined inquiries 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>
<button class="primary" @click="deleteInquiry">Delete</button>
</div>
</modal>
</div>
@@ -149,11 +150,11 @@ import CloseIcon from '@/components/svg/close'
import TextField from '@/components/TextField'
import CheckBox from '@/components/CheckBox'
import tooltipMixin from '@/tooltipMixin'
import storedQueries from '@/lib/storedQueries'
import storedInquiries from '@/lib/storedInquiries'
import fu from '@/lib/utils/fileIo'
export default {
name: 'MyQueries',
name: 'Inquiries',
components: {
RenameIcon,
CopyIcon,
@@ -166,13 +167,13 @@ export default {
mixins: [tooltipMixin],
data () {
return {
queries: [],
inquiries: [],
filter: null,
newName: null,
processedQueryId: null,
processedInquiryId: null,
errorMsg: null,
selectedQueriesIds: new Set(),
selectedQueriesCount: 0,
selectedInquiriesIds: new Set(),
selectedInquiriesCount: 0,
selectedNotPredefinedCount: 0,
selectAll: false,
deleteGroup: false,
@@ -181,61 +182,61 @@ export default {
}
},
computed: {
predefinedQueries () {
return this.$store.state.predefinedQueries.map(query => {
query.isPredefined = true
return query
predefinedInquiries () {
return this.$store.state.predefinedInquiries.map(inquiry => {
inquiry.isPredefined = true
return inquiry
})
},
predefinedQueriesIds () {
return new Set(this.predefinedQueries.map(query => query.id))
predefinedInquiriesIds () {
return new Set(this.predefinedInquiries.map(inquiry => inquiry.id))
},
showedQueries () {
let showedQueries = this.allQueries
showedInquiries () {
let showedInquiries = this.allInquiries
if (this.filter) {
showedQueries = showedQueries.filter(
query => query.name.toUpperCase().indexOf(this.filter.toUpperCase()) >= 0
showedInquiries = showedInquiries.filter(
inquiry => inquiry.name.toUpperCase().indexOf(this.filter.toUpperCase()) >= 0
)
}
return showedQueries
return showedInquiries
},
allQueries () {
return this.predefinedQueries.concat(this.queries)
allInquiries () {
return this.predefinedInquiries.concat(this.inquiries)
},
processedQueryIndex () {
return this.queries.findIndex(query => query.id === this.processedQueryId)
processedInquiryIndex () {
return this.inquiries.findIndex(inquiry => inquiry.id === this.processedInquiryId)
},
deleteDialogMsg () {
if (!this.deleteGroup && (
this.processedQueryIndex === null ||
this.processedQueryIndex < 0 ||
this.processedQueryIndex > this.queries.length
this.processedInquiryIndex === null ||
this.processedInquiryIndex < 0 ||
this.processedInquiryIndex > this.inquiries.length
)) {
return ''
}
const deleteItem = this.deleteGroup
? `${this.selectedNotPredefinedCount} ${this.selectedNotPredefinedCount > 1
? 'queries'
: 'query'}`
: `"${this.queries[this.processedQueryIndex].name}"`
? 'inquiries'
: 'inquiry'}`
: `"${this.inquiries[this.processedInquiryIndex].name}"`
return `Are you sure you want to delete ${deleteItem}?`
}
},
created () {
storedQueries.readPredefinedQueries()
.then(queries => {
this.$store.commit('updatePredefinedQueries', queries)
storedInquiries.readPredefinedInquiries()
.then(inquiries => {
this.$store.commit('updatePredefinedInquiries', inquiries)
})
.catch(console.error)
.finally(() => {
this.queries = storedQueries.getStoredQueries()
this.inquiries = storedInquiries.getStoredInquiries()
})
},
mounted () {
this.resizeObserver = new ResizeObserver(this.calcMaxTableHeight)
this.resizeObserver.observe(this.$refs['my-queries-content'])
this.resizeObserver.observe(this.$refs['my-inquiries-content'])
this.tableResizeObserver = new ResizeObserver(this.calcNameWidth)
this.tableResizeObserver.observe(this.$refs.table)
@@ -243,7 +244,7 @@ export default {
this.calcMaxTableHeight()
},
beforeDestroy () {
this.resizeObserver.unobserve(this.$refs['my-queries-content'])
this.resizeObserver.unobserve(this.$refs['my-inquiries-content'])
this.tableResizeObserver.unobserve(this.$refs.table)
},
filters: {
@@ -269,153 +270,153 @@ export default {
this.$refs['name-th'].style = `width: ${nameWidth}px`
},
calcMaxTableHeight () {
const freeSpace = this.$refs['my-queries-content'].offsetHeight - 200
const freeSpace = this.$refs['my-inquiries-content'].offsetHeight - 200
this.maxTableHeight = freeSpace - (freeSpace % 40) + 1
},
openQuery (index) {
const tab = this.showedQueries[index]
openInquiry (index) {
const tab = this.showedInquiries[index]
this.$store.dispatch('addTab', tab).then(id => {
this.$store.commit('setCurrentTabId', id)
this.$router.push('/editor')
this.$router.push('/workspace')
})
},
showRenameDialog (id) {
this.errorMsg = null
this.processedQueryId = id
this.newName = this.queries[this.processedQueryIndex].name
this.processedInquiryId = id
this.newName = this.inquiries[this.processedInquiryIndex].name
this.$modal.show('rename')
},
renameQuery () {
renameInquiry () {
if (!this.newName) {
this.errorMsg = 'Query name can\'t be empty'
this.errorMsg = "Inquiry name can't be empty"
return
}
const processedQuery = this.queries[this.processedQueryIndex]
processedQuery.name = this.newName
this.$set(this.queries, this.processedQueryIndex, processedQuery)
const processedInquiry = this.inquiries[this.processedInquiryIndex]
processedInquiry.name = this.newName
this.$set(this.inquiries, this.processedInquiryIndex, processedInquiry)
// update queries in local storage
storedQueries.updateStorage(this.queries)
// update inquiries in local storage
storedInquiries.updateStorage(this.inquiries)
// update tab, if renamed query is opened
const tabIndex = this.findTabIndex(processedQuery.id)
// update tab, if renamed inquiry is opened
const tabIndex = this.findTabIndex(processedInquiry.id)
if (tabIndex >= 0) {
this.$store.commit('updateTab', {
index: tabIndex,
name: this.newName,
id: processedQuery.id
id: processedInquiry.id
})
}
// hide dialog
this.$modal.hide('rename')
},
duplicateQuery (index) {
const newQuery = storedQueries.duplicateQuery(this.showedQueries[index])
duplicateInquiry (index) {
const newInquiry = storedInquiries.duplicateInquiry(this.showedInquiries[index])
if (this.selectAll) {
this.selectedQueriesIds.add(newQuery.id)
this.selectedQueriesCount = this.selectedQueriesIds.size
this.selectedInquiriesIds.add(newInquiry.id)
this.selectedInquiriesCount = this.selectedInquiriesIds.size
}
this.queries.push(newQuery)
storedQueries.updateStorage(this.queries)
this.inquiries.push(newInquiry)
storedInquiries.updateStorage(this.inquiries)
},
showDeleteDialog (idsSet) {
this.deleteGroup = idsSet.size > 1
if (!this.deleteGroup) {
this.processedQueryId = idsSet.values().next().value
this.processedInquiryId = idsSet.values().next().value
}
this.$modal.show('delete')
},
deleteQuery () {
deleteInquiry () {
this.$modal.hide('delete')
if (!this.deleteGroup) {
this.queries.splice(this.processedQueryIndex, 1)
this.inquiries.splice(this.processedInquiryIndex, 1)
// Close deleted query tab if it was opened
const tabIndex = this.findTabIndex(this.processedQueryId)
// Close deleted inquiry tab if it was opened
const tabIndex = this.findTabIndex(this.processedInquiryId)
if (tabIndex >= 0) {
this.$store.commit('deleteTab', tabIndex)
}
// Clear checkboxes
if (this.selectedQueriesIds.has(this.processedQueryId)) {
this.selectedQueriesIds.delete(this.processedQueryId)
if (this.selectedInquiriesIds.has(this.processedInquiryId)) {
this.selectedInquiriesIds.delete(this.processedInquiryId)
}
} else {
this.queries = this.selectAll
this.inquiries = this.selectAll
? []
: this.queries.filter(query => !this.selectedQueriesIds.has(query.id))
: this.inquiries.filter(inquiry => !this.selectedInquiriesIds.has(inquiry.id))
// Close deleted queries if it was opened
// Close deleted inquiries if it was opened
const tabs = this.$store.state.tabs
for (let i = tabs.length - 1; i >= 0; i--) {
if (this.selectedQueriesIds.has(tabs[i].id)) {
if (this.selectedInquiriesIds.has(tabs[i].id)) {
this.$store.commit('deleteTab', i)
}
}
// Clear checkboxes
this.selectedQueriesIds.clear()
this.selectedInquiriesIds.clear()
}
this.selectedQueriesCount = this.selectedQueriesIds.size
storedQueries.updateStorage(this.queries)
this.selectedInquiriesCount = this.selectedInquiriesIds.size
storedInquiries.updateStorage(this.inquiries)
},
findTabIndex (id) {
return this.$store.state.tabs.findIndex(tab => tab.id === id)
},
exportToFile (queryList, fileName) {
const jsonStr = storedQueries.serialiseQueries(queryList)
exportToFile (inquiryList, fileName) {
const jsonStr = storedInquiries.serialiseInquiries(inquiryList)
fu.exportToFile(jsonStr, fileName)
},
exportSelectedQueries () {
const queryList = this.selectAll
? this.allQueries
: this.allQueries.filter(query => this.selectedQueriesIds.has(query.id))
exportSelectedInquiries () {
const inquiryList = this.selectAll
? this.allInquiries
: this.allInquiries.filter(inquiry => this.selectedInquiriesIds.has(inquiry.id))
this.exportToFile(queryList, 'My sqliteviz queries.json')
this.exportToFile(inquiryList, 'My sqliteviz inquiries.json')
},
importQueries () {
storedQueries.importQueries()
.then(importedQueries => {
importInquiries () {
storedInquiries.importInquiries()
.then(importedInquiries => {
if (this.selectAll) {
importedQueries.forEach(query => {
this.selectedQueriesIds.add(query.id)
importedInquiries.forEach(inquiry => {
this.selectedInquiriesIds.add(inquiry.id)
})
this.selectedQueriesCount = this.selectedQueriesIds.size
this.selectedInquiriesCount = this.selectedInquiriesIds.size
}
this.queries = this.queries.concat(importedQueries)
storedQueries.updateStorage(this.queries)
this.inquiries = this.inquiries.concat(importedInquiries)
storedInquiries.updateStorage(this.inquiries)
})
},
toggleSelectAll (checked) {
this.selectAll = checked
this.$refs.rowCheckBox.forEach(item => { item.checked = checked })
this.selectedQueriesIds = checked
? new Set(this.allQueries.map(query => query.id))
this.selectedInquiriesIds = checked
? new Set(this.allInquiries.map(inquiry => inquiry.id))
: new Set()
this.selectedQueriesCount = this.selectedQueriesIds.size
this.selectedNotPredefinedCount = checked ? this.queries.length : 0
this.selectedInquiriesCount = this.selectedInquiriesIds.size
this.selectedNotPredefinedCount = checked ? this.inquiries.length : 0
},
toggleRow (checked, id) {
const isPredefined = this.predefinedQueriesIds.has(id)
const isPredefined = this.predefinedInquiriesIds.has(id)
if (checked) {
this.selectedQueriesIds.add(id)
this.selectedInquiriesIds.add(id)
if (!isPredefined) {
this.selectedNotPredefinedCount += 1
}
} else {
if (this.selectedQueriesIds.size === this.allQueries.length) {
if (this.selectedInquiriesIds.size === this.allInquiries.length) {
this.$refs.mainCheckBox.checked = false
this.selectAll = false
}
this.selectedQueriesIds.delete(id)
this.selectedInquiriesIds.delete(id)
if (!isPredefined) {
this.selectedNotPredefinedCount -= 1
}
}
this.selectedQueriesCount = this.selectedQueriesIds.size
this.selectedInquiriesCount = this.selectedInquiriesIds.size
}
}
}
@@ -432,13 +433,13 @@ export default {
text-align: center;
}
#my-queries-content {
#my-inquiries-content {
padding: 52px;
height: 100%;
box-sizing: border-box;
}
#my-queries-toolbar {
#my-inquiries-toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 18px;
@@ -465,47 +466,47 @@ export default {
margin-left: 24px;
}
table {
table.sqliteviz-table {
margin-top: 0;
}
tbody tr td {
.sqliteviz-table tbody tr td {
min-width: 0;
height: 40px;
}
tbody tr td:first-child {
.sqliteviz-table tbody tr td:first-child {
width: 70%;
max-width: 0;
padding: 0 12px;
}
tbody tr td:last-child {
.sqliteviz-table tbody tr td:last-child {
width: 30%;
max-width: 0;
padding: 0 24px;
}
tbody .cell-data {
.sqliteviz-table tbody .cell-data {
display: flex;
align-items: center;
max-width: 100%;
width: 100%;
}
tbody .cell-data div.name {
.sqliteviz-table tbody .cell-data div.name {
overflow: hidden;
text-overflow: ellipsis;
margin-left: 24px;
}
tbody tr:hover td {
.sqliteviz-table tbody tr:hover td {
cursor: pointer;
}
tbody tr:hover td {
.sqliteviz-table tbody tr:hover td {
color: var(--color-text-active);
}
.second-column {
.sqliteviz-table .second-column {
display: flex;
justify-content: space-between;
align-items: center;
@@ -523,7 +524,7 @@ tbody tr:hover td {
overflow: hidden;
text-overflow: ellipsis;
}
tbody tr:hover .icons-container {
.sqliteviz-table tbody tr:hover .icons-container {
display: flex;
}
.dialog input {
@@ -545,7 +546,7 @@ button.toolbar {
margin-left: 12px;
}
tbody tr:hover .badge {
.sqliteviz-table tbody tr:hover .badge {
display: block;
}
#note {

View File

@@ -6,18 +6,17 @@
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@click.stop="$emit('click')"
@mouseover="showTooltip"
@mouseout="hideTooltip"
@click.stop="onClick"
@mouseenter="showTooltip"
@mouseleave="hideTooltip"
>
<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 class="icon-tooltip" :style="tooltipStyle" ref="tooltip">
Duplicate inquiry
</span>
</span>
</template>
@@ -27,7 +26,13 @@ import tooltipMixin from '@/tooltipMixin'
export default {
name: 'CopyIcon',
mixins: [tooltipMixin]
mixins: [tooltipMixin],
methods: {
onClick () {
this.hideTooltip()
this.$emit('click')
}
}
}
</script>

View File

@@ -6,18 +6,17 @@
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@click.stop="$emit('click')"
@mouseover="showTooltip"
@mouseout="hideTooltip"
@click.stop="onClick"
@mouseenter="showTooltip($event, 'top-left')"
@mouseleave="hideTooltip"
>
<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 class="icon-tooltip" :style="tooltipStyle" ref="tooltip">
Delete inquiry
</span>
</span>
</template>
@@ -27,7 +26,13 @@ import tooltipMixin from '@/tooltipMixin'
export default {
name: 'DeleteIcon',
mixins: [tooltipMixin]
mixins: [tooltipMixin],
methods: {
onClick () {
this.hideTooltip()
this.$emit('click')
}
}
}
</script>

View File

@@ -6,18 +6,17 @@
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@click.stop="$emit('click')"
@mouseover="showTooltip"
@mouseout="hideTooltip"
@click.stop="onClick"
@mouseenter="showTooltip"
@mouseleave="hideTooltip"
>
<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 class="icon-tooltip" :style="tooltipStyle" ref="tooltip">
Rename inquiry
</span>
</span>
</template>
@@ -27,7 +26,13 @@ import tooltipMixin from '@/tooltipMixin'
export default {
name: 'RenameIcon',
mixins: [tooltipMixin]
mixins: [tooltipMixin],
methods: {
onClick () {
this.hideTooltip()
this.$emit('click')
}
}
}
</script>

View File

@@ -1,53 +1,44 @@
<template>
<nav>
<div>
<router-link to="/editor">Editor</router-link>
<router-link to="/my-queries">My queries</router-link>
<router-link to="/workspace">Workspace</router-link>
<router-link to="/inquiries">Inquiries</router-link>
<a href="https://github.com/lana-k/sqliteviz/wiki" target="_blank">Help</a>
</div>
<div id="nav-buttons">
<button
id="run-btn"
v-if="currentQuery && $route.path === '/editor'"
class="primary"
:disabled="runDisabled"
@click="currentQuery.execute"
>
Run
</button>
<button
id="save-btn"
v-show="currentQuery && $route.path === '/editor'"
v-show="currentInquiry && $route.path === '/workspace'"
class="primary"
:disabled="!isUnsaved"
@click="checkQueryBeforeSave"
:disabled="isSaved"
@click="checkInquiryBeforeSave"
>
Save
</button>
<button
id="create-btn"
class="primary"
@click="createNewQuery"
@click="createNewInquiry"
>
Create
</button>
<app-diagnostic-info />
</div>
<!--Save Query dialog -->
<!--Save Inquiry dialog -->
<modal name="save" classes="dialog" height="auto">
<div class="dialog-header">
Save query
Save inquiry
<close-icon @click="cancelSave"/>
</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.
Note: Predefined inquiries can't be edited.
That's why your modifications will be saved as a new inquiry. Enter the name for it.
</div>
<text-field
label="Query name"
label="Inquiry name"
:error-msg="errorMsg"
v-model="name"
width="100%"
@@ -55,7 +46,7 @@
</div>
<div class="dialog-buttons-container">
<button class="secondary" @click="cancelSave">Cancel</button>
<button class="primary" @click="saveQuery">Save</button>
<button class="primary" @click="saveInquiry">Save</button>
</div>
</modal>
</nav>
@@ -64,7 +55,7 @@
<script>
import TextField from '@/components/TextField'
import CloseIcon from '@/components/svg/close'
import storedQueries from '@/lib/storedQueries'
import storedInquiries from '@/lib/storedInquiries'
import AppDiagnosticInfo from './AppDiagnosticInfo'
export default {
@@ -81,121 +72,122 @@ export default {
}
},
computed: {
currentQuery () {
currentInquiry () {
return this.$store.state.currentTab
},
isUnsaved () {
if (!this.currentQuery) {
isSaved () {
if (!this.currentInquiry) {
return false
}
const tabIndex = this.currentQuery.tabIndex
const tabIndex = this.currentInquiry.tabIndex
const tab = this.$store.state.tabs[tabIndex]
return tab && tab.isUnsaved
return tab && tab.isSaved
},
isPredefined () {
if (this.currentQuery) {
return this.currentQuery.isPredefined
if (this.currentInquiry) {
return this.currentInquiry.isPredefined
} else {
return false
}
},
runDisabled () {
return this.currentQuery && (!this.$store.state.db || !this.currentQuery.query)
return this.currentInquiry && (!this.$store.state.db || !this.currentInquiry.query)
}
},
created () {
this.$root.$on('createNewQuery', this.createNewQuery)
this.$root.$on('saveQuery', this.checkQueryBeforeSave)
this.$root.$on('createNewInquiry', this.createNewInquiry)
this.$root.$on('saveInquiry', this.checkInquiryBeforeSave)
document.addEventListener('keydown', this._keyListener)
},
beforeDestroy () {
document.removeEventListener('keydown', this._keyListener)
},
methods: {
createNewQuery () {
createNewInquiry () {
this.$store.dispatch('addTab').then(id => {
this.$store.commit('setCurrentTabId', id)
if (this.$route.path !== '/editor') {
this.$router.push('/editor')
if (this.$route.path !== '/workspace') {
this.$router.push('/workspace')
}
})
},
cancelSave () {
this.$modal.hide('save')
this.$root.$off('querySaved')
this.$root.$off('inquirySaved')
},
checkQueryBeforeSave () {
checkInquiryBeforeSave () {
this.errorMsg = null
this.name = ''
if (storedQueries.isTabNeedName(this.currentQuery)) {
if (storedInquiries.isTabNeedName(this.currentInquiry)) {
this.$modal.show('save')
} else {
this.saveQuery()
this.saveInquiry()
}
},
saveQuery () {
const isNeedName = storedQueries.isTabNeedName(this.currentQuery)
saveInquiry () {
const isNeedName = storedInquiries.isTabNeedName(this.currentInquiry)
if (isNeedName && !this.name) {
this.errorMsg = 'Query name can\'t be empty'
this.errorMsg = 'Inquiry name can\'t be empty'
return
}
const dataSet = this.currentQuery.result
const tabView = this.currentQuery.view
const dataSet = this.currentInquiry.result
const tabView = this.currentInquiry.view
// Save query
const value = storedQueries.save(this.currentQuery, this.name)
// Save inquiry
const value = storedInquiries.save(this.currentInquiry, this.name)
// Update tab in store
this.$store.commit('updateTab', {
index: this.currentQuery.tabIndex,
index: this.currentInquiry.tabIndex,
name: value.name,
id: value.id,
query: value.query,
chart: value.chart,
isUnsaved: false
viewType: value.viewType,
viewOptions: value.viewOptions,
isSaved: true
})
// Restore data:
// e.g. if we save predefined query the tab will be created again
// e.g. if we save predefined inquiry 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
this.currentInquiry.result = dataSet
this.currentInquiry.view = tabView
})
// Hide dialog
this.$modal.hide('save')
// Signal about saving
this.$root.$emit('querySaved')
this.$root.$emit('inquirySaved')
},
_keyListener (e) {
if (this.$route.path === '/editor') {
if (this.$route.path === '/workspace') {
// Run query Ctrl+R or Ctrl+Enter
if ((e.key === 'r' || e.key === 'Enter') && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
if (!this.runDisabled) {
this.currentQuery.execute()
this.currentInquiry.execute()
}
return
}
// Save query Ctrl+S
// Save inquiry Ctrl+S
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
if (this.isUnsaved) {
this.checkQueryBeforeSave()
if (!this.isSaved) {
this.checkInquiryBeforeSave()
}
return
}
}
// New (blank) query Ctrl+B
// New (blank) inquiry Ctrl+B
if (e.key === 'b' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
this.createNewQuery()
this.createNewInquiry()
}
}
}

View File

@@ -1,27 +1,17 @@
import dereference from 'react-chart-editor/lib/lib/dereference'
export function getDataSourcesFromSqlResult (sqlResult) {
if (!sqlResult) {
return {}
}
const dataSorces = {}
const matrix = sqlResult.values
const [row] = matrix
const transposedMatrix = row.map((value, column) => matrix.map(row => row[column]))
sqlResult.columns.forEach((column, index) => {
dataSorces[column] = transposedMatrix[index]
})
return dataSorces
}
export function getOptionsFromDataSources (dataSources) {
if (!dataSources) {
return []
}
return Object.keys(dataSources).map(name => ({
value: name,
label: name
}))
}
export function getChartStateForSave (state, dataSources) {
export function getOptionsForSave (state, dataSources) {
// we don't need to save the data, only settings
// so we modify state.data using dereference
const stateCopy = JSON.parse(JSON.stringify(state))
@@ -34,7 +24,6 @@ export function getChartStateForSave (state, dataSources) {
}
export default {
getDataSourcesFromSqlResult,
getOptionsFromDataSources,
getChartStateForSave
getOptionsForSave
}

View File

@@ -0,0 +1,118 @@
<template>
<div v-show="visible" class="chart-container" ref="chartContainer">
<div class="warning chart-warning" v-show="!dataSources && 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, modeBarButtonsToRemove: ['toImage'] }"
:dataSources="dataSources"
:dataSourceOptions="dataSourceOptions"
:plotly="plotly"
@onUpdate="update"
@onRender="onRender"
:useResizeHandler="true"
:debug="true"
:advancedTraceTypeSelector="true"
class="chart"
ref="plotlyEditor"
:style="{ height: !dataSources ? 'calc(100% - 40px)' : '100%' }"
/>
</div>
</template>
<script>
import plotly from 'plotly.js'
import 'react-chart-editor/lib/react-chart-editor.min.css'
import PlotlyEditor from 'react-chart-editor'
import chartHelper from './chartHelper'
import dereference from 'react-chart-editor/lib/lib/dereference'
import fIo from '@/lib/utils/fileIo'
export default {
name: 'Chart',
props: ['dataSources', 'initOptions', 'importToPngEnabled'],
components: {
PlotlyEditor
},
data () {
return {
plotly: plotly,
state: this.initOptions || {
data: [],
layout: {},
frames: []
},
visible: true,
resizeObserver: null
}
},
computed: {
dataSourceOptions () {
return chartHelper.getOptionsFromDataSources(this.dataSources)
}
},
mounted () {
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.chartContainer)
},
beforeDestroy () {
this.resizeObserver.unobserve(this.$refs.chartContainer)
},
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: {
handleResize () {
this.visible = false
this.$nextTick(() => {
this.visible = true
})
},
onRender (data, layout, frames) {
// TODO: check changes and enable Save button if needed
},
update (data, layout, frames) {
this.state = { data, layout, frames }
this.$emit('update')
},
getOptionsForSave () {
return chartHelper.getOptionsForSave(this.state, this.dataSources)
},
async saveAsPng () {
const chartElement = this.$refs.plotlyEditor.$el.querySelector('.js-plotly-plot')
const url = await plotly.toImage(chartElement, { format: 'png', width: null, height: null })
this.$emit('loadingImageCompleted')
fIo.downloadFromUrl(url, 'chart')
}
}
}
</script>
<style scoped>
.chart-container {
height: 100%;
}
.chart-warning {
height: 40px;
line-height: 40px;
border-bottom: 1px solid var(--color-border);
box-sizing: border-box;
}
.chart {
min-height: 242px;
}
>>> .editor_controls .sidebar__item:before {
width: 0;
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<div :class="['pivot-sort-btn', direction] " @click="changeSorting">
{{ value.includes('key') ? 'key' : 'value' }}
<sort-icon
class="sort-icon"
:horizontal="direction === 'col'"
:asc="value.includes('a_to_z')"
/>
</div>
</template>
<script>
import SortIcon from '@/components/svg/sort'
export default {
name: 'PivotSortBtn',
props: ['direction', 'value'],
components: {
SortIcon
},
methods: {
changeSorting () {
if (this.value === 'key_a_to_z') {
this.$emit('input', 'value_a_to_z')
} else if (this.value === 'value_a_to_z') {
this.$emit('input', 'value_z_to_a')
} else {
this.$emit('input', 'key_a_to_z')
}
}
}
}
</script>
<style scoped>
.pivot-sort-btn {
display: flex;
justify-content: center;
align-items: center;
width: 43px;
height: 27px;
background-color: var(--color-bg-light-4);
border-radius: var(--border-radius-medium-2);
border: 1px solid var(--color-border);
cursor: pointer;
font-size: 11px;
color: var(--color-text-base);
line-height: 8px;
box-sizing: border-box;
}
.pivot-sort-btn:hover {
color: var(--color-text-active);
border-color: var(--color-border-dark);
}
.pivot-sort-btn:hover >>> .sort-icon path {
fill: var(--color-text-active);
}
.pivot-sort-btn.col {
flex-direction: column;
padding-top: 5px;
}
.pivot-sort-btn.row {
flex-direction: row;
}
.pivot-sort-btn.row .sort-icon {
margin-left: 2px;
}
</style>

View File

@@ -0,0 +1,302 @@
<template>
<div class="pivot-ui">
<div :class="{collapsed}">
<div class="row">
<label>Columns</label>
<multiselect
class="sqliteviz-select cols"
v-model="cols"
:options="colsToSelect"
:disabled="colsToSelect.length === 0"
:multiple="true"
:hideSelected="true"
:close-on-select="true"
:show-labels="false"
:max="colsToSelect.length"
open-direction="bottom"
placeholder=""
>
<template slot="maxElements">
<span class="no-results">No Results</span>
</template>
<template slot="placeholder">Choose columns</template>
<template slot="noResult">
<span class="no-results">No Results</span>
</template>
</multiselect>
<pivot-sort-btn class="sort-btn" direction="col" v-model="colOrder" />
</div>
<div class="row">
<label>Rows</label>
<multiselect
class="sqliteviz-select rows"
v-model="rows"
:options="rowsToSelect"
:disabled="rowsToSelect.length === 0"
:multiple="true"
:hideSelected="true"
:close-on-select="true"
:show-labels="false"
:max="rowsToSelect.length"
:option-height="29"
open-direction="bottom"
placeholder=""
>
<template slot="maxElements">
<span class="no-results">No Results</span>
</template>
<template slot="placeholder">Choose rows</template>
<template slot="noResult">
<span class="no-results">No Results</span>
</template>
</multiselect>
<pivot-sort-btn class="sort-btn" direction="row" v-model="rowOrder" />
</div>
<div class="row aggregator">
<label>Aggregator</label>
<multiselect
class="sqliteviz-select short aggregator"
v-model="aggregator"
:options="aggregators"
label="name"
track-by="name"
:close-on-select="true"
:show-labels="false"
:hideSelected="true"
:option-height="29"
open-direction="bottom"
placeholder="Choose a function"
>
<template slot="noResult">
<span class="no-results">No Results</span>
</template>
</multiselect>
<multiselect
class="sqliteviz-select aggr-arg"
v-show="valCount > 0"
v-model="val1"
:options="keyNames"
:disabled="keyNames.length === 0"
:close-on-select="true"
:show-labels="false"
:hideSelected="true"
:option-height="29"
open-direction="bottom"
placeholder="Choose an argument"
/>
<multiselect
class="sqliteviz-select aggr-arg"
v-show="valCount > 1"
v-model="val2"
:options="keyNames"
:disabled="keyNames.length === 0"
:close-on-select="true"
:show-labels="false"
:hideSelected="true"
:option-height="29"
open-direction="bottom"
placeholder="Choose a second argument"
/>
</div>
<div class="row">
<label>View</label>
<multiselect
class="sqliteviz-select short renderer"
v-model="renderer"
:options="renderers"
label="name"
track-by="name"
:close-on-select="true"
:allow-empty="false"
:show-labels="false"
:hideSelected="true"
:option-height="29"
open-direction="bottom"
placeholder="Choose a view"
>
<template slot="noResult">
<span class="no-results">No Results</span>
</template>
</multiselect>
</div>
</div>
<span @click="collapsed = !collapsed" class="switcher">
{{ collapsed ? 'Show pivot settings' : 'Hide pivot settings' }}
</span>
</div>
</template>
<script>
import $ from 'jquery'
import Multiselect from 'vue-multiselect'
import PivotSortBtn from './PivotSortBtn'
import { renderers, aggregators, zeroValAggregators, twoValAggregators } from './pivotHelper'
import Chart from '@/views/Main/Workspace/Tabs/Tab/DataView/Chart'
import Vue from 'vue'
const ChartClass = Vue.extend(Chart)
export default {
name: 'pivotUi',
props: ['keyNames', 'value'],
components: {
Multiselect,
PivotSortBtn
},
data () {
const aggregatorName = (this.value && this.value.aggregatorName) || 'Count'
const rendererName = (this.value && this.value.rendererName) || 'Table'
return {
collapsed: false,
renderer: { name: rendererName, fun: $.pivotUtilities.renderers[rendererName] },
aggregator: { name: aggregatorName, fun: $.pivotUtilities.aggregators[aggregatorName] },
rows: (this.value && this.value.rows) || [],
cols: (this.value && this.value.cols) || [],
val1: (this.value && this.value.vals && this.value.vals[0]) || '',
val2: (this.value && this.value.vals && this.value.vals[1]) || '',
colOrder: (this.value && this.value.colOrder) || 'key_a_to_z',
rowOrder: (this.value && this.value.rowOrder) || 'key_a_to_z',
customChartComponent:
(this.value && this.value.rendererOptions && this.value.rendererOptions.customChartComponent) ||
new ChartClass()
}
},
computed: {
valCount () {
if (zeroValAggregators.includes(this.aggregator.name)) {
return 0
}
if (twoValAggregators.includes(this.aggregator.name)) {
return 2
}
return 1
},
renderers () {
return renderers
},
aggregators () {
return aggregators
},
rowsToSelect () {
return this.keyNames.filter(key => !this.cols.includes(key))
},
colsToSelect () {
return this.keyNames.filter(key => !this.rows.includes(key))
}
},
watch: {
renderer () {
this.returnValue()
},
aggregator () {
this.returnValue()
},
rows () {
this.returnValue()
},
cols () {
this.returnValue()
},
val1 () {
this.returnValue()
},
val2 () {
this.returnValue()
},
colOrder () {
this.returnValue()
},
rowOrder () {
this.returnValue()
}
},
created () {
this.customChartComponent.$on('update', () => { this.$emit('update') })
this.customChartComponent.$on('loadingImageCompleted', value => { this.$emit('loadingCustomChartImageCompleted') })
},
methods: {
returnValue () {
const vals = []
for (let i = 1; i <= this.valCount; i++) {
vals.push(this[`val${i}`])
}
this.$emit('update')
this.$emit('input', {
rows: this.rows,
cols: this.cols,
colOrder: this.colOrder,
rowOrder: this.rowOrder,
aggregator: this.aggregator.fun(vals),
aggregatorName: this.aggregator.name,
renderer: this.renderer.fun,
rendererName: this.renderer.name,
rendererOptions: this.renderer.name !== 'Custom chart' ? undefined : {
customChartComponent: this.customChartComponent
},
vals
})
}
}
}
</script>
<style scoped>
.pivot-ui {
padding: 12px 24px;
color: var(--color-text-base);
font-size: 12px;
border-bottom: 1px solid var(--color-border-light);
background-color: var(--color-bg-light);
}
.pivot-ui .row {
display: flex;
align-items: center;
margin: 12px 0;
}
.pivot-ui .row label {
width: 76px;
flex-shrink: 0;
}
.pivot-ui .row .sqliteviz-select.short {
width: 220px;
flex-shrink: 0;
}
.pivot-ui .row .aggr-arg {
margin-left: 12px;
max-width: 220px;
}
.pivot-ui .row .sort-btn {
margin-left: 12px;
flex-shrink: 0;
}
.collapsed {
display: none;
}
.switcher {
display: block;
width: min-content;
white-space: nowrap;
margin: auto;
cursor: pointer;
}
.switcher:hover {
color: var(--color-accent);
}
</style>

View File

@@ -0,0 +1,77 @@
import $ from 'jquery'
import 'pivottable'
import 'pivottable/dist/export_renderers.js'
import 'pivottable/dist/plotly_renderers.js'
export const zeroValAggregators = [
'Count',
'Count as Fraction of Total',
'Count as Fraction of Rows',
'Count as Fraction of Columns'
]
export const twoValAggregators = [
'Sum over Sum',
'80% Upper Bound',
'80% Lower Bound'
]
export function _getDataSources (pivotData) {
const rowKeys = pivotData.getRowKeys()
const colKeys = pivotData.getColKeys()
const dataSources = {
'Column keys': colKeys.map(colKey => colKey.join('-')),
'Row keys': rowKeys.map(rowKey => rowKey.join('-'))
}
const dataSourcesByRows = {}
const dataSourcesByCols = {}
const rowAttrs = pivotData.rowAttrs.join('-')
const colAttrs = pivotData.colAttrs.join('-')
colKeys.forEach(colKey => {
const sourceColKey = colAttrs + ':' + colKey.join('-')
dataSourcesByCols[sourceColKey] = []
rowKeys.forEach(rowKey => {
const value = pivotData.getAggregator(rowKey, colKey).value()
dataSourcesByCols[sourceColKey].push(value)
const sourceRowKey = rowAttrs + ':' + rowKey.join('-')
if (!dataSourcesByRows[sourceRowKey]) {
dataSourcesByRows[sourceRowKey] = []
}
dataSourcesByRows[sourceRowKey].push(value)
})
})
return Object.assign(dataSources, dataSourcesByCols, dataSourcesByRows)
}
function customChartRenderer (data, options) {
options.customChartComponent.dataSources = _getDataSources(data)
options.customChartComponent.$mount()
return $(options.customChartComponent.$el)
}
$.extend(
$.pivotUtilities.renderers,
$.pivotUtilities.export_renderers,
$.pivotUtilities.plotly_renderers,
{ 'Custom chart': customChartRenderer }
)
export const renderers = Object.keys($.pivotUtilities.renderers).map(key => {
return {
name: key,
fun: $.pivotUtilities.renderers[key]
}
})
export const aggregators = Object.keys($.pivotUtilities.aggregators).map(key => {
return {
name: key,
fun: $.pivotUtilities.aggregators[key]
}
})

View File

@@ -0,0 +1,228 @@
<template>
<div class="pivot-container">
<div class="warning pivot-warning" v-show="!dataSources">
There is no data to build a pivot. Run your SQL query and make sure the result is not empty.
</div>
<pivot-ui
:key-names="columns"
v-model="pivotOptions"
@update="$emit('update')"
@loadingCustomChartImageCompleted="$emit('loadingImageCompleted')"
/>
<div ref="pivotOutput" class="pivot-output"/>
</div>
</template>
<script>
import html2canvas from 'html2canvas'
import plotly from 'plotly.js'
import fIo from '@/lib/utils/fileIo'
import $ from 'jquery'
import 'pivottable'
import 'pivottable/dist/pivot.css'
import PivotUi from './PivotUi'
import Chart from '@/views/Main/Workspace/Tabs/Tab/DataView/Chart'
import Vue from 'vue'
const ChartClass = Vue.extend(Chart)
export default {
name: 'pivot',
props: ['dataSources', 'initOptions', 'importToPngEnabled'],
components: {
PivotUi
},
data () {
return {
resizeObserver: null,
pivotOptions: !this.initOptions
? {
rows: [],
cols: [],
colOrder: 'key_a_to_z',
rowOrder: 'key_a_to_z',
aggregatorName: 'Count',
aggregator: $.pivotUtilities.aggregators.Count(),
vals: [],
rendererName: 'Table',
renderer: $.pivotUtilities.renderers.Table,
rendererOptions: undefined
}
: {
rows: this.initOptions.rows,
cols: this.initOptions.cols,
colOrder: this.initOptions.colOrder,
rowOrder: this.initOptions.rowOrder,
aggregatorName: this.initOptions.aggregatorName,
aggregator: $.pivotUtilities.aggregators[this.initOptions.aggregatorName](this.initOptions.vals),
vals: this.initOptions.vals,
rendererName: this.initOptions.rendererName,
renderer: $.pivotUtilities.renderers[this.initOptions.rendererName],
rendererOptions: !this.initOptions.rendererOptions ? undefined : {
customChartComponent: new ChartClass({
propsData: { initOptions: this.initOptions.rendererOptions.customChartOptions }
})
}
}
}
},
computed: {
columns () {
return Object.keys(this.dataSources || {})
}
},
watch: {
dataSources () {
this.show()
},
'pivotOptions.rendererName': {
immediate: true,
handler () {
this.$emit('update:importToPngEnabled', this.pivotOptions.rendererName !== 'TSV Export')
}
},
pivotOptions () {
this.show()
}
},
mounted () {
this.show()
// We need to detect resizing because plotly doesn't resize when resixe its container
// but it resize on window.resize (we will trigger it manualy in order to make plotly resize)
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.pivotOutput)
},
beforeDestroy () {
this.resizeObserver.unobserve(this.$refs.pivotOutput)
},
methods: {
handleResize () {
// hack: plotly changes size only on window.resize event,
// so, we trigger it when container resizes (e.g. when move splitter)
if (this.pivotOptions.rendererName in $.pivotUtilities.plotly_renderers) {
window.dispatchEvent(new Event('resize'))
}
},
show () {
const options = { ...this.pivotOptions }
if (this.pivotOptions.rendererName in $.pivotUtilities.plotly_renderers) {
options.rendererOptions = {
plotly: {
autosize: true,
width: null,
height: null
},
plotlyConfig: {
displaylogo: false,
responsive: true,
modeBarButtonsToRemove: ['toImage']
}
}
}
$(this.$refs.pivotOutput).pivot(
function (callback) {
const rowCount = !this.dataSources ? 0 : this.dataSources[this.columns[0]].length
for (let i = 1; i <= rowCount; i++) {
const row = {}
this.columns.forEach(col => {
row[col] = this.dataSources[col][i - 1]
})
callback(row)
}
}.bind(this),
options
)
// fix for Firefox: fit plotly renderers just after choosing it in pivotUi
if (this.pivotOptions.rendererName in $.pivotUtilities.plotly_renderers) {
window.dispatchEvent(new Event('resize'))
}
},
getOptionsForSave () {
const options = { ...this.pivotOptions }
if (options.rendererOptions) {
const chartComponent = this.pivotOptions.rendererOptions.customChartComponent
options.rendererOptions = {
customChartOptions: chartComponent.getOptionsForSave()
}
}
return options
},
async saveAsPng () {
if (this.pivotOptions.rendererName === 'Custom chart') {
this.pivotOptions.rendererOptions.customChartComponent.saveAsPng()
} else if (this.pivotOptions.rendererName in $.pivotUtilities.plotly_renderers) {
const chartElement = this.$refs.pivotOutput.querySelector('.js-plotly-plot')
const url = await plotly.toImage(chartElement, {
format: 'png',
width: null,
height: null
})
this.$emit('loadingImageCompleted')
fIo.downloadFromUrl(url, 'pivot')
} else {
const tableElement = this.$refs.pivotOutput.querySelector('.pvtTable')
const canvas = await html2canvas(tableElement)
this.$emit('loadingImageCompleted')
fIo.downloadFromUrl(canvas.toDataURL('image/png'), 'pivot', 'image/png')
}
}
}
}
</script>
<style scoped>
.pivot-container {
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--color-white);
}
.pivot-output {
flex-grow: 1;
width: 100%;
overflow: auto;
}
.pivot-warning {
height: 40px;
line-height: 40px;
box-sizing: border-box;
}
>>> .pvtTable {
min-width: 100%;
}
>>> table.pvtTable tbody tr td,
>>> table.pvtTable thead tr th,
>>> table.pvtTable tbody tr th {
border-color: var(--color-border-light);
}
>>> table.pvtTable thead tr th,
>>> table.pvtTable tbody tr th {
background-color: var(--color-bg-dark);
color: var(--color-text-light);
}
>>> table.pvtTable tbody tr td {
color: var(--color-text-base);
}
.pivot-output >>> textarea {
color: var(--color-text-base);
min-width: 100%;
height: 100% !important;
display: block;
box-sizing: border-box;
border-width: 0;
}
.pivot-output >>> textarea:focus-visible {
outline: none;
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div class="data-view-panel">
<div class="data-view-panel-content">
<component
:is="mode"
:init-options="mode === initMode ? initOptions : undefined"
:data-sources="dataSource"
:import-to-png-enabled.sync="importToPngEnabled"
@loadingImageCompleted="loadingImage = false"
ref="viewComponent"
@update="$emit('update')"
/>
</div>
<side-tool-bar panel="dataView" @switchTo="$emit('switchTo', $event)">
<icon-button
:active="mode === 'chart'"
@click="mode = 'chart'"
tooltip="Switch to chart"
tooltip-position="top-left"
>
<chart-icon />
</icon-button>
<icon-button
:active="mode === 'pivot'"
@click="mode = 'pivot'"
tooltip="Switch to pivot"
tooltip-position="top-left"
>
<pivot-icon />
</icon-button>
<div class="side-tool-bar-divider"/>
<icon-button
:disabled="!importToPngEnabled || loadingImage"
:loading="loadingImage"
tooltip="Save as PNG image"
tooltip-position="top-left"
@click="saveAsPng"
>
<png-icon />
</icon-button>
</side-tool-bar>
</div>
</template>
<script>
import Chart from './Chart'
import Pivot from './Pivot'
import SideToolBar from '../SideToolBar'
import IconButton from '@/components/IconButton'
import ChartIcon from '@/components/svg/chart'
import PivotIcon from '@/components/svg/pivot'
import PngIcon from '@/components/svg/png'
export default {
name: 'DataView',
props: ['dataSource', 'initOptions', 'initMode'],
components: {
Chart,
Pivot,
SideToolBar,
IconButton,
ChartIcon,
PivotIcon,
PngIcon
},
data () {
return {
mode: this.initMode || 'chart',
importToPngEnabled: true,
loadingImage: false
}
},
watch: {
mode () {
this.$emit('update')
this.importToPngEnabled = true
}
},
methods: {
async saveAsPng () {
this.loadingImage = true
/*
setTimeout does its thing by putting its callback on the callback queue. The callback queue is only called by the browser after both the call stack and the render queue are done. So our animation (which is on the call stack) gets done, the render queue renders it, and then the browser is ready for the callback queue and calls the long-calculation.
nextTick allows you to do something after you have changed the data and VueJS has updated the DOM based on your data change, but before the browser has rendered those changed on the page.
Lees meer van Katinka Hesselink: http://www.hesselinkwebdesign.nl/2019/nexttick-vs-settimeout-in-vue/
*/
setTimeout(() => {
this.$refs.viewComponent.saveAsPng()
}, 0)
},
getOptionsForSave () {
return this.$refs.viewComponent.getOptionsForSave()
}
}
}
</script>
<style scoped>
.data-view-panel {
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
}
.data-view-panel-content {
position: relative;
flex-grow: 1;
width: calc(100% - 39px);
height: 100%;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,130 @@
<template>
<div class="run-result-panel" ref="runResultPanel">
<div class="run-result-panel-content">
<div
v-show="result === null && !isGettingResults && !error"
class="table-preview result-before"
>
Run your query and get results here
</div>
<div v-if="isGettingResults" class="table-preview result-in-progress">
<loading-indicator :size="30"/>
Fetching results...
</div>
<div
v-show="result === undefined && !isGettingResults && !error"
class="table-preview result-empty"
>
No rows retrieved according to your query
</div>
<logs v-if="error" :messages="[error]"/>
<sql-table
v-if="result"
:data-set="result"
:time="time"
:pageSize="pageSize"
class="straight"
/>
</div>
<side-tool-bar @switchTo="$emit('switchTo', $event)" panel="table"/>
</div>
</template>
<script>
import Logs from '@/components/Logs'
import SqlTable from '@/components/SqlTable'
import LoadingIndicator from '@/components/LoadingIndicator'
import SideToolBar from './SideToolBar'
export default {
name: 'RunResult',
props: ['result', 'isGettingResults', 'error', 'time'],
data () {
return {
resizeObserver: null,
pageSize: 20
}
},
components: {
SqlTable,
LoadingIndicator,
Logs,
SideToolBar
},
mounted () {
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.runResultPanel)
this.calculatePageSize()
},
beforeDestroy () {
this.resizeObserver.unobserve(this.$refs.runResultPanel)
},
methods: {
handleResize () {
this.calculatePageSize()
},
calculatePageSize () {
const runResultPanel = this.$refs.runResultPanel
// 27 - table footer hight
// 5 - padding-bottom of rounded table container
// 35 - height of table header
const freeSpace = runResultPanel.offsetHeight - 27 - 5 - 35
this.pageSize = Math.max(Math.floor(freeSpace / 35), 20)
}
}
}
</script>
<style scoped>
.run-result-panel {
display: flex;
height: 100%;
overflow: hidden;
}
.run-result-panel-content {
position: relative;
flex-grow: 1;
height: 100%;
width: 0;
box-sizing: border-box;
}
.table-preview {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--color-text-base);
font-size: 13px;
}
.result-in-progress {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
will-change: opacity;
/*
We need to show loader in 1 sec after starting query execution. We can't do that with
setTimeout because the main thread can be busy by getting a result set from the web worker.
But we can use CSS animation for opacity. Opacity triggers changes only in the Composite Layer
stage in rendering waterfall. Hence it can be processed only with Compositor Thread while
the Main Thread processes a result set.
https://www.viget.com/articles/animation-performance-101-browser-under-the-hood/
*/
animation: show-loader 1s linear 0s 1;
}
@keyframes show-loader {
0% {
opacity: 0;
}
99% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<div class="side-tool-bar">
<icon-button
:active="panel === 'sqlEditor'"
tooltip="Switch panel to SQL editor"
tooltip-position="top-left"
@click.native="$emit('switchTo', 'sqlEditor')"
>
<sql-editor-icon />
</icon-button>
<icon-button
:active="panel === 'table'"
tooltip="Switch panel to result set"
tooltip-position="top-left"
@click.native="$emit('switchTo', 'table')"
>
<table-icon/>
</icon-button>
<icon-button
:active="panel === 'dataView'"
tooltip="Switch panel to data view"
tooltip-position="top-left"
@click.native="$emit('switchTo', 'dataView')"
>
<data-view-icon />
</icon-button>
<div class="side-tool-bar-divider" v-if="$slots.default"/>
<slot/>
</div>
</template>
<script>
import IconButton from '@/components/IconButton'
import TableIcon from '@/components/svg/table'
import SqlEditorIcon from '@/components/svg/sqlEditor'
import DataViewIcon from '@/components/svg/dataView'
export default {
name: 'SideToolBar',
props: ['panel'],
components: {
IconButton,
SqlEditorIcon,
DataViewIcon,
TableIcon
}
}
</script>
<style scoped>
.side-tool-bar {
background-color: var(--color-bg-light);
border-left: 1px solid var(--color-border-light);
padding: 6px;
}
.side-tool-bar-divider {
width: 26px;
height: 1px;
background: var(--color-border-light);
margin: 6px 0;
}
</style>

View File

@@ -1,6 +1,24 @@
<template>
<div class="codemirror-container">
<codemirror ref="cm" v-model="query" :options="cmOptions" @changes="onChange" />
<div class="sql-editor-panel">
<div class="codemirror-container">
<codemirror
ref="cm"
v-model="query"
:options="cmOptions"
@changes="onChange"
/>
</div>
<side-tool-bar panel="sqlEditor" @switchTo="$emit('switchTo', $event)">
<icon-button
:disabled="runDisabled"
:loading="isGettingResults"
tooltip="Run SQL query"
tooltip-position="top-left"
@click="$emit('run')"
>
<run-icon :disabled="runDisabled"/>
</icon-button>
</side-tool-bar>
</div>
</template>
@@ -13,16 +31,23 @@ import 'codemirror/mode/sql/sql.js'
import 'codemirror/theme/neo.css'
import 'codemirror/addon/hint/show-hint.css'
import 'codemirror/addon/display/autorefresh.js'
import SideToolBar from '../SideToolBar'
import IconButton from '@/components/IconButton'
import RunIcon from '@/components/svg/run'
export default {
name: 'SqlEditor',
props: ['value'],
components: { codemirror },
props: ['value', 'isGettingResults'],
components: {
codemirror,
SideToolBar,
IconButton,
RunIcon
},
data () {
return {
query: this.value,
cmOptions: {
// codemirror options
tabSize: 4,
mode: 'text/x-mysql',
theme: 'neo',
@@ -33,6 +58,11 @@ export default {
}
}
},
computed: {
runDisabled () {
return (!this.$store.state.db || !this.query || this.isGettingResults)
}
},
watch: {
query () {
this.$emit('input', this.query)
@@ -48,9 +78,18 @@ export default {
</script>
<style scoped>
.sql-editor-panel {
display: flex;
flex-grow: 1;
height: 100%;
max-height: 100%;
box-sizing: border-box;
overflow: hidden;
}
.codemirror-container {
flex-grow: 1;
min-height: 0;
overflow: auto;
}
>>> .vue-codemirror {

View File

@@ -0,0 +1,160 @@
<template>
<div class="tab-content-container" v-show="isActive">
<splitpanes
class="query-results-splitter"
horizontal
:before="{ size: 50, max: 100 }"
:after="{ size: 50, max: 100 }"
>
<template #left-pane>
<div :id="'above-' + tabIndex" class="above" />
</template>
<template #right-pane>
<div :id="'bottom-'+ tabIndex" ref="bottomPane" class="bottomPane" />
</template>
</splitpanes>
<div :id="'hidden-'+ tabIndex" class="hidden-part" />
<teleport :to="`#${layout.sqlEditor}-${tabIndex}`">
<sql-editor
ref="sqlEditor"
v-model="query"
:is-getting-results="isGettingResults"
@switchTo="onSwitchView('sqlEditor', $event)"
@run="execute"
/>
</teleport>
<teleport :to="`#${layout.table}-${tabIndex}`">
<run-result
:result="result"
:is-getting-results="isGettingResults"
:error="error"
:time="time"
@switchTo="onSwitchView('table', $event)"
/>
</teleport>
<teleport :to="`#${layout.dataView}-${tabIndex}`">
<data-view
:data-source="result"
:init-options="initViewOptions"
:init-mode="initViewType"
ref="dataView"
@switchTo="onSwitchView('dataView', $event)"
@update="onDataViewUpdate"
/>
</teleport>
</div>
</template>
<script>
import Splitpanes from '@/components/Splitpanes'
import SqlEditor from './SqlEditor'
import DataView from './DataView'
import RunResult from './RunResult'
import time from '@/lib/utils/time'
import Teleport from 'vue2-teleport'
export default {
name: 'Tab',
props: ['id', 'initName', 'initQuery', 'initViewOptions', 'tabIndex', 'isPredefined', 'initViewType'],
components: {
SqlEditor,
DataView,
RunResult,
Splitpanes,
Teleport
},
data () {
return {
query: this.initQuery,
result: null,
isGettingResults: false,
error: null,
time: 0,
layout: {
sqlEditor: 'above',
table: 'bottom',
dataView: 'hidden'
}
}
},
computed: {
isActive () {
return this.id === this.$store.state.currentTabId
}
},
watch: {
isActive: {
immediate: true,
async handler () {
if (this.isActive) {
this.$store.commit('setCurrentTab', this)
await this.$nextTick()
this.$refs.sqlEditor.focus()
}
}
},
query () {
this.$store.commit('updateTab', { index: this.tabIndex, isSaved: false })
}
},
methods: {
onSwitchView (from, to) {
const fromPosition = this.layout[from]
this.layout[from] = this.layout[to]
this.layout[to] = fromPosition
},
onDataViewUpdate () {
this.$store.commit('updateTab', { index: this.tabIndex, isSaved: false })
},
async execute () {
this.isGettingResults = true
this.result = null
this.error = null
const state = this.$store.state
try {
const start = new Date()
this.result = await state.db.execute(this.query + ';')
this.time = time.getPeriod(start, new Date())
} catch (err) {
this.error = {
type: 'error',
message: err
}
}
state.db.refreshSchema()
this.isGettingResults = false
}
}
}
</script>
<style scoped>
.above {
height: 100%;
max-height: 100%;
}
.hidden-part {
display: none;
}
.tab-content-container {
background-color: var(--color-white);
border-top: 1px solid var(--color-border-light);
margin-top: -1px;
}
.bottomPane {
height: 100%;
background-color: var(--color-bg-light);
}
.query-results-splitter {
height: calc(100vh - 104px);
background-color: var(--color-bg-light);
}
</style>

View File

@@ -8,7 +8,7 @@
:class="[{'tab-selected': (tab.id === selectedIndex)}, 'tab']"
>
<div class="tab-name">
<span v-show="tab.isUnsaved" class="star">*</span>
<span v-show="!tab.isSaved" class="star">*</span>
<span v-if="tab.name">{{ tab.name }}</span>
<span v-else class="tab-untitled">{{ tab.tempName }}</span>
</div>
@@ -23,14 +23,15 @@
:id="tab.id"
:init-name="tab.name"
:init-query="tab.query"
:init-chart="tab.chart"
:init-view-options="tab.viewOptions"
:init-view-type="tab.viewType"
:is-predefined="tab.isPredefined"
:tab-index="index"
/>
<div v-show="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>
<span class="link" @click="$root.$emit('createNewInquiry')">Create</span>
new inquiry from scratch or open one from
<router-link class="link" to="/inquiries">Inquiries</router-link>
</div>
<!--Close tab warning dialog -->
@@ -88,7 +89,7 @@ export default {
},
methods: {
leavingSqliteviz (event) {
if (this.tabs.some(tab => tab.isUnsaved)) {
if (this.tabs.some(tab => !tab.isSaved)) {
event.preventDefault()
event.returnValue = ''
}
@@ -98,7 +99,7 @@ export default {
},
beforeCloseTab (index) {
this.closingTabIndex = index
if (this.tabs[index].isUnsaved) {
if (!this.tabs[index].isSaved) {
this.$modal.show('close-warn')
} else {
this.closeTab(index)
@@ -110,14 +111,14 @@ export default {
this.$store.commit('deleteTab', index)
},
saveAndClose (index) {
this.$root.$on('querySaved', () => {
this.$root.$on('inquirySaved', () => {
this.closeTab(index)
this.$root.$off('querySaved')
this.$root.$off('inquirySaved')
})
this.selectTab(this.tabs[index].id)
this.$modal.hide('close-warn')
this.$nextTick(() => {
this.$root.$emit('saveQuery')
this.$root.$emit('saveInquiry')
})
}
}
@@ -159,18 +160,29 @@ export default {
flex-shrink: 1;
}
#tabs-header div:hover {
#tabs-header .tab:hover {
cursor: pointer;
}
#tabs-header .tab-selected {
color: var(--color-text-active);
font-weight: 600;
border-bottom: none;
background-color: var(--color-white);
position: relative;
}
#tabs-header .tab-selected:hover {
cursor: default;
#tabs-header .tab-selected:after {
content: '';
width: 100%;
height: 4px;
background-color: var(--color-accent);
position: absolute;
left: 0;
bottom: 0;
}
#tabs-header .tab.tab-selected:hover {
cursor: default;
}
.close-icon {

View File

@@ -21,7 +21,7 @@ import Schema from './Schema'
import Tabs from './Tabs'
export default {
name: 'Editor',
name: 'Workspace',
components: {
Schema,
Splitpanes,

View File

@@ -1,7 +1,7 @@
<template>
<div>
<main-menu />
<keep-alive include="Editor">
<keep-alive include="Workspace">
<router-view id="main-view" />
</keep-alive>
</div>