1
0
mirror of https://github.com/lana-k/sqliteviz.git synced 2025-12-07 18:48:55 +08:00
This commit is contained in:
lana-k
2025-03-30 15:57:47 +02:00
parent 6f7961e1b4
commit df16383d49
64 changed files with 316 additions and 279 deletions

View File

@@ -0,0 +1,55 @@
<template>
<div>
<div class="table-name" @click="colVisible = !colVisible">
<tree-chevron :expanded="colVisible" />
{{ name }}
</div>
<div v-show="colVisible" class="columns">
<div v-for="(col, index) in columns" :key="index" class="column">
{{ col.name }}
<span class="column-type">{{ col.type }}</span>
</div>
</div>
</div>
</template>
<script>
import TreeChevron from '@/components/svg/treeChevron'
export default {
name: 'TableDescription',
components: { TreeChevron },
props: {
name: String,
columns: Array
},
data() {
return {
colVisible: false
}
}
}
</script>
<style scoped>
.table-name,
.column {
margin-top: 11px;
}
.table-name:hover {
cursor: pointer;
}
.columns {
margin-left: 24px;
}
.column-type {
display: inline-block;
background-color: var(--color-gray-light-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-small);
padding: 2px 6px;
font-size: 11px;
text-transform: uppercase;
}
</style>

View File

@@ -0,0 +1,149 @@
<template>
<div id="schema-container">
<div id="schema-filter">
<text-field v-model="filter" placeholder="Search table" width="100%" />
</div>
<div id="db">
<div class="db-name" @click="schemaVisible = !schemaVisible">
<tree-chevron v-show="schema.length > 0" :expanded="schemaVisible" />
{{ dbName }}
</div>
<db-uploader id="db-edit" type="small" />
<export-icon tooltip="Export database" @click="exportToFile" />
<add-table-icon @click="addCsvJson" />
</div>
<div v-show="schemaVisible" class="schema">
<table-description
v-for="table in schema"
:key="table.name"
:name="table.name"
:columns="table.columns"
/>
</div>
<!--Parse csv or json dialog -->
<csv-json-import
ref="addCsvJson"
:file="file"
:db="$store.state.db"
dialog-name="addCsvJson"
/>
</div>
</template>
<script>
import fIo from '@/lib/utils/fileIo'
import events from '@/lib/utils/events'
import TableDescription from './TableDescription'
import TextField from '@/components/TextField'
import TreeChevron from '@/components/svg/treeChevron'
import DbUploader from '@/components/DbUploader'
import ExportIcon from '@/components/svg/export'
import AddTableIcon from '@/components/svg/addTable'
import CsvJsonImport from '@/components/CsvJsonImport'
export default {
name: 'Schema',
components: {
TableDescription,
TextField,
TreeChevron,
DbUploader,
ExportIcon,
AddTableIcon,
CsvJsonImport
},
data() {
return {
schemaVisible: true,
filter: null,
file: null
}
},
computed: {
schema() {
if (!this.$store.state.db.schema) {
return []
}
return !this.filter
? this.$store.state.db.schema
: this.$store.state.db.schema.filter(
table =>
table.name.toUpperCase().indexOf(this.filter.toUpperCase()) !== -1
)
},
dbName() {
return this.$store.state.db.dbName
}
},
methods: {
exportToFile() {
this.$store.state.db.export(`${this.dbName}.sqlite`)
},
async addCsvJson() {
this.file = await fIo.getFileFromUser('.csv,.json,.ndjson')
await this.$nextTick()
const csvJsonImportModal = this.$refs.addCsvJson
csvJsonImportModal.reset()
await csvJsonImportModal.preview()
csvJsonImportModal.open()
const isJson = fIo.isJSON(this.file) || fIo.isNDJSON(this.file)
events.send('database.import', this.file.size, {
from: isJson ? 'json' : 'csv',
new_db: false
})
}
}
}
</script>
<style scoped>
#schema-container {
position: relative;
padding-bottom: 24px;
}
.schema {
margin-left: 12px;
padding: 0 12px;
}
#schema-filter {
padding: 32px 12px;
position: sticky;
position: -webkit-sticky;
top: 0;
width: 100%;
height: 100px;
box-sizing: border-box;
background-image: linear-gradient(white 73%, rgba(255, 255, 255, 0));
z-index: 2;
}
.schema,
.db-name {
color: var(--color-text-base);
font-size: 13px;
white-space: nowrap;
}
#db {
display: flex;
align-items: center;
margin-top: -5px;
padding: 0 12px;
}
.db-name {
cursor: pointer;
margin-right: 6px;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
}
.db-name:hover .chevron-icon path,
:deep(.table-name:hover .chevron-icon path) {
fill: var(--color-gray-dark);
}
</style>

View File

