mirror of
https://github.com/lana-k/sqliteviz.git
synced 2025-12-07 02:28:54 +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:
@@ -0,0 +1,29 @@
|
||||
import dereference from 'react-chart-editor/lib/lib/dereference'
|
||||
|
||||
export function getOptionsFromDataSources (dataSources) {
|
||||
if (!dataSources) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Object.keys(dataSources).map(name => ({
|
||||
value: name,
|
||||
label: name
|
||||
}))
|
||||
}
|
||||
|
||||
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))
|
||||
const emptySources = {}
|
||||
for (const key in dataSources) {
|
||||
emptySources[key] = []
|
||||
}
|
||||
dereference(stateCopy.data, emptySources)
|
||||
return stateCopy
|
||||
}
|
||||
|
||||
export default {
|
||||
getOptionsFromDataSources,
|
||||
getOptionsForSave
|
||||
}
|
||||
118
src/views/Main/Workspace/Tabs/Tab/DataView/Chart/index.vue
Normal file
118
src/views/Main/Workspace/Tabs/Tab/DataView/Chart/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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]
|
||||
}
|
||||
})
|
||||
228
src/views/Main/Workspace/Tabs/Tab/DataView/Pivot/index.vue
Normal file
228
src/views/Main/Workspace/Tabs/Tab/DataView/Pivot/index.vue
Normal 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>
|
||||
118
src/views/Main/Workspace/Tabs/Tab/DataView/index.vue
Normal file
118
src/views/Main/Workspace/Tabs/Tab/DataView/index.vue
Normal 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>
|
||||
130
src/views/Main/Workspace/Tabs/Tab/RunResult.vue
Normal file
130
src/views/Main/Workspace/Tabs/Tab/RunResult.vue
Normal 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>
|
||||
67
src/views/Main/Workspace/Tabs/Tab/SideToolBar.vue
Normal file
67
src/views/Main/Workspace/Tabs/Tab/SideToolBar.vue
Normal 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>
|
||||
50
src/views/Main/Workspace/Tabs/Tab/SqlEditor/hint.js
Normal file
50
src/views/Main/Workspace/Tabs/Tab/SqlEditor/hint.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import CM from 'codemirror'
|
||||
import 'codemirror/addon/hint/show-hint.js'
|
||||
import 'codemirror/addon/hint/sql-hint.js'
|
||||
import store from '@/store'
|
||||
|
||||
export function getHints (cm, options) {
|
||||
const token = cm.getTokenAt(cm.getCursor()).string.toUpperCase()
|
||||
const result = CM.hint.sql(cm, options)
|
||||
// Don't show the hint if there is only one option
|
||||
// and the token is already completed with this option
|
||||
if (result.list.length === 1 && result.list[0].text.toUpperCase() === token) {
|
||||
result.list = []
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const hintOptions = {
|
||||
get tables () {
|
||||
const tables = {}
|
||||
if (store.state.db.schema) {
|
||||
store.state.db.schema.forEach(table => {
|
||||
tables[table.name] = table.columns.map(column => column.name)
|
||||
})
|
||||
}
|
||||
return tables
|
||||
},
|
||||
get defaultTable () {
|
||||
const schema = store.state.db.schema
|
||||
return schema && schema.length === 1 ? schema[0].name : null
|
||||
},
|
||||
completeSingle: false,
|
||||
completeOnSingleClick: true,
|
||||
alignWithWord: false
|
||||
}
|
||||
|
||||
export function showHintOnDemand (editor) {
|
||||
CM.showHint(editor, getHints, hintOptions)
|
||||
}
|
||||
|
||||
export default function showHint (editor) {
|
||||
// Don't show autocomplete after a space or semicolon or in string literals
|
||||
const token = editor.getTokenAt(editor.getCursor())
|
||||
const ch = token.string.slice(-1)
|
||||
const tokenType = token.type
|
||||
if (tokenType === 'string' || !ch || ch === ' ' || ch === ';') {
|
||||
return
|
||||
}
|
||||
|
||||
CM.showHint(editor, getHints, hintOptions)
|
||||
}
|
||||
107
src/views/Main/Workspace/Tabs/Tab/SqlEditor/index.vue
Normal file
107
src/views/Main/Workspace/Tabs/Tab/SqlEditor/index.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<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>
|
||||
|
||||
<script>
|
||||
import showHint, { showHintOnDemand } from './hint'
|
||||
import time from '@/lib/utils/time'
|
||||
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.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', 'isGettingResults'],
|
||||
components: {
|
||||
codemirror,
|
||||
SideToolBar,
|
||||
IconButton,
|
||||
RunIcon
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
query: this.value,
|
||||
cmOptions: {
|
||||
tabSize: 4,
|
||||
mode: 'text/x-mysql',
|
||||
theme: 'neo',
|
||||
lineNumbers: true,
|
||||
line: true,
|
||||
autoRefresh: true,
|
||||
extraKeys: { 'Ctrl-Space': showHintOnDemand }
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
runDisabled () {
|
||||
return (!this.$store.state.db || !this.query || this.isGettingResults)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
query () {
|
||||
this.$emit('input', this.query)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onChange: time.debounce(showHint, 400),
|
||||
focus () {
|
||||
this.$refs.cm.codemirror.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
</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;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
>>> .vue-codemirror {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
>>> .CodeMirror {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
>>> .CodeMirror-cursor {
|
||||
width: 1px;
|
||||
background: var(--color-text-base);
|
||||
}
|
||||
</style>
|
||||
160
src/views/Main/Workspace/Tabs/Tab/index.vue
Normal file
160
src/views/Main/Workspace/Tabs/Tab/index.vue
Normal 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>
|
||||
207
src/views/Main/Workspace/Tabs/index.vue
Normal file
207
src/views/Main/Workspace/Tabs/index.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div id="tabs">
|
||||
<div id="tabs-header" v-if="tabs.length > 0">
|
||||
<div
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="index"
|
||||
@click="selectTab(tab.id)"
|
||||
:class="[{'tab-selected': (tab.id === selectedIndex)}, 'tab']"
|
||||
>
|
||||
<div class="tab-name">
|
||||
<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>
|
||||
<div>
|
||||
<close-icon class="close-icon" :size="10" @click="beforeCloseTab(index)"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<tab
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab.id"
|
||||
:id="tab.id"
|
||||
:init-name="tab.name"
|
||||
:init-query="tab.query"
|
||||
: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('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 -->
|
||||
<modal name="close-warn" classes="dialog" height="auto">
|
||||
<div class="dialog-header">
|
||||
Close tab {{
|
||||
closingTabIndex !== null
|
||||
? (tabs[closingTabIndex].name || `[${tabs[closingTabIndex].tempName}]`)
|
||||
: ''
|
||||
}}
|
||||
<close-icon @click="$modal.hide('close-warn')"/>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
You have unsaved changes. Save changes in {{
|
||||
closingTabIndex !== null
|
||||
? (tabs[closingTabIndex].name || `[${tabs[closingTabIndex].tempName}]`)
|
||||
: ''
|
||||
}} before closing?
|
||||
</div>
|
||||
<div class="dialog-buttons-container">
|
||||
<button class="secondary" @click="closeTab(closingTabIndex)">
|
||||
Close without saving
|
||||
</button>
|
||||
<button class="secondary" @click="$modal.hide('close-warn')">Cancel</button>
|
||||
<button class="primary" @click="saveAndClose(closingTabIndex)">Save and close</button>
|
||||
</div>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Tab from './Tab'
|
||||
import CloseIcon from '@/components/svg/close'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Tab,
|
||||
CloseIcon
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
closingTabIndex: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
tabs () {
|
||||
return this.$store.state.tabs
|
||||
},
|
||||
selectedIndex () {
|
||||
return this.$store.state.currentTabId
|
||||
}
|
||||
},
|
||||
created () {
|
||||
window.addEventListener('beforeunload', this.leavingSqliteviz)
|
||||
},
|
||||
methods: {
|
||||
leavingSqliteviz (event) {
|
||||
if (this.tabs.some(tab => !tab.isSaved)) {
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
},
|
||||
selectTab (id) {
|
||||
this.$store.commit('setCurrentTabId', id)
|
||||
},
|
||||
beforeCloseTab (index) {
|
||||
this.closingTabIndex = index
|
||||
if (!this.tabs[index].isSaved) {
|
||||
this.$modal.show('close-warn')
|
||||
} else {
|
||||
this.closeTab(index)
|
||||
}
|
||||
},
|
||||
closeTab (index) {
|
||||
this.$modal.hide('close-warn')
|
||||
this.closingTabIndex = null
|
||||
this.$store.commit('deleteTab', index)
|
||||
},
|
||||
saveAndClose (index) {
|
||||
this.$root.$on('inquirySaved', () => {
|
||||
this.closeTab(index)
|
||||
this.$root.$off('inquirySaved')
|
||||
})
|
||||
this.selectTab(this.tabs[index].id)
|
||||
this.$modal.hide('close-warn')
|
||||
this.$nextTick(() => {
|
||||
this.$root.$emit('saveInquiry')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#tabs {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background-color: var(--color-bg-light);
|
||||
}
|
||||
#tabs-header {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
#tabs-header .tab {
|
||||
height: 36px;
|
||||
background-color: var(--color-bg-light);
|
||||
border-right: 1px solid var(--color-border-light);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
line-height: 36px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-base);
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
max-width: 200px;
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
#tabs-header .tab-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
#tabs-header .tab:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#tabs-header .tab-selected {
|
||||
color: var(--color-text-active);
|
||||
border-bottom: none;
|
||||
background-color: var(--color-white);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#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 {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
#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>
|
||||
Reference in New Issue
Block a user