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,98 @@
<template>
<div id="app-info-container">
<img
id="app-info-icon"
src="~@/assets/images/info.svg"
@click="$modal.show('app-info')"
/>
<modal modal-id="app-info" class="dialog" content-class="app-info-modal">
<div class="dialog-header">
App info
<close-icon @click="$modal.hide('app-info')" />
</div>
<div class="dialog-body">
<div v-for="(item, index) in info" :key="index" class="info-item">
{{ item.name }}
<div class="divider" />
<div class="options">
<div v-for="(opt, optIndex) in item.info" :key="optIndex">
{{ opt }}
</div>
</div>
</div>
</div>
</modal>
</div>
</template>
<script>
import CloseIcon from '@/components/svg/close'
import { version } from '../../../package.json'
export default {
name: 'AppDiagnosticInfo',
components: { CloseIcon },
data() {
return {
info: [
{
name: 'sqliteviz version',
info: [version]
}
]
}
},
async created() {
const state = this.$store.state
let result = (await state.db.execute('select sqlite_version()')).values
this.info.push({
name: 'SQLite version',
info: result['sqlite_version()']
})
result = (await state.db.execute('PRAGMA compile_options')).values
this.info.push({
name: 'SQLite compile options',
info: result.compile_options
})
}
}
</script>
<style>
.app-info-modal {
width: 400px;
}
</style>
<style scoped>
#app-info-icon {
cursor: pointer;
width: 24px;
}
#app-info-container {
display: flex;
justify-content: center;
margin-left: 32px;
}
.divider {
height: 1px;
background-color: var(--color-border);
margin: 4px 0;
}
.options {
font-family: monospace;
font-size: 13px;
margin-left: 8px;
overflow: auto;
max-height: 170px;
}
.info-item {
margin-bottom: 32px;
font-size: 14px;
}
.info-item:last-child {
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,649 @@
<template>
<div id="my-inquiries-container">
<div v-if="allInquiries.length === 0" id="start-guide">
You don't have saved inquiries so far.
<span class="link" @click="emitCreateTabEvent">Create</span>
the one from scratch or
<span class="link" @click="importInquiries">import</span> from a file.
</div>
<div
v-if="$store.state.loadingPredefinedInquiries"
id="loading-predefined-status"
>
<loading-indicator />
Loading predefined inquiries...
</div>
<div
v-show="allInquiries.length > 0"
id="my-inquiries-content"
ref="my-inquiries-content"
>
<div id="my-inquiries-toolbar">
<div id="toolbar-buttons">
<button
id="toolbar-btns-import"
class="toolbar"
@click="importInquiries"
>
Import
</button>
<button
v-show="selectedInquiriesCount > 0"
id="toolbar-btns-export"
class="toolbar"
@click="exportSelectedInquiries()"
>
Export
</button>
<button
v-show="selectedNotPredefinedCount > 0"
id="toolbar-btns-delete"
class="toolbar"
@click="showDeleteDialog(selectedInquiriesIds)"
>
Delete
</button>
</div>
<div id="toolbar-search">
<text-field
v-model="filter"
placeholder="Search inquiry by name"
width="300px"
/>
</div>
</div>
<div v-show="showedInquiries.length === 0" id="inquiries-not-found">
No inquiries found
</div>
<div v-show="showedInquiries.length > 0" class="rounded-bg">
<div class="header-container">
<div>
<div ref="name-th" class="fixed-header">
<check-box
ref="mainCheckBox"
theme="light"
@click="toggleSelectAll"
/>
<div class="name-th">Name</div>
</div>
<div class="fixed-header">Created at</div>
</div>
</div>
<div
class="table-container"
:style="{ 'max-height': `${maxTableHeight}px` }"
>
<table ref="table" class="sqliteviz-table">
<tbody>
<tr
v-for="(inquiry, index) in showedInquiries"
:key="inquiry.id"
@click="openInquiry(index)"
>
<td ref="name-td">
<div class="cell-data">
<check-box
ref="rowCheckBox"
:init="selectAll || selectedInquiriesIds.has(inquiry.id)"
data-test="rowCheckBox"
@click="toggleRow($event, inquiry.id)"
/>
<div class="name">{{ inquiry.name }}</div>
<div
v-if="inquiry.isPredefined"
class="badge"
@mouseenter="showTooltip"
@mouseleave="hideTooltip"
>
Predefined
<span
ref="tooltip"
class="icon-tooltip"
:style="tooltipStyle"
>
Predefined inquiries come from the server. These
inquiries cant be deleted or renamed.
</span>
</div>
</div>
</td>
<td>
<div class="second-column">
<div class="date-container">
{{ createdAtFormatted(inquiry.createdAt) }}
</div>
<div class="icons-container">
<rename-icon
v-if="!inquiry.isPredefined"
@click="showRenameDialog(inquiry.id)"
/>
<copy-icon @click="duplicateInquiry(index)" />
<export-icon
tooltip="Export inquiry to file"
tooltip-position="top-left"
@click="exportToFile([inquiry], `${inquiry.name}.json`)"
/>
<delete-icon
v-if="!inquiry.isPredefined"
@click="showDeleteDialog(new Set().add(inquiry.id))"
/>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!--Rename Inquiry dialog -->
<modal modal-id="rename" class="dialog" content-style="width: 560px;">
<div class="dialog-header">
Rename inquiry
<close-icon @click="$modal.hide('rename')" />
</div>
<div class="dialog-body">
<text-field
v-model="newName"
label="New inquiry name"
:error-msg="errorMsg"
width="100%"
/>
</div>
<div class="dialog-buttons-container">
<button class="secondary" @click="$modal.hide('rename')">Cancel</button>
<button class="primary" @click="renameInquiry">Rename</button>
</div>
</modal>
<!--Delete Inquiry dialog -->
<modal modal-id="delete" class="dialog" content-style="width: 480px;">
<div class="dialog-header">
Delete {{ deleteGroup ? 'inquiries' : 'inquiry' }}
<close-icon @click="$modal.hide('delete')" />
</div>
<div class="dialog-body">
{{ deleteDialogMsg }}
<div
v-show="selectedInquiriesCount > selectedNotPredefinedCount"
id="note"
>
<img src="~@/assets/images/info.svg" />
Note: Predefined inquiries you've selected won't be deleted
</div>
</div>
<div class="dialog-buttons-container">
<button class="secondary" @click="$modal.hide('delete')">Cancel</button>
<button class="primary" @click="deleteInquiry">Delete</button>
</div>
</modal>
</div>
</template>
<script>
import RenameIcon from './svg/rename'
import CopyIcon from './svg/copy'
import ExportIcon from '@/components/svg/export'
import DeleteIcon from './svg/delete'
import CloseIcon from '@/components/svg/close'
import TextField from '@/components/TextField'
import CheckBox from '@/components/CheckBox'
import LoadingIndicator from '@/components/LoadingIndicator'
import tooltipMixin from '@/tooltipMixin'
import storedInquiries from '@/lib/storedInquiries'
import eventBus from '@/lib/eventBus'
export default {
name: 'Inquiries',
components: {
RenameIcon,
CopyIcon,
ExportIcon,
DeleteIcon,
CloseIcon,
TextField,
CheckBox,
LoadingIndicator
},
mixins: [tooltipMixin],
data() {
return {
filter: null,
newName: null,
processedInquiryId: null,
errorMsg: null,
selectedInquiriesIds: new Set(),
selectedInquiriesCount: 0,
selectedNotPredefinedCount: 0,
selectAll: false,
deleteGroup: false,
resizeObserver: null,
maxTableHeight: 0
}
},
computed: {
inquiries() {
return this.$store.state.inquiries
},
predefinedInquiries() {
return this.$store.state.predefinedInquiries.map(inquiry => {
inquiry.isPredefined = true
return inquiry
})
},
predefinedInquiriesIds() {
return new Set(this.predefinedInquiries.map(inquiry => inquiry.id))
},
showedInquiries() {
let showedInquiries = this.allInquiries
if (this.filter) {
showedInquiries = showedInquiries.filter(
inquiry =>
inquiry.name.toUpperCase().indexOf(this.filter.toUpperCase()) >= 0
)
}
return showedInquiries
},
allInquiries() {
return this.predefinedInquiries.concat(this.inquiries)
},
processedInquiryIndex() {
return this.inquiries.findIndex(
inquiry => inquiry.id === this.processedInquiryId
)
},
deleteDialogMsg() {
if (
!this.deleteGroup &&
(this.processedInquiryIndex === null ||
this.processedInquiryIndex < 0 ||
this.processedInquiryIndex > this.inquiries.length)
) {
return ''
}
const deleteItem = this.deleteGroup
? `${this.selectedNotPredefinedCount} ${
this.selectedNotPredefinedCount > 1 ? 'inquiries' : 'inquiry'
}`
: `"${this.inquiries[this.processedInquiryIndex].name}"`
return `Are you sure you want to delete ${deleteItem}?`
}
},
watch: {
showedInquiries: {
handler() {
this.selectedInquiriesIds = new Set(
this.showedInquiries
.filter(inquiry => this.selectedInquiriesIds.has(inquiry.id))
.map(inquiry => inquiry.id)
)
this.selectedInquiriesCount = this.selectedInquiriesIds.size
this.selectedNotPredefinedCount = [...this.selectedInquiriesIds].filter(
id => !this.predefinedInquiriesIds.has(id)
).length
if (this.selectedInquiriesIds.size < this.showedInquiries.length) {
if (this.$refs.mainCheckBox) {
this.$refs.mainCheckBox.checked = false
}
this.selectAll = false
}
},
deep: true
}
},
async created() {
const loadingPredefinedInquiries =
this.$store.state.loadingPredefinedInquiries
const predefinedInquiriesLoaded =
this.$store.state.predefinedInquiriesLoaded
if (!predefinedInquiriesLoaded && !loadingPredefinedInquiries) {
try {
this.$store.commit('setLoadingPredefinedInquiries', true)
const inquiries = await storedInquiries.readPredefinedInquiries()
this.$store.commit('updatePredefinedInquiries', inquiries)
this.$store.commit('setPredefinedInquiriesLoaded', true)
} catch (e) {
console.error(e)
}
this.$store.commit('setLoadingPredefinedInquiries', false)
}
},
mounted() {
this.resizeObserver = new ResizeObserver(this.calcMaxTableHeight)
this.resizeObserver.observe(this.$refs['my-inquiries-content'])
this.tableResizeObserver = new ResizeObserver(this.calcNameWidth)
this.tableResizeObserver.observe(this.$refs.table)
this.calcNameWidth()
this.calcMaxTableHeight()
},
beforeUnmount() {
this.resizeObserver.unobserve(this.$refs['my-inquiries-content'])
this.tableResizeObserver.unobserve(this.$refs.table)
},
methods: {
emitCreateTabEvent() {
eventBus.$emit('createNewInquiry')
},
createdAtFormatted(value) {
if (!value) {
return ''
}
const dateOptions = { year: 'numeric', month: 'long', day: 'numeric' }
const timeOptions = {
hour12: false,
hour: '2-digit',
minute: '2-digit'
}
return (
new Date(value).toLocaleDateString('en-GB', dateOptions) +
' ' +
new Date(value).toLocaleTimeString('en-GB', timeOptions)
)
},
calcNameWidth() {
const nameWidth =
this.$refs['name-td'] && this.$refs['name-td'][0]
? this.$refs['name-td'][0].getBoundingClientRect().width
: 0
this.$refs['name-th'].style = `width: ${nameWidth}px`
},
calcMaxTableHeight() {
const freeSpace = this.$refs['my-inquiries-content'].offsetHeight - 200
this.maxTableHeight = freeSpace - (freeSpace % 40) + 1
},
openInquiry(index) {
const tab = this.showedInquiries[index]
setTimeout(() => {
this.$store.dispatch('addTab', tab).then(id => {
this.$store.commit('setCurrentTabId', id)
this.$router.push('/workspace')
})
})
},
showRenameDialog(id) {
this.errorMsg = null
this.processedInquiryId = id
this.newName = this.inquiries[this.processedInquiryIndex].name
this.$modal.show('rename')
},
renameInquiry() {
if (!this.newName) {
this.errorMsg = "Inquiry name can't be empty"
return
}
this.$store.dispatch('renameInquiry', {
inquiryId: this.processedInquiryId,
newName: this.newName
})
// hide dialog
this.$modal.hide('rename')
},
duplicateInquiry(index) {
const newInquiry = storedInquiries.duplicateInquiry(
this.showedInquiries[index]
)
this.$store.dispatch('addInquiry', newInquiry)
},
showDeleteDialog(idsSet) {
this.deleteGroup = idsSet.size > 1
if (!this.deleteGroup) {
this.processedInquiryId = idsSet.values().next().value
}
this.$modal.show('delete')
},
deleteInquiry() {
this.$modal.hide('delete')
if (!this.deleteGroup) {
this.$store.dispatch(
'deleteInquiries',
new Set().add(this.processedInquiryId)
)
// Clear checkbox
if (this.selectedInquiriesIds.has(this.processedInquiryId)) {
this.selectedInquiriesIds.delete(this.processedInquiryId)
}
} else {
this.$store.dispatch('deleteInquiries', this.selectedInquiriesIds)
// Clear checkboxes
this.selectedInquiriesIds.clear()
}
this.selectedInquiriesCount = this.selectedInquiriesIds.size
},
exportToFile(inquiryList, fileName) {
storedInquiries.export(inquiryList, fileName)
},
exportSelectedInquiries() {
const inquiryList = this.allInquiries.filter(inquiry =>
this.selectedInquiriesIds.has(inquiry.id)
)
this.exportToFile(inquiryList, 'My sqliteviz inquiries.json')
},
importInquiries() {
storedInquiries.importInquiries().then(importedInquiries => {
this.$store.commit(
'setInquiries',
this.inquiries.concat(importedInquiries)
)
})
},
toggleSelectAll(checked) {
this.selectAll = checked
this.$refs.rowCheckBox.forEach(item => {
item.checked = checked
})
this.selectedInquiriesIds = checked
? new Set(this.showedInquiries.map(inquiry => inquiry.id))
: new Set()
this.selectedInquiriesCount = this.selectedInquiriesIds.size
this.selectedNotPredefinedCount = checked
? [...this.selectedInquiriesIds].filter(
id => !this.predefinedInquiriesIds.has(id)
).length
: 0
},
toggleRow(checked, id) {
const isPredefined = this.predefinedInquiriesIds.has(id)
if (checked) {
this.selectedInquiriesIds.add(id)
if (!isPredefined) {
this.selectedNotPredefinedCount += 1
}
} else {
if (this.selectedInquiriesIds.size === this.showedInquiries.length) {
this.$refs.mainCheckBox.checked = false
this.selectAll = false
}
this.selectedInquiriesIds.delete(id)
if (!isPredefined) {
this.selectedNotPredefinedCount -= 1
}
}
this.selectedInquiriesCount = this.selectedInquiriesIds.size
}
}
}
</script>
<style scoped>
#my-inquiries-container {
position: relative;
}
#loading-predefined-status {
position: absolute;
right: 0;
display: flex;
gap: 4px;
font-size: 12px;
color: var(--color-text-light-2);
align-items: center;
padding: 8px;
}
#start-guide {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--color-text-base);
font-size: 14px;
text-align: center;
}
#inquiries-not-found {
padding: 35px 5px;
border-radius: 5px;
border: 1px solid var(--color-border-light);
color: var(--color-text-base);
font-size: 14px;
text-align: center;
}
#my-inquiries-content {
padding: 52px;
height: 100%;
box-sizing: border-box;
}
#my-inquiries-toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 18px;
margin: 0 auto 8px;
max-width: 1500px;
width: 100%;
}
.rounded-bg {
padding-top: 40px;
margin: 0 auto;
max-width: 1500px;
width: 100%;
}
.fixed-header {
padding: 11px 24px;
}
.fixed-header:first-child {
display: flex;
align-items: center;
padding-left: 12px;
}
.fixed-header:first-child .name-th {
margin-left: 24px;
}
table.sqliteviz-table {
margin-top: 0;
}
.sqliteviz-table tbody tr td {
min-width: 0;
height: 40px;
}
.sqliteviz-table tbody tr td:first-child {
width: 70%;
max-width: 0;
padding: 0 12px;
}
.sqliteviz-table tbody tr td:last-child {
width: 30%;
max-width: 0;
padding: 0 24px;
}
.sqliteviz-table tbody .cell-data {
display: flex;
align-items: center;
max-width: 100%;
width: 100%;
}
.sqliteviz-table tbody .cell-data div.name {
overflow: hidden;
text-overflow: ellipsis;
margin-left: 24px;
}
.sqliteviz-table tbody tr:hover td {
cursor: pointer;
}
.sqliteviz-table tbody tr:hover td {
color: var(--color-text-active);
}
.sqliteviz-table .second-column {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
max-width: 100%;
}
.icons-container {
display: none;
margin-right: -12px;
}
.date-container {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.sqliteviz-table tbody tr:hover .icons-container {
display: flex;
}
.dialog input {
width: 100%;
}
button.toolbar {
margin-right: 16px;
}
.badge {
display: none;
background-color: var(--color-gray-light-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-small);
padding: 2px 6px;
font-size: 11px;
line-height: normal;
margin-left: 12px;
}
.sqliteviz-table tbody tr:hover .badge {
display: block;
}
#note {
margin-top: 24px;
}
#note img {
vertical-align: middle;
}
.icon-tooltip {
display: block;
width: 149px;
white-space: normal;
height: auto;
line-height: normal;
padding: 6px;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<span>
<svg
class="icon"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
@click.stop="onClick"
@mouseenter="showTooltip"
@mouseleave="hideTooltip"
>
<path
d="M14.25 15.75H6V5.25H14.25V15.75ZM14.25 3.75H6C5.60218 3.75 5.22064 3.90804 4.93934
4.18934C4.65804 4.47064 4.5 4.85218 4.5 5.25V15.75C4.5 16.1478 4.65804 16.5294 4.93934
16.8107C5.22064 17.092 5.60218 17.25 6 17.25H14.25C14.6478 17.25 15.0294 17.092 15.3107
16.8107C15.592 16.5294 15.75 16.1478 15.75 15.75V5.25C15.75 4.85218 15.592 4.47064
15.3107 4.18934C15.0294 3.90804 14.6478 3.75 14.25 3.75ZM12 0.75H3C2.60218 0.75 2.22064
0.908035 1.93934 1.18934C1.65804 1.47064 1.5 1.85218 1.5 2.25V12.75H3V2.25H12V0.75Z"
fill="#A2B1C6"
/>
</svg>
<span ref="tooltip" class="icon-tooltip" :style="tooltipStyle">
Duplicate inquiry
</span>
</span>
</template>
<script>
import tooltipMixin from '@/tooltipMixin'
export default {
name: 'CopyIcon',
mixins: [tooltipMixin],
emits: ['click'],
methods: {
onClick() {
this.hideTooltip()
this.$emit('click')
}
}
}
</script>
<style scoped>
.icon {
display: block;
margin: 0 12px;
}
.icon:hover path {
fill: var(--color-accent);
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<span>
<svg
class="icon"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
@click.stop="onClick"
@mouseenter="showTooltip($event, 'top-left')"
@mouseleave="hideTooltip"
>
<path
d="M6.75 2.25V3H3V4.5H3.75V14.25C3.75 14.6478 3.90804 15.0294 4.18934 15.3107C4.47064
15.592 4.85218 15.75 5.25 15.75H12.75C13.1478 15.75 13.5294 15.592 13.8107
15.3107C14.092 15.0294 14.25 14.6478 14.25 14.25V4.5H15V3H11.25V2.25H6.75ZM5.25
4.5H12.75V14.25H5.25V4.5ZM6.75 6V12.75H8.25V6H6.75ZM9.75 6V12.75H11.25V6H9.75Z"
fill="#A2B1C6"
/>
</svg>
<span ref="tooltip" class="icon-tooltip" :style="tooltipStyle">
Delete inquiry
</span>
</span>
</template>
<script>
import tooltipMixin from '@/tooltipMixin'
export default {
name: 'DeleteIcon',
mixins: [tooltipMixin],
emits: ['click'],
methods: {
onClick() {
this.hideTooltip()
this.$emit('click')
}
}
}
</script>
<style scoped>
.icon {
display: block;
margin: 0 12px;
}
.icon:hover path {
fill: var(--color-accent);
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<span>
<svg
class="icon"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
@click.stop="onClick"
@mouseenter="showTooltip"
@mouseleave="hideTooltip"
>
<path
d="M10.545 6.75L11.25 7.455L4.44 14.25H3.75V13.56L10.545 6.75ZM13.245 2.25C13.0575 2.25
12.8625 2.325 12.72 2.4675L11.3475 3.84L14.16 6.6525L15.5325 5.28C15.825 4.9875 15.825
4.5 15.5325 4.2225L13.7775 2.4675C13.6275 2.3175 13.44 2.25 13.245 2.25ZM10.545
4.6425L2.25 12.9375V15.75H5.0625L13.3575 7.455L10.545 4.6425Z"
fill="#A2B1C6"
/>
</svg>
<span ref="tooltip" class="icon-tooltip" :style="tooltipStyle">
Rename inquiry
</span>
</span>
</template>
<script>
import tooltipMixin from '@/tooltipMixin'
export default {
name: 'RenameIcon',
mixins: [tooltipMixin],
emits: ['click'],
methods: {
onClick() {
this.hideTooltip()
this.$emit('click')
}
}
}
</script>
<style scoped>
.icon {
display: block;
margin: 0 12px;
}
.icon:hover path {
fill: var(--color-accent);
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<nav>
<div id="nav-links">
<a href="https://sqliteviz.com">
<img src="~@/assets/images/logo_simple.svg" />
</a>
<router-link to="/workspace">Workspace</router-link>
<router-link to="/inquiries">Inquiries</router-link>
<a href="https://sqliteviz.com/docs" target="_blank">Help</a>
</div>
<div id="nav-buttons">
<button
v-show="currentInquiry && $route.path === '/workspace'"
id="save-btn"
class="primary"
:disabled="isSaved"
@click="checkInquiryBeforeSave"
>
Save
</button>
<button id="create-btn" class="primary" @click="createNewInquiry">
Create
</button>
<app-diagnostic-info />
</div>
<!--Save Inquiry dialog -->
<modal modal-id="save" class="dialog" content-style="width: 560px;">
<div class="dialog-header">
Save inquiry
<close-icon @click="cancelSave" />
</div>
<div class="dialog-body">
<div v-show="isPredefined" id="save-note">
<img src="~@/assets/images/info.svg" />
Note: Predefined inquiries can't be edited. That's why your
modifications will be saved as a new inquiry. Enter the name for it.
</div>
<text-field
v-model="name"
label="Inquiry name"
:error-msg="errorMsg"
width="100%"
/>
</div>
<div class="dialog-buttons-container">
<button class="secondary" @click="cancelSave">Cancel</button>
<button class="primary" @click="saveInquiry">Save</button>
</div>
</modal>
</nav>
</template>
<script>
import TextField from '@/components/TextField'
import CloseIcon from '@/components/svg/close'
import storedInquiries from '@/lib/storedInquiries'
import AppDiagnosticInfo from './AppDiagnosticInfo'
import events from '@/lib/utils/events'
import eventBus from '@/lib/eventBus'
export default {
name: 'MainMenu',
components: {
TextField,
CloseIcon,
AppDiagnosticInfo
},
data() {
return {
name: '',
errorMsg: null
}
},
computed: {
currentInquiry() {
return this.$store.state.currentTab
},
isSaved() {
return this.currentInquiry && this.currentInquiry.isSaved
},
isPredefined() {
return this.currentInquiry && this.currentInquiry.isPredefined
},
runDisabled() {
return (
this.currentInquiry &&
(!this.$store.state.db || !this.currentInquiry.query)
)
}
},
created() {
eventBus.$on('createNewInquiry', this.createNewInquiry)
eventBus.$on('saveInquiry', this.checkInquiryBeforeSave)
document.addEventListener('keydown', this._keyListener)
},
beforeUnmount() {
document.removeEventListener('keydown', this._keyListener)
},
methods: {
createNewInquiry() {
this.$store.dispatch('addTab').then(id => {
this.$store.commit('setCurrentTabId', id)
if (this.$route.path !== '/workspace') {
this.$router.push('/workspace')
}
})
events.send('inquiry.create', null, { auto: false })
},
cancelSave() {
this.$modal.hide('save')
eventBus.$off('inquirySaved')
},
checkInquiryBeforeSave() {
this.errorMsg = null
this.name = ''
if (storedInquiries.isTabNeedName(this.currentInquiry)) {
this.$modal.show('save')
} else {
this.saveInquiry()
}
},
async saveInquiry() {
const isNeedName = storedInquiries.isTabNeedName(this.currentInquiry)
if (isNeedName && !this.name) {
this.errorMsg = "Inquiry name can't be empty"
return
}
const dataSet = this.currentInquiry.result
const tabView = this.currentInquiry.view
// Save inquiry
const value = await this.$store.dispatch('saveInquiry', {
inquiryTab: this.currentInquiry,
newName: this.name
})
// Update tab in store
this.$store.commit('updateTab', {
tab: this.currentInquiry,
newValues: {
name: value.name,
id: value.id,
query: value.query,
viewType: value.viewType,
viewOptions: value.viewOptions,
isSaved: true
}
})
// Restore data:
// e.g. if we save predefined inquiry the tab will be created again
// (because of new id) and
// it will be without sql result and has default view - table.
// That's why we need to restore data and view
this.$nextTick(() => {
this.currentInquiry.result = dataSet
this.currentInquiry.view = tabView
})
// Hide dialog
this.$modal.hide('save')
// Signal about saving
eventBus.$emit('inquirySaved')
events.send('inquiry.save')
},
_keyListener(e) {
if (this.$route.path === '/workspace') {
// Run query Ctrl+R or Ctrl+Enter
if ((e.key === 'r' || e.key === 'Enter') && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
if (!this.runDisabled) {
this.currentInquiry.execute()
}
return
}
// Save inquiry Ctrl+S
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
if (!this.isSaved) {
this.checkInquiryBeforeSave()
}
return
}
}
// New (blank) inquiry Ctrl+B
if (e.key === 'b' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
this.createNewInquiry()
}
}
}
}
</script>
<style scoped>
nav {
height: 68px;
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--color-bg-light);
border-bottom: 1px solid var(--color-border-light);
box-shadow: var(--shadow-1);
box-sizing: border-box;
position: fixed;
top: 0;
left: 0;
width: 100vw;
padding: 0 16px 0 52px;
z-index: 999;
}
a {
font-size: 18px;
color: var(--color-text-base);
text-transform: none;
text-decoration: none;
margin-right: 28px;
}
a.router-link-active {
color: var(--color-accent);
}
button {
margin-left: 16px;
}
#save-note {
margin-bottom: 24px;
display: flex;
align-items: flex-start;
}
#save-note img {
margin: -3px 6px 0 0;
}
#nav-buttons {
display: flex;
}
#nav-links {
display: flex;
align-items: center;
}
#nav-links img {
width: 32px;
}
</style>

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>

View File

@@ -0,0 +1,27 @@
<template>
<div>
<main-menu />
<router-view id="main-view" v-slot="{ Component }">
<keep-alive include="Workspace,Inquiries">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</template>
<script>
import MainMenu from './MainMenu'
import '@/assets/styles/scrollbars.css'
export default {
name: 'MainView',
components: { MainMenu }
}
</script>
<style scoped>
#main-view {
margin-top: 68px;
height: calc(100vh - 68px);
overflow-y: auto;
}
</style>