@@ -0,0 +1,195 @@
<template>
<div ref="chartContainer" class="chart-container">
<div v-show="!dataSources && visible" class="warning chart-warning">
There is no data to build a chart. Run your SQL query and make sure the
result is not empty.
</div>
<div
class="chart"
:style="{ height: !dataSources ? 'calc(100% - 40px)' : '100%' }"
>
<PlotlyEditor
v-show="visible"
ref="plotlyEditor"
:data="state.data"
:layout="state.layout"
:frames="state.frames"
:config="config"
:data-sources="dataSources"
:data-source-options="dataSourceOptions"
:plotly="plotly"
:use-resize-handler="useResizeHandler"
:debug="true"
:advanced-trace-type-selector="true"
@update="update"
@render="onRender"
/>
</div>
</div>
</template>
<script>
import { applyPureReactInVue } from 'veaury'
import plotly from 'plotly.js'
import 'react-chart-editor/lib/react-chart-editor.css'
import ReactPlotlyEditorWithPlotRef from '@/lib/ReactPlotlyEditorWithPlotRef.jsx'
import chartHelper from '@/lib/chartHelper'
import * as dereference from 'react-chart-editor/lib/lib/dereference'
import fIo from '@/lib/utils/fileIo'
import events from '@/lib/utils/events'
export default {
name: 'Chart',
components: {
PlotlyEditor: applyPureReactInVue(ReactPlotlyEditorWithPlotRef)
},
props: {
dataSources: Object,
initOptions: Object,
importToPngEnabled: Boolean,
importToSvgEnabled: Boolean,
forPivot: Boolean
},
emits: ['update:importToSvgEnabled', 'update', 'loadingImageCompleted'],
data() {
return {
plotly,
state: this.initOptions || {
data: [],
layout: { autosize: true },
frames: []
},
config: {
editable: true,
displaylogo: false,
modeBarButtonsToRemove: ['toImage']
},
visible: true,
resizeObserver: null,
useResizeHandler: this.$store.state.isWorkspaceVisible
}
},
computed: {
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
if (this.dataSources) {
dereference.default(this.state.data, this.dataSources)
this.updatePlotly()
}
}
},
created() {
// https://github.com/plotly/plotly.js/issues/4555
plotly.setPlotConfig({
notifyOnLogging: 1
})
this.$watch(
() =>
this.state &&
this.state.data &&
this.state.data
.map(trace => `${trace.type}${trace.mode ? '-' + trace.mode : ''}`)
.join(','),
value => {
events.send('viz_plotly.render', null, {
type: value,
pivot: !!this.forPivot
})
},
{ deep: true }
)
this.$emit('update:importToSvgEnabled', true)
},
mounted() {
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.chartContainer)
if (this.dataSources) {
dereference.default(this.state.data, this.dataSources)
this.updatePlotly()
}
},
activated() {
this.useResizeHandler = true
},
deactivated() {
this.useResizeHandler = false
},
beforeUnmount() {
this.resizeObserver.unobserve(this.$refs.chartContainer)
},
methods: {
async handleResize() {
this.updatePlotly()
},
onRender() {
// TODO: check changes and enable Save button if needed
},
update(data, layout, frames) {
this.state = { data, layout, frames }
this.$emit('update')
},
updatePlotly() {
const plotComponent = this.$refs.plotlyEditor.plotComponentRef.current
plotComponent.updatePlotly(
false, // shouldInvokeResizeHandler
plotComponent.props.onUpdate, // figureCallbackFunction
false // shouldAttachUpdateEvents
)
},
getOptionsForSave() {
return chartHelper.getOptionsForSave(this.state, this.dataSources)
},
async saveAsPng() {
const url = await this.prepareCopy()
this.$emit('loadingImageCompleted')
fIo.downloadFromUrl(url, 'chart')
},
async saveAsSvg() {
const url = await this.prepareCopy('svg')
fIo.downloadFromUrl(url, 'chart')
},
saveAsHtml() {
fIo.exportToFile(
chartHelper.getHtml(this.state),
'chart.html',
'text/html'
)
},
async prepareCopy(type = 'png') {
return await chartHelper.getImageDataUrl(
this.$refs.plotlyEditor.$el,
type
)
}
}
}
</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;
}
:deep(.editor_controls .sidebar__item:before) {
width: 0;
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div :class="['pivot-sort-btn', direction]" @click="changeSorting">
{{ modelValue.includes('key') ? 'key' : 'value' }}
<sort-icon
class="sort-icon"
:horizontal="direction === 'col'"
:asc="modelValue.includes('a_to_z')"
/>
</div>
</template>
<script>
import SortIcon from '@/components/svg/sort'
export default {
name: 'PivotSortBtn',
components: {
SortIcon
},
props: {
direction: String,
modelValue: String
},
emits: ['update:modelValue'],
methods: {
changeSorting() {
if (this.modelValue === 'key_a_to_z') {
this.$emit('update:modelValue', 'value_a_to_z')
} else if (this.modelValue === 'value_a_to_z') {
this.$emit('update:modelValue', 'value_z_to_a')
} else {
this.$emit('update:modelValue', '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 :deep(.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,309 @@
<template>
<div class="pivot-ui">
<div :class="{ collapsed }">
<div class="row">
<label>Columns</label>
<multiselect
v-model="cols"
class="sqliteviz-select cols"
:options="colsToSelect"
:disabled="colsToSelect.length === 0"
:multiple="true"
:hide-selected="true"
:close-on-select="true"
:show-labels="false"
:max="colsToSelect.length"
open-direction="bottom"
placeholder=""
>
<template #maxElements>
<span class="no-results">No Results</span>
</template>
<template #placeholder>Choose columns</template>
<template #noResult>
<span class="no-results">No Results</span>
</template>
</multiselect>
<pivot-sort-btn v-model="colOrder" class="sort-btn" direction="col" />
</div>
<div class="row">
<label>Rows</label>
<multiselect
v-model="rows"
class="sqliteviz-select rows"
:options="rowsToSelect"
:disabled="rowsToSelect.length === 0"
:multiple="true"
:hide-selected="true"
:close-on-select="true"
:show-labels="false"
:max="rowsToSelect.length"
:option-height="29"
open-direction="bottom"
placeholder=""
>
<template #maxElements>
<span class="no-results">No Results</span>
</template>
<template #placeholder>Choose rows</template>
<template #noResult>
<span class="no-results">No Results</span>
</template>
</multiselect>
<pivot-sort-btn v-model="rowOrder" class="sort-btn" direction="row" />
</div>
<div class="row aggregator">
<label>Aggregator</label>
<multiselect
v-model="aggregator"
class="sqliteviz-select short aggregator"
:options="aggregators"
label="name"
track-by="name"
:close-on-select="true"
:show-labels="false"
:hide-selected="true"
:option-height="29"
open-direction="bottom"
placeholder="Choose a function"
>
<template #noResult>
<span class="no-results">No Results</span>
</template>
</multiselect>
<multiselect
v-show="valCount > 0"
v-model="val1"
class="sqliteviz-select aggr-arg"
:options="keyNames"
:disabled="keyNames.length === 0"
:close-on-select="true"
:show-labels="false"
:hide-selected="true"
:option-height="29"
open-direction="bottom"
placeholder="Choose an argument"
/>
<multiselect
v-show="valCount > 1"
v-model="val2"
class="sqliteviz-select aggr-arg"
:options="keyNames"
:disabled="keyNames.length === 0"
:close-on-select="true"
:show-labels="false"
:hide-selected="true"
:option-height="29"
open-direction="bottom"
placeholder="Choose a second argument"
/>
</div>
<div class="row">
<label>View</label>
<multiselect
v-model="renderer"
class="sqliteviz-select short renderer"
:options="renderers"
label="name"
track-by="name"
:close-on-select="true"
:allow-empty="false"
:show-labels="false"
:hide-selected="true"
:option-height="29"
open-direction="bottom"
placeholder="Choose a view"
>
<template #noResult>
<span class="no-results">No Results</span>
</template>
</multiselect>
</div>
</div>
<span class="switcher" @click="collapsed = !collapsed">
{{ 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'
export default {
name: 'PivotUi',
components: {
Multiselect,
PivotSortBtn
},
props: {
keyNames: Array,
modelValue: Object
},
emits: ['update:modelValue', 'update'],
data() {
const aggregatorName =
(this.modelValue && this.modelValue.aggregatorName) || 'Count'
const rendererName =
(this.modelValue && this.modelValue.rendererName) || 'Table'
return {
collapsed: false,
renderer: {
name: rendererName,
fun: $.pivotUtilities.renderers[rendererName]
},
aggregator: {
name: aggregatorName,
fun: $.pivotUtilities.aggregators[aggregatorName]
},
rows: (this.modelValue && this.modelValue.rows) || [],
cols: (this.modelValue && this.modelValue.cols) || [],
val1:
(this.modelValue && this.modelValue.vals && this.modelValue.vals[0]) ||
'',
val2:
(this.modelValue && this.modelValue.vals && this.modelValue.vals[1]) ||
'',
colOrder: (this.modelValue && this.modelValue.colOrder) || 'key_a_to_z',
rowOrder: (this.modelValue && this.modelValue.rowOrder) || 'key_a_to_z'
}
},
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()
}
},
methods: {
returnValue() {
const vals = []
for (let i = 1; i <= this.valCount; i++) {
vals.push(this[`val${i}`])
}
this.$emit('update')
this.$emit('update:modelValue', {
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,
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,319 @@
<template>
<div class="pivot-container">
<div v-show="!dataSources" class="warning pivot-warning">
There is no data to build a pivot. Run your SQL query and make sure the
result is not empty.
</div>
<pivot-ui
v-model="pivotOptions"
:key-names="columns"
@update="$emit('update')"
/>
<div ref="pivotOutput" class="pivot-output" />
<div
v-show="viewCustomChart"
ref="customChartOutput"
class="custom-chart-output"
>
<chart
ref="customChart"
v-bind="customChartComponentProps"
@update="$emit('update')"
@loading-image-completed="$emit('loadingImageCompleted')"
/>
</div>
</div>
</template>
<script>
import fIo from '@/lib/utils/fileIo'
import $ from 'jquery'
import 'pivottable'
import 'pivottable/dist/pivot.css'
import PivotUi from './PivotUi'
import pivotHelper from './pivotHelper'
import Chart from '@/views/MainView/Workspace/Tabs/Tab/DataView/Chart'
import chartHelper from '@/lib/chartHelper'
import events from '@/lib/utils/events'
export default {
name: 'Pivot',
components: {
PivotUi,
Chart
},
props: {
dataSources: Object,
initOptions: Object,
importToPngEnabled: Boolean,
importToSvgEnabled: Boolean
},
emits: [
'loadingImageCompleted',
'update',
'update:importToSvgEnabled',
'update:importToPngEnabled'
],
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
}
: {
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]
},
customChartComponentProps: {
initOptions: this.initOptions?.rendererOptions?.customChartOptions,
forPivot: true
}
}
},
computed: {
columns() {
return Object.keys(this.dataSources || {})
},
viewStandartChart() {
return this.pivotOptions.rendererName in $.pivotUtilities.plotly_renderers
},
viewCustomChart() {
return this.pivotOptions.rendererName === 'Custom chart'
}
},
watch: {
dataSources() {
this.show()
},
'pivotOptions.rendererName': {
immediate: true,
handler() {
this.$emit(
'update:importToPngEnabled',
this.pivotOptions.rendererName !== 'TSV Export'
)
this.$emit(
'update:importToSvgEnabled',
this.viewStandartChart || this.viewCustomChart
)
events.send('viz_pivot.render', null, {
type: this.pivotOptions.rendererName
})
}
},
pivotOptions() {
this.show()
}
},
mounted() {
this.show()
// We need to detect resizing because plotly doesn't resize when resize 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.customChartOutput)
},
beforeUnmount() {
this.resizeObserver.unobserve(this.$refs.customChartOutput)
},
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.viewStandartChart) {
window.dispatchEvent(new Event('resize'))
}
},
show() {
const options = { ...this.pivotOptions }
if (this.viewStandartChart) {
options.rendererOptions = {
plotly: {
autosize: true,
width: null,
height: null
},
plotlyConfig: {
displaylogo: false,
responsive: true,
modeBarButtonsToRemove: ['toImage']
}
}
}
if (this.viewCustomChart) {
options.rendererOptions = {
getCustomComponentsProps: () => this.customChartComponentProps
}
}
$(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.viewStandartChart) {
window.dispatchEvent(new Event('resize'))
}
},
getOptionsForSave() {
const options = { ...this.pivotOptions }
if (this.viewCustomChart) {
const chartComponent = this.$refs.customChart
options.rendererOptions = {
customChartOptions: chartComponent.getOptionsForSave()
}
}
return options
},
async saveAsPng() {
if (this.viewCustomChart) {
this.$refs.customChart.saveAsPng()
} else {
const source = this.viewStandartChart
? await chartHelper.getImageDataUrl(this.$refs.pivotOutput, 'png')
: (
await pivotHelper.getPivotCanvas(this.$refs.pivotOutput)
).toDataURL('image/png')
this.$emit('loadingImageCompleted')
fIo.downloadFromUrl(source, 'pivot')
}
},
async prepareCopy() {
if (this.viewCustomChart) {
return await this.$refs.customChart.prepareCopy()
}
if (this.viewStandartChart) {
return await chartHelper.getImageDataUrl(this.$refs.pivotOutput, 'png')
}
return await pivotHelper.getPivotCanvas(this.$refs.pivotOutput)
},
async saveAsSvg() {
if (this.viewCustomChart) {
this.$refs.customChart.saveAsSvg()
} else if (this.viewStandartChart) {
const url = await chartHelper.getImageDataUrl(
this.$refs.pivotOutput,
'svg'
)
fIo.downloadFromUrl(url, 'pivot')
}
},
saveAsHtml() {
if (this.viewCustomChart) {
this.$refs.customChart.saveAsHtml()
return
}
if (this.viewStandartChart) {
const chartState = chartHelper.getChartData(this.$refs.pivotOutput)
fIo.exportToFile(
chartHelper.getHtml(chartState),
'chart.html',
'text/html'
)
return
}
fIo.exportToFile(
pivotHelper.getPivotHtml(this.$refs.pivotOutput),
'pivot.html',
'text/html'
)
}
}
}
</script>
<style scoped>
.pivot-container {
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--color-white);
}
.pivot-output,
.custom-chart-output {
flex-grow: 1;
width: 100%;
overflow: auto;
}
.pivot-warning {
height: 40px;
line-height: 40px;
box-sizing: border-box;
}
:deep(.pvtTable) {
min-width: 100%;
}
:deep(table.pvtTable tbody tr td),
:deep(table.pvtTable thead tr th),
:deep(table.pvtTable tbody tr th) {
border-color: var(--color-border-light);
}
:deep(table.pvtTable thead tr th),
:deep(table.pvtTable tbody tr th) {
background-color: var(--color-bg-dark);
color: var(--color-text-light);
}
:deep(table.pvtTable tbody tr td) {
color: var(--color-text-base);
}
.pivot-output :deep(textarea) {
color: var(--color-text-base);
min-width: 100%;
height: 100% !important;
display: block;
box-sizing: border-box;
border-width: 0;
}
.pivot-output :deep(textarea:focus-visible) {
outline: none;
}
.pivot-output:empty {
flex-grow: 0;
}
</style>

View File

@@ -0,0 +1,121 @@
import $ from 'jquery'
import 'pivottable'
import 'pivottable/dist/export_renderers.js'
import 'pivottable/dist/plotly_renderers.js'
import html2canvas from 'html2canvas'
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) {
const propsRef = options.getCustomComponentsProps()
propsRef.dataSources = _getDataSources(data)
return null
}
$.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]
}
}
)
export async function getPivotCanvas(pivotOutput) {
const tableElement = pivotOutput.querySelector('.pvtTable')
return await html2canvas(tableElement, { logging: false })
}
export function getPivotHtml(pivotOutput) {
return `
<style>
table.pvtTable {
font-family: Arial, sans-serif;
font-size: 12px;
text-align: left;
border-collapse: collapse;
min-width: 100%;
}
table.pvtTable .pvtColLabel {
text-align: center;
}
table.pvtTable .pvtTotalLabel {
text-align: right;
}
table.pvtTable tbody tr td {
color: #506784;
border: 1px solid #DFE8F3;
text-align: right;
}
table.pvtTable thead tr th,
table.pvtTable tbody tr th {
background-color: #506784;
color: #fff;
border: 1px solid #DFE8F3;
}
</style>
${pivotOutput.outerHTML}
`
}
export default {
getPivotCanvas,
getPivotHtml
}

View File

@@ -0,0 +1,245 @@
<template>
<div class="data-view-panel">
<div class="data-view-panel-content">
<component
:is="mode"
ref="viewComponent"
v-model:import-to-png-enabled="importToPngEnabled"
v-model:import-to-svg-enabled="importToSvgEnabled"
:init-options="mode === initMode ? initOptions : undefined"
:data-sources="dataSource"
@loading-image-completed="loadingImage = false"
@update="$emit('update')"
/>
</div>
<side-tool-bar panel="dataView" @switch-to="$emit('switchTo', $event)">
<icon-button
:active="mode === 'chart'"
tooltip="Switch to chart"
tooltip-position="top-left"
@click="mode = 'chart'"
>
<chart-icon />
</icon-button>
<icon-button
ref="pivotBtn"
:active="mode === 'pivot'"
tooltip="Switch to pivot"
tooltip-position="top-left"
@click="mode = 'pivot'"
>
<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>
<icon-button
ref="svgExportBtn"
:disabled="!importToSvgEnabled"
tooltip="Save as SVG"
tooltip-position="top-left"
@click="saveAsSvg"
>
<export-to-svg-icon />
</icon-button>
<icon-button
ref="htmlExportBtn"
tooltip="Save as HTML"
tooltip-position="top-left"
@click="saveAsHtml"
>
<HtmlIcon />
</icon-button>
<icon-button
ref="copyToClipboardBtn"
:loading="copyingImage"
tooltip="Copy visualisation to clipboard"
tooltip-position="top-left"
@click="prepareCopy"
>
<clipboard-icon />
</icon-button>
</side-tool-bar>
<loading-dialog
loading-msg="Rendering the visualisation..."
success-msg="Image is ready"
action-btn-name="Copy"
name="prepareCopy"
title="Copy to clipboard"
:loading="preparingCopy"
@action="copyToClipboard"
@cancel="cancelCopy"
/>
</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 HtmlIcon from '@/components/svg/html'
import ExportToSvgIcon from '@/components/svg/exportToSvg'
import PngIcon from '@/components/svg/png'
import ClipboardIcon from '@/components/svg/clipboard'
import cIo from '@/lib/utils/clipboardIo'
import loadingDialog from '@/components/LoadingDialog'
import time from '@/lib/utils/time'
import events from '@/lib/utils/events'
export default {
name: 'DataView',
components: {
Chart,
Pivot,
SideToolBar,
IconButton,
ChartIcon,
PivotIcon,
ExportToSvgIcon,
PngIcon,
HtmlIcon,
ClipboardIcon,
loadingDialog
},
props: {
dataSource: Object,
initOptions: Object,
initMode: String
},
emits: ['update', 'switchTo'],
data() {
return {
mode: this.initMode || 'chart',
importToPngEnabled: true,
importToSvgEnabled: true,
loadingImage: false,
copyingImage: false,
preparingCopy: false,
dataToCopy: null
}
},
computed: {
plotlyInPivot() {
return this.mode === 'pivot' && this.$refs.viewComponent.viewCustomChart
}
},
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.
http://www.hesselinkwebdesign.nl/2019/nexttick-vs-settimeout-in-vue/
*/
await time.sleep(0)
this.$refs.viewComponent.saveAsPng()
this.exportSignal('png')
},
getOptionsForSave() {
return this.$refs.viewComponent.getOptionsForSave()
},
async prepareCopy() {
if ('ClipboardItem' in window) {
this.preparingCopy = true
this.$modal.show('prepareCopy')
const t0 = performance.now()
await time.sleep(0)
this.dataToCopy = await this.$refs.viewComponent.prepareCopy()
const t1 = performance.now()
if (t1 - t0 < 950) {
this.$modal.hide('prepareCopy')
this.copyToClipboard()
} else {
this.preparingCopy = false
}
} else {
alert(
"Your browser doesn't support copying images into the clipboard. " +
'If you use Firefox you can enable it ' +
'by setting dom.events.asyncClipboard.clipboardItem to true.'
)
}
},
async copyToClipboard() {
cIo.copyImage(this.dataToCopy)
this.$modal.hide('prepareCopy')
this.exportSignal('clipboard')
},
cancelCopy() {
this.dataToCopy = null
this.$modal.hide('prepareCopy')
},
saveAsSvg() {
this.$refs.viewComponent.saveAsSvg()
this.exportSignal('svg')
},
saveAsHtml() {
this.$refs.viewComponent.saveAsHtml()
this.exportSignal('html')
},
exportSignal(to) {
const eventLabels = { type: to }
if (this.mode === 'chart' || this.plotlyInPivot) {
eventLabels.pivot = this.plotlyInPivot
}
events.send(
this.mode === 'chart' || this.plotlyInPivot
? 'viz_plotly.export'
: 'viz_pivot.export',
null,
eventLabels
)
}
}
}
</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,70 @@
<template>
<div class="record-navigator">
<icon-button
:disabled="modelValue === 0"
tooltip="First row"
tooltip-position="top-left"
class="first"
@click="$emit('update:modelValue', 0)"
>
<edge-arrow-icon :disabled="false" />
</icon-button>
<icon-button
:disabled="modelValue === 0"
tooltip="Previous row"
tooltip-position="top-left"
class="prev"
@click="$emit('update:modelValue', modelValue - 1)"
>
<arrow-icon :disabled="false" />
</icon-button>
<icon-button
:disabled="modelValue === total - 1"
tooltip="Next row"
tooltip-position="top-left"
class="next"
@click="$emit('update:modelValue', modelValue + 1)"
>
<arrow-icon :disabled="false" />
</icon-button>
<icon-button
:disabled="modelValue === total - 1"
tooltip="Last row"
tooltip-position="top-left"
class="last"
@click="$emit('update:modelValue', total - 1)"
>
<edge-arrow-icon :disabled="false" />
</icon-button>
</div>
</template>
<script>
import IconButton from '@/components/IconButton'
import ArrowIcon from '@/components/svg/arrow'
import EdgeArrowIcon from '@/components/svg/edgeArrow'
export default {
components: {
IconButton,
ArrowIcon,
EdgeArrowIcon
},
props: {
modelValue: Number,
total: Number
},
emits: ['update:modelValue']
}
</script>
<style scoped>
.record-navigator {
display: flex;
}
.record-navigator .next,
.record-navigator .last {
transform: rotate(180deg);
}
</style>

View File

@@ -0,0 +1,228 @@
<template>
<div class="record-view">
<div class="table-container">
<table
ref="table"
class="sqliteviz-table"
tabindex="0"
@keydown="onTableKeydown"
>
<thead>
<tr>
<th />
<th>
<div class="cell-data">Row #{{ currentRowIndex + 1 }}</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(col, index) in columns" :key="index">
<th class="column-cell" :title="col">
{{ col }}
</th>
<td
:key="index"
:data-col="index"
:data-row="currentRowIndex"
:data-isNull="isNull(getCellValue(col))"
:data-isBlob="isBlob(getCellValue(col))"
:aria-selected="false"
@click="onCellClick"
>
<div class="cell-data">
{{ getCellText(col) }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="table-footer">
<div class="table-footer-count">
{{ rowCount }} {{ rowCount === 1 ? 'row' : 'rows' }} retrieved
<span v-if="time">in {{ time }}</span>
</div>
<row-navigator v-model="currentRowIndex" :total="rowCount" />
</div>
</div>
</template>
<script>
import RowNavigator from './RowNavigator.vue'
import { nextTick } from 'vue'
export default {
components: { RowNavigator },
props: {
dataSet: Object,
time: String,
rowIndex: { type: Number, default: 0 },
selectedColumnIndex: Number
},
emits: ['updateSelectedCell'],
data() {
return {
selectedCellElement: null,
currentRowIndex: this.rowIndex
}
},
computed: {
columns() {
return this.dataSet.columns
},
rowCount() {
return this.dataSet.values[this.columns[0]].length
}
},
watch: {
async currentRowIndex() {
await nextTick()
if (this.selectedCellElement) {
const previouslySelected = this.selectedCellElement
this.selectCell(null)
this.selectCell(previouslySelected)
}
}
},
mounted() {
const col = this.selectedColumnIndex
const row = this.currentRowIndex
const cell = this.$refs.table.querySelector(
`td[data-col="${col}"][data-row="${row}"]`
)
if (cell) {
this.selectCell(cell)
}
},
methods: {
isBlob(value) {
return value && ArrayBuffer.isView(value)
},
isNull(value) {
return value === null
},
getCellValue(col) {
return this.dataSet.values[col][this.currentRowIndex]
},
getCellText(col) {
const value = this.getCellValue(col)
if (this.isNull(value)) {
return 'NULL'
}
if (this.isBlob(value)) {
return 'BLOB'
}
return value
},
onTableKeydown(e) {
const keyCodeMap = {
38: 'up',
40: 'down'
}
if (
!this.selectedCellElement ||
!Object.keys(keyCodeMap).includes(e.keyCode.toString())
) {
return
}
e.preventDefault()
this.moveFocusInTable(this.selectedCellElement, keyCodeMap[e.keyCode])
},
onCellClick(e) {
this.selectCell(e.target.closest('td'), false)
},
selectCell(cell, scrollTo = true) {
if (!cell) {
if (this.selectedCellElement) {
this.selectedCellElement.ariaSelected = 'false'
}
this.selectedCellElement = cell
} else if (!cell.ariaSelected || cell.ariaSelected === 'false') {
if (this.selectedCellElement) {
this.selectedCellElement.ariaSelected = 'false'
}
cell.ariaSelected = 'true'
this.selectedCellElement = cell
} else {
cell.ariaSelected = 'false'
this.selectedCellElement = null
}
if (this.selectedCellElement && scrollTo) {
this.selectedCellElement.scrollIntoView()
this.selectedCellElement
.closest('.table-container')
.scrollTo({ left: 0 })
}
this.$emit('updateSelectedCell', this.selectedCellElement)
},
moveFocusInTable(initialCell, direction) {
const currentColIndex = +initialCell.dataset.col
const newColIndex =
direction === 'up' ? currentColIndex - 1 : currentColIndex + 1
const newCell = this.$refs.table.querySelector(
`td[data-col="${newColIndex}"][data-row="${this.currentRowIndex}"]`
)
if (newCell) {
this.selectCell(newCell)
}
}
}
}
</script>
<style scoped>
table.sqliteviz-table:focus {
outline: none;
}
.sqliteviz-table tbody td:hover {
background-color: var(--color-bg-light-3);
}
.sqliteviz-table tbody td[aria-selected='true'] {
box-shadow: inset 0 0 0 1px var(--color-accent);
}
table.sqliteviz-table {
margin-top: 0;
}
.sqliteviz-table thead tr th {
border-bottom: 1px solid var(--color-border-light);
text-align: left;
}
.sqliteviz-table tbody tr th {
font-size: 14px;
font-weight: 600;
box-sizing: border-box;
background-color: var(--color-bg-dark);
color: var(--color-text-light);
border-bottom: 1px solid var(--color-border-light);
border-right: 1px solid var(--color-border-light);
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.table-footer {
align-items: center;
}
.record-view {
display: flex;
flex-direction: column;
height: 100%;
}
.table-container {
flex-grow: 1;
overflow: auto;
}
.column-cell {
max-width: 150px;
width: 0;
}
</style>

View File

@@ -0,0 +1,222 @@
<template>
<div class="value-viewer">
<div class="value-viewer-toolbar">
<button
v-for="format in formats"
:key="format.value"
type="button"
:aria-selected="currentFormat === format.value"
:class="format.value"
@click="currentFormat = format.value"
>
{{ format.text }}
</button>
<button type="button" class="copy" @click="copyToClipboard">Copy</button>
<button
type="button"
class="line-wrap"
:aria-selected="lineWrapping === true"
@click="lineWrapping = !lineWrapping"
>
Line wrap
</button>
</div>
<div class="value-body">
<codemirror
v-if="currentFormat === 'json' && formattedJson"
:value="formattedJson"
:options="cmOptions"
class="json-value original-style"
/>
<pre
v-if="currentFormat === 'text'"
:class="[
'text-value',
{ 'meta-value': isNull || isBlob },
{ 'line-wrap': lineWrapping }
]"
>{{ cellText }}</pre
>
<logs
v-if="messages && messages.length > 0"
:messages="messages"
class="messages"
/>
</div>
</div>
</template>
<script>
import Codemirror from 'codemirror-editor-vue3'
import 'codemirror/lib/codemirror.css'
import 'codemirror/mode/javascript/javascript.js'
import 'codemirror/addon/fold/foldcode.js'
import 'codemirror/addon/fold/foldgutter.js'
import 'codemirror/addon/fold/foldgutter.css'
import 'codemirror/addon/fold/brace-fold.js'
import 'codemirror/theme/neo.css'
import cIo from '@/lib/utils/clipboardIo'
import Logs from '@/components/Logs'
export default {
components: {
Codemirror,
Logs
},
props: {
cellValue: [String, Number, Uint8Array]
},
data() {
return {
formats: [
{ text: 'Text', value: 'text' },
{ text: 'JSON', value: 'json' }
],
currentFormat: 'text',
lineWrapping: false,
formattedJson: '',
messages: []
}
},
computed: {
cmOptions() {
return {
tabSize: 4,
mode: { name: 'javascript', json: true },
theme: 'neo',
lineNumbers: true,
line: true,
lineWrapping: this.lineWrapping,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
readOnly: true
}
},
isBlob() {
return this.cellValue && ArrayBuffer.isView(this.cellValue)
},
isNull() {
return this.cellValue === null
},
cellText() {
const value = this.cellValue
if (this.isNull) {
return 'NULL'
}
if (this.isBlob) {
return 'BLOB'
}
return value
}
},
watch: {
currentFormat() {
this.messages = []
this.formattedJson = ''
if (this.currentFormat === 'json') {
this.formatJson(this.cellValue)
}
},
cellValue() {
this.messages = []
if (this.currentFormat === 'json') {
this.formatJson(this.cellValue)
}
}
},
methods: {
formatJson(jsonStr) {
try {
this.formattedJson = JSON.stringify(JSON.parse(jsonStr), null, 4)
} catch (e) {
this.formattedJson = ''
this.messages = [
{
type: 'error',
message: "Can't parse JSON."
}
]
}
},
copyToClipboard() {
cIo.copyText(
this.currentFormat === 'json' ? this.formattedJson : this.cellValue,
'The value is copied to clipboard.'
)
}
}
}
</script>
<style scoped>
.value-viewer {
background-color: var(--color-white);
height: 100%;
display: flex;
flex-direction: column;
}
.value-viewer-toolbar {
display: flex;
justify-content: end;
}
.value-body {
flex-grow: 1;
overflow: auto;
}
.text-value {
padding: 0 8px;
margin: 0;
color: var(--color-text-base);
}
.json-value {
margin-top: -4px;
}
.text-value.meta-value {
font-style: italic;
color: var(--color-text-light-2);
}
.text-value.line-wrap {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.messages {
margin: 0 8px;
}
.value-viewer-toolbar button {
font-size: 10px;
height: 20px;
padding: 0 8px;
border: none;
background: transparent;
color: var(--color-text-base);
border-radius: var(--border-radius-small);
}
.value-viewer-toolbar button:hover {
background-color: var(--color-bg-light);
}
.value-viewer-toolbar button[aria-selected='true'] {
color: var(--color-accent);
}
:deep(.codemirror-container) {
display: block;
height: 100%;
max-height: 100%;
}
:deep(.CodeMirror) {
height: 100%;
max-height: 100%;
}
:deep(.CodeMirror-cursor) {
width: 1px;
background: var(--color-text-base);
}
</style>

View File

@@ -0,0 +1,388 @@
<template>
<div ref="runResultPanel" class="run-result-panel">
<component
:is="viewValuePanelVisible ? 'splitpanes' : 'div'"
:before="{ size: 50, max: 100 }"
:after="{ size: 50, max: 100 }"
:default="{ before: 50, after: 50 }"
class="run-result-panel-content"
>
<template #left-pane>
<div
:id="'run-result-left-pane-' + tab.id"
class="result-set-container"
/>
</template>
<div
:id="'run-result-result-set-' + tab.id"
class="result-set-container"
/>
<template v-if="viewValuePanelVisible" #right-pane>
<div class="value-viewer-container">
<value-viewer
v-show="selectedCell"
:cell-value="
selectedCell
? result.values[result.columns[selectedCell.dataset.col]][
selectedCell.dataset.row
]
: ''
"
/>
<div v-show="!selectedCell" class="table-preview">
No cell selected to view
</div>
</div>
</template>
</component>
<side-tool-bar panel="table" @switch-to="$emit('switchTo', $event)">
<icon-button
:disabled="!result"
tooltip="Export result set to CSV file"
tooltip-position="top-left"
@click="exportToCsv"
>
<export-to-csv-icon />
</icon-button>
<icon-button
ref="copyToClipboardBtn"
:disabled="!result"
tooltip="Copy result set to clipboard"
tooltip-position="top-left"
@click="prepareCopy"
>
<clipboard-icon />
</icon-button>
<icon-button
ref="rowBtn"
:disabled="!result"
tooltip="View record"
tooltip-position="top-left"
:active="viewRecord"
@click="toggleViewRecord"
>
<row-icon />
</icon-button>
<icon-button
ref="viewCellValueBtn"
:disabled="!result"
tooltip="View value"
tooltip-position="top-left"
:active="viewValuePanelVisible"
@click="toggleViewValuePanel"
>
<view-cell-value-icon />
</icon-button>
</side-tool-bar>
<loading-dialog
loading-msg="Building CSV..."
success-msg="CSV is ready"
action-btn-name="Copy"
name="prepareCSVCopy"
title="Copy to clipboard"
:loading="preparingCopy"
@action="copyToClipboard"
@cancel="cancelCopy"
/>
<teleport defer :to="resultSetTeleportTarget" :disabled="!enableTeleport">
<div>
<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 && !viewRecord"
:data-set="result"
:time="time"
:page-size="pageSize"
:page="defaultPage"
:selected-cell-coordinates="defaultSelectedCell"
class="straight"
@update-selected-cell="onUpdateSelectedCell"
/>
<record
v-if="result && viewRecord"
ref="recordView"
:data-set="result"
:time="time"
:selected-column-index="selectedCell ? +selectedCell.dataset.col : 0"
:row-index="selectedCell ? +selectedCell.dataset.row : 0"
@update-selected-cell="onUpdateSelectedCell"
/>
</div>
</teleport>
</div>
</template>
<script>
import Logs from '@/components/Logs'
import SqlTable from '@/components/SqlTable/index.vue'
import LoadingIndicator from '@/components/LoadingIndicator'
import SideToolBar from '../SideToolBar'
import Splitpanes from '@/components/Splitpanes'
import ExportToCsvIcon from '@/components/svg/exportToCsv'
import ClipboardIcon from '@/components/svg/clipboard'
import ViewCellValueIcon from '@/components/svg/viewCellValue'
import RowIcon from '@/components/svg/row'
import IconButton from '@/components/IconButton'
import csv from '@/lib/csv'
import fIo from '@/lib/utils/fileIo'
import cIo from '@/lib/utils/clipboardIo'
import time from '@/lib/utils/time'
import loadingDialog from '@/components/LoadingDialog'
import events from '@/lib/utils/events'
import ValueViewer from './ValueViewer'
import Record from './Record/index.vue'
export default {
name: 'RunResult',
components: {
SqlTable,
LoadingIndicator,
Logs,
SideToolBar,
ExportToCsvIcon,
IconButton,
ClipboardIcon,
ViewCellValueIcon,
RowIcon,
loadingDialog,
ValueViewer,
Record,
Splitpanes
},
props: {
tab: Object,
result: Object,
isGettingResults: Boolean,
error: Object,
time: [String, Number]
},
emits: ['switchTo'],
data() {
return {
resizeObserver: null,
pageSize: 20,
preparingCopy: false,
dataToCopy: null,
viewValuePanelVisible: false,
selectedCell: null,
viewRecord: false,
defaultPage: 1,
defaultSelectedCell: null,
enableTeleport: this.$store.state.isWorkspaceVisible
}
},
computed: {
resultSetTeleportTarget() {
if (!this.enableTeleport) {
return undefined
}
const base = `#${
this.viewValuePanelVisible
? 'run-result-left-pane'
: 'run-result-result-set'
}`
const tabIdPostfix = `-${this.tab.id}`
return base + tabIdPostfix
}
},
watch: {
result() {
this.defaultSelectedCell = null
this.selectedCell = null
}
},
activated() {
this.enableTeleport = true
},
deactivated() {
this.enableTeleport = false
},
mounted() {
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.runResultPanel)
this.calculatePageSize()
},
beforeUnmount() {
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)
},
exportToCsv() {
if (this.result && this.result.values) {
events.send(
'resultset.export',
this.result.values[this.result.columns[0]].length,
{ to: 'csv' }
)
}
fIo.exportToFile(csv.serialize(this.result), 'result_set.csv', 'text/csv')
},
async prepareCopy() {
if (this.result && this.result.values) {
events.send(
'resultset.export',
this.result.values[this.result.columns[0]].length,
{ to: 'clipboard' }
)
}
if ('ClipboardItem' in window) {
this.preparingCopy = true
this.$modal.show('prepareCSVCopy')
const t0 = performance.now()
await time.sleep(0)
this.dataToCopy = csv.serialize(this.result)
const t1 = performance.now()
if (t1 - t0 < 950) {
this.$modal.hide('prepareCSVCopy')
this.copyToClipboard()
} else {
this.preparingCopy = false
}
} else {
alert(
"Your browser doesn't support copying into the clipboard. " +
'If you use Firefox you can enable it ' +
'by setting dom.events.asyncClipboard.clipboardItem to true.'
)
}
},
copyToClipboard() {
cIo.copyText(this.dataToCopy, 'CSV copied to clipboard successfully')
this.$modal.hide('prepareCSVCopy')
},
cancelCopy() {
this.dataToCopy = null
this.$modal.hide('prepareCSVCopy')
},
toggleViewValuePanel() {
this.viewValuePanelVisible = !this.viewValuePanelVisible
},
toggleViewRecord() {
if (this.viewRecord) {
this.defaultSelectedCell = {
row: this.$refs.recordView.currentRowIndex,
col: this.selectedCell ? +this.selectedCell.dataset.col : 0
}
this.defaultPage = Math.ceil(
(this.$refs.recordView.currentRowIndex + 1) / this.pageSize
)
}
this.viewRecord = !this.viewRecord
},
onUpdateSelectedCell(e) {
this.selectedCell = e
}
}
}
</script>
<style scoped>
.run-result-panel {
display: flex;
height: 100%;
overflow: hidden;
}
.run-result-panel-content {
flex-grow: 1;
height: 100%;
width: 0;
}
.result-set-container,
.result-set-container > div {
position: relative;
height: 100%;
width: 100%;
box-sizing: border-box;
}
.value-viewer-container {
height: 100%;
width: 100%;
background-color: var(--color-white);
position: relative;
}
.table-preview {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--color-text-base);
font-size: 13px;
text-align: center;
}
.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,73 @@
<template>
<div class="side-tool-bar">
<icon-button
ref="sqlEditorBtn"
:active="panel === 'sqlEditor'"
tooltip="Switch panel to SQL editor"
tooltip-position="top-left"
@click="$emit('switchTo', 'sqlEditor')"
>
<sql-editor-icon />
</icon-button>
<icon-button
ref="tableBtn"
:active="panel === 'table'"
tooltip="Switch panel to result set"
tooltip-position="top-left"
@click="$emit('switchTo', 'table')"
>
<table-icon />
</icon-button>
<icon-button
ref="dataViewBtn"
:active="panel === 'dataView'"
tooltip="Switch panel to data view"
tooltip-position="top-left"
@click="$emit('switchTo', 'dataView')"
>
<data-view-icon />
</icon-button>
<div v-if="$slots.default" class="side-tool-bar-divider" />
<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',
components: {
IconButton,
SqlEditorIcon,
DataViewIcon,
TableIcon
},
props: {
panel: String
},
emits: ['switchTo']
}
</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

@@ -0,0 +1,58 @@
import CM from 'codemirror'
import 'codemirror/addon/hint/show-hint.js'
import 'codemirror/addon/hint/sql-hint.js'
import store from '@/store'
function _getHintText(hint) {
return typeof hint === 'string' ? hint : hint.text
}
export function getHints(cm, options) {
const result = CM.hint.sql(cm, options)
// Don't show the hint if there is only one option
// and the replacingText is already equals to this option
const replacedText = cm.getRange(result.from, result.to).toUpperCase()
if (
result.list.length === 1 &&
_getHintText(result.list[0]).toUpperCase() === replacedText
) {
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)
}

View File

@@ -0,0 +1,112 @@
<template>
<div class="sql-editor-panel">
<div class="codemirror-box original-style">
<codemirror
ref="cm"
v-model:value="query"
:options="cmOptions"
:original-style="true"
@change="onChange"
/>
</div>
<side-tool-bar panel="sqlEditor" @switch-to="$emit('switchTo', $event)">
<icon-button
ref="runBtn"
: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 'codemirror-editor-vue3'
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',
components: {
Codemirror,
SideToolBar,
IconButton,
RunIcon
},
props: { modelValue: String, isGettingResults: Boolean },
emits: ['update:modelValue', 'run', 'switchTo'],
data() {
return {
query: this.modelValue,
cmOptions: {
tabSize: 4,
mode: 'text/x-mysql',
theme: 'neo',
lineNumbers: true,
line: true,
autoRefresh: true,
styleActiveLine: false,
extraKeys: { 'Ctrl-Space': showHintOnDemand }
}
}
},
computed: {
runDisabled() {
return !this.$store.state.db || !this.query || this.isGettingResults
}
},
watch: {
query() {
this.$emit('update:modelValue', this.query)
}
},
methods: {
onChange: time.debounce((value, editor) => showHint(editor), 400),
focus() {
this.$refs.cm.cminstance?.focus()
}
}
}
</script>
<style scoped>
.sql-editor-panel {
display: flex;
flex-grow: 1;
height: 100%;
max-height: 100%;
box-sizing: border-box;
overflow: hidden;
}
.codemirror-box {
flex-grow: 1;
overflow: auto;
}
:deep(.codemirror-container) {
display: block;
height: 100%;
max-height: 100%;
}
:deep(.CodeMirror) {
height: 100%;
max-height: 100%;
}
:deep(.CodeMirror-cursor) {
width: 1px;
background: var(--color-text-base);
}
</style>

View File

@@ -0,0 +1,176 @@
<template>
<div v-show="isActive" class="tab-content-container">
<splitpanes
class="query-results-splitter"
horizontal
:before="{ size: topPaneSize, max: 100 }"
:after="{ size: 100 - topPaneSize, max: 100 }"
:default="{ before: 50, after: 50 }"
>
<template #left-pane>
<div :id="'above-' + tab.id" class="above" />
</template>
<template #right-pane>
<div :id="'bottom-' + tab.id" ref="bottomPane" class="bottomPane" />
</template>
</splitpanes>
<div :id="'hidden-' + tab.id" class="hidden-part" />
<teleport
defer
:to="enableTeleport ? `#${tab.layout.sqlEditor}-${tab.id}` : undefined"
:disabled="!enableTeleport"
>
<sql-editor
ref="sqlEditor"
v-model="tab.query"
:is-getting-results="tab.isGettingResults"
@switch-to="onSwitchView('sqlEditor', $event)"
@run="tab.execute()"
/>
</teleport>
<teleport
defer
:to="enableTeleport ? `#${tab.layout.table}-${tab.id}` : undefined"
:disabled="!enableTeleport"
>
<run-result
:tab="tab"
:result="tab.result"
:is-getting-results="tab.isGettingResults"
:error="tab.error"
:time="tab.time"
@switch-to="onSwitchView('table', $event)"
/>
</teleport>
<teleport
defer
:to="enableTeleport ? `#${tab.layout.dataView}-${tab.id}` : undefined"
:disabled="!enableTeleport"
>
<data-view
ref="dataView"
:data-source="(tab.result && tab.result.values) || null"
:init-options="tab.viewOptions"
:init-mode="tab.viewType"
@switch-to="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 { nextTick } from 'vue'
import events from '@/lib/utils/events'
export default {
name: 'Tab',
components: {
SqlEditor,
DataView,
RunResult,
Splitpanes
},
props: {
tab: Object
},
emits: [],
data() {
return {
topPaneSize: this.tab.maximize
? this.tab.layout[this.tab.maximize] === 'above'
? 100
: 0
: 50,
enableTeleport: this.$store.state.isWorkspaceVisible
}
},
computed: {
isActive() {
return this.tab.id === this.$store.state.currentTabId
}
},
watch: {
isActive: {
immediate: true,
async handler() {
if (this.isActive) {
await nextTick()
this.$refs.sqlEditor?.focus()
}
}
},
'tab.query'() {
this.$store.commit('updateTab', {
tab: this.tab,
newValues: { isSaved: false }
})
}
},
async activated() {
this.enableTeleport = true
if (this.isActive) {
await nextTick()
this.$refs.sqlEditor.focus()
}
},
deactivated() {
this.enableTeleport = false
},
async mounted() {
this.tab.dataView = this.$refs.dataView
},
methods: {
onSwitchView(from, to) {
const fromPosition = this.tab.layout[from]
this.tab.layout[from] = this.tab.layout[to]
this.tab.layout[to] = fromPosition
window.dispatchEvent(new Event('resize'))
events.send('inquiry.panel', null, { panel: to })
},
onDataViewUpdate() {
this.$store.commit('updateTab', {
tab: this.tab,
newValues: { isSaved: 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

@@ -0,0 +1,212 @@
<template>
<div id="tabs">
<div v-if="tabs.length > 0" id="tabs-header">
<div
v-for="(tab, index) in tabs"
:key="index"
:class="[{ 'tab-selected': tab.id === selectedTabId }, 'tab']"
@click="selectTab(tab.id)"
>
<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(tab)"
/>
</div>
</div>
</div>
<tab v-for="tab in tabs" :key="tab.id" :tab="tab" />
<div v-show="tabs.length === 0" id="start-guide">
<span class="link" @click="emitCreateTabEvent">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 modal-id="close-warn" class="dialog" content-style="width: 560px;">
<div class="dialog-header">
Close tab
{{
closingTab !== null
? closingTab.name || `[${closingTab.tempName}]`
: ''
}}
<close-icon @click="$modal.hide('close-warn')" />
</div>
<div class="dialog-body">
You have unsaved changes. Save changes in
{{
closingTab !== null
? closingTab.name || `[${closingTab.tempName}]`
: ''
}}
before closing?
</div>
<div class="dialog-buttons-container">
<button class="secondary" @click="closeTab(closingTab)">
Close without saving
</button>
<button class="secondary" @click="$modal.hide('close-warn')">
Don't close
</button>
<button class="primary" @click="saveAndClose(closingTab)">
Save and close
</button>
</div>
</modal>
</div>
</template>
<script>
import Tab from './Tab'
import CloseIcon from '@/components/svg/close'
import eventBus from '@/lib/eventBus'
export default {
components: {
Tab,
CloseIcon
},
emits: [],
data() {
return {
closingTab: null
}
},
computed: {
tabs() {
return this.$store.state.tabs
},
selectedTabId() {
return this.$store.state.currentTabId
}
},
created() {
window.addEventListener('beforeunload', this.leavingSqliteviz)
},
methods: {
emitCreateTabEvent() {
eventBus.$emit('createNewInquiry')
},
leavingSqliteviz(event) {
if (this.tabs.some(tab => !tab.isSaved)) {
event.preventDefault()
event.returnValue = ''
}
},
selectTab(id) {
this.$store.commit('setCurrentTabId', id)
},
beforeCloseTab(tab) {
this.closingTab = tab
if (!tab.isSaved) {
this.$modal.show('close-warn')
} else {
this.closeTab(tab)
}
},
closeTab(tab) {
this.$modal.hide('close-warn')
this.$store.commit('deleteTab', tab)
},
saveAndClose(tab) {
eventBus.$on('inquirySaved', () => {
this.closeTab(tab)
eventBus.$off('inquirySaved')
})
this.selectTab(tab.id)
this.$modal.hide('close-warn')
this.$nextTick(() => {
eventBus.$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>

View File

@@ -0,0 +1,80 @@
<template>
<div>
<splitpanes
class="schema-tabs-splitter"
:before="{ size: schemaWidth, max: 30 }"
:after="{ size: 100 - schemaWidth, max: 100 }"
:default="{ before: 20, after: 80 }"
>
<template #left-pane>
<schema />
</template>
<template #right-pane>
<tabs />
</template>
</splitpanes>
</div>
</template>
<script>
import Splitpanes from '@/components/Splitpanes'
import Schema from './Schema'
import Tabs from './Tabs'
import events from '@/lib/utils/events'
export default {
name: 'Workspace',
components: {
Schema,
Splitpanes,
Tabs
},
data() {
return {
schemaWidth: this.$route.query.hide_schema === '1' ? 0 : 20
}
},
async beforeCreate() {
const schema = this.$store.state.db.schema
if (
(!schema || schema.length === 0) &&
this.$store.state.tabs.length === 0
) {
const stmt = [
'/*',
' * Your database is empty. In order to start building charts',
' * you should create a table and insert data into it.',
' */',
'CREATE TABLE house',
'(',
' name TEXT,',
' points INTEGER',
');',
'INSERT INTO house VALUES',
"('Gryffindor', 100),",
"('Hufflepuff', 90),",
"('Ravenclaw', 95),",
"('Slytherin', 80);"
].join('\n')
const tabId = await this.$store.dispatch('addTab', { query: stmt })
this.$store.commit('setCurrentTabId', tabId)
events.send('inquiry.create', null, { auto: true })
}
},
activated() {
this.$store.commit('setIsWorkspaceVisible', true)
},
deactivated() {
this.$store.commit('setIsWorkspaceVisible', false)
}
}
</script>
<style scoped>
.schema-tabs-splitter {
height: 100%;
background-color: var(--color-white);
}
</style